Skip to content

Commit 0ff837a

Browse files
Merge pull request #9 from connortessaro/copilot/improve-functionality-and-fixes
Fix 5 bugs across URL inference, validation pipeline, Liquid output, and store setup
2 parents e15083c + 5bd2183 commit 0ff837a

File tree

13 files changed

+461
-206
lines changed

13 files changed

+461
-206
lines changed

GEMINI.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# GEMINI.md
2+
3+
## Project Context
4+
Shopify Web Replicator is a monorepo containing a replication engine, an MCP server, a companion API, and a React frontend. It automates the replication of Shopify storefronts into a local theme workspace.
5+
6+
## Critical Mandates
7+
- **Build Order:** You MUST build packages in the following order: `packages/shared``packages/engine``apps/api` | `apps/web` | `apps/mcp`.
8+
- **Logic Duplication:** `apps/api/src/services/` contains intentional duplicates of services in `packages/engine`. When modifying engine logic, you MUST check if the corresponding service in `apps/api` needs mirroring.
9+
- **Stable Artifacts:** Never change the filenames of generated artifacts defined as constants in `packages/shared/src/job.ts`.
10+
- **Preflight Checks:** The MCP server (`apps/mcp`) runs preflight checks on every tool call. Ensure any new dependencies or system requirements are added to `runtime-preflight.ts`.
11+
12+
## Development Workflows
13+
14+
### Build & Verify
15+
- **Full Verification:** `pnpm build && pnpm test && pnpm typecheck && pnpm theme:check`
16+
- **Install:** `pnpm install --frozen-lockfile --ignore-scripts`
17+
- **Build All:** `pnpm build`
18+
- **Type-check:** `pnpm typecheck`
19+
- **Theme Linting:** `pnpm theme:check`
20+
21+
### Testing
22+
- **Run All Tests:** `pnpm test`
23+
- **Package Specific:** `pnpm --filter <package-name> test` (e.g., `@shopify-web-replicator/engine`)
24+
- **Single File:** `pnpm --filter <package-name> exec vitest run <path-to-test>`
25+
26+
### Local Execution
27+
- **Dev Mode:** `pnpm dev` (starts watchers and servers in parallel)
28+
- **Database:** SQLite is used, located at `.data/replicator.db` by default.
29+
30+
## Architectural Patterns
31+
- **Services:** All services depend on interfaces in `src/services/types.ts` to allow for easy testing with doubles.
32+
- **Persistence:** `SqliteJobRepository` handles job state persistence after each pipeline stage.
33+
- **Pipeline:** Deterministic sequence: `intake``analysis``mapping``theme_generation``store_setup``commerce_wiring``validation``integration_check``review`.
34+
35+
## Environment Variables
36+
| Variable | Default | Purpose |
37+
|---|---|---|
38+
| `REPLICATOR_DB_PATH` | `.data/replicator.db` | SQLite database path |
39+
| `THEME_WORKSPACE_PATH` | `packages/theme-workspace` | Target Shopify theme directory |
40+
| `REPLICATOR_CAPTURE_ROOT` | `.data/captures` | Playwright capture storage |
41+
| `PORT` | `8787` | API server port |

apps/api/src/services/integration-report-generator.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe("ShopifyIntegrationReportGenerator", () => {
153153
summary: "All deterministic integration checks passed for Example Storefront."
154154
});
155155
await expect(readFile(join(themeRoot, "config/generated-integration-report.json"), "utf8")).resolves.toContain(
156-
"\"generated_artifacts\""
156+
"\"generatedArtifacts\""
157157
);
158158
});
159159

apps/api/src/services/replication-pipeline.test.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe("ReplicationPipeline", () => {
3434

3535
const repository = new SqliteJobRepository(join(dataRoot, "replicator.db"));
3636
const job = createReplicationJob({
37-
referenceUrl: "https://example.com",
37+
referenceUrl: "https://example.com/offer",
3838
notes: "Landing page MVP"
3939
});
4040

@@ -159,7 +159,7 @@ describe("ReplicationPipeline", () => {
159159
readFile(join(themeRoot, "templates/page.generated-reference.json"), "utf8")
160160
).resolves.toContain('"type": "generated-reference"');
161161
await expect(readFile(join(themeRoot, "config/generated-store-setup.json"), "utf8")).resolves.toContain(
162-
"\"example-storefront\""
162+
"\"example-heading\""
163163
);
164164
await expect(readFile(join(themeRoot, "snippets/generated-commerce-wiring.liquid"), "utf8")).resolves.toContain(
165165
"/checkout"
@@ -556,4 +556,84 @@ describe("ReplicationPipeline", () => {
556556
])
557557
});
558558
});
559+
560+
it("still writes the integration report when validation fails (Bug 3)", async () => {
561+
const dataRoot = await mkdtemp(join(tmpdir(), "shopify-web-replicator-jobs-"));
562+
const themeRoot = await mkdtemp(join(tmpdir(), "shopify-web-replicator-theme-"));
563+
tempDirectories.push(dataRoot, themeRoot);
564+
565+
const repository = new SqliteJobRepository(join(dataRoot, "replicator.db"));
566+
const job = createReplicationJob({
567+
referenceUrl: "https://example.com",
568+
notes: "Landing page MVP"
569+
});
570+
571+
await repository.save(job);
572+
573+
const pipeline = new ReplicationPipeline({
574+
repository,
575+
analyzer: {
576+
async analyze({ referenceUrl, notes }) {
577+
return {
578+
sourceUrl: referenceUrl,
579+
pageType: "landing_page",
580+
title: "Example Storefront",
581+
summary: notes
582+
? `Prepared deterministic analysis for Example Storefront. Operator notes: ${notes}`
583+
: "Prepared deterministic analysis for Example Storefront.",
584+
analyzedAt: "2026-03-20T12:01:00.000Z",
585+
recommendedSections: ["hero", "cta"]
586+
} satisfies ReferenceAnalysis;
587+
}
588+
},
589+
mapper: {
590+
async map({ analysis, referenceUrl, notes }) {
591+
return {
592+
sourceUrl: referenceUrl,
593+
title: analysis.title,
594+
summary: notes
595+
? `Mapped ${analysis.title} into the stable generated reference section. Operator notes: ${notes}`
596+
: `Mapped ${analysis.title} into the stable generated reference section.`,
597+
mappedAt: "2026-03-20T12:02:00.000Z",
598+
templatePath: "templates/page.generated-reference.json",
599+
sectionPath: "sections/generated-reference.liquid",
600+
sections: [
601+
{
602+
id: "hero-1",
603+
type: "hero",
604+
heading: "Example heading",
605+
body: "Example body copy",
606+
ctaLabel: "Review generated output",
607+
ctaHref: "/pages/generated-reference"
608+
}
609+
]
610+
} satisfies ThemeMapping;
611+
}
612+
},
613+
generator: new ShopifyThemeGenerator(themeRoot),
614+
storeSetupGenerator: new ShopifyStoreSetupGenerator(themeRoot),
615+
commerceGenerator: new ShopifyCommerceWiringGenerator(themeRoot),
616+
integrationGenerator: new ShopifyIntegrationReportGenerator(themeRoot),
617+
themeValidator: {
618+
async validate() {
619+
return {
620+
status: "failed",
621+
summary: "Theme check failed: missing required liquid tag.",
622+
checkedAt: "2026-03-20T12:03:00.000Z"
623+
} satisfies ThemeCheckResult;
624+
}
625+
}
626+
});
627+
628+
await pipeline.process(job.id);
629+
630+
const savedJob = await repository.getById(job.id);
631+
632+
expect(savedJob?.status).toBe("failed");
633+
expect(savedJob?.integration).toBeDefined();
634+
635+
await expect(
636+
readFile(join(themeRoot, "config/generated-integration-report.json"), "utf8")
637+
).resolves.toContain("\"commerce_snippet_reference\"");
638+
});
559639
});

apps/api/src/services/theme-generator.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ describe("ShopifyThemeGenerator", () => {
8484
await expect(readFile(join(themeRoot, "templates/page.generated-reference.json"), "utf8")).resolves.toContain(
8585
'"type": "generated-reference"'
8686
);
87+
await expect(readFile(join(themeRoot, "templates/page.generated-reference.json"), "utf8")).resolves.not.toContain(
88+
'"mapping_summary"'
89+
);
8790
});
8891

8992
it("writes product-page outputs with a product form into product-specific stable files", async () => {
@@ -242,4 +245,102 @@ describe("ShopifyThemeGenerator", () => {
242245
'"type": "generated-reference"'
243246
);
244247
});
248+
249+
it("guards the product-page eyebrow with a blank check, includes a product image, and omits mapping_summary", async () => {
250+
const themeRoot = await mkdtemp(join(tmpdir(), "shopify-web-replicator-theme-"));
251+
tempDirectories.push(themeRoot);
252+
253+
const generator = new ShopifyThemeGenerator(themeRoot);
254+
const analysis: ReferenceAnalysis = {
255+
sourceUrl: "https://example.com/products/trail-pack",
256+
referenceHost: "example.com",
257+
pageType: "product_page",
258+
title: "Trail Pack",
259+
summary: "Product page summary.",
260+
analyzedAt: "2026-03-20T12:00:00.000Z",
261+
recommendedSections: ["product_detail"]
262+
};
263+
const mapping: ThemeMapping = {
264+
sourceUrl: "https://example.com/products/trail-pack",
265+
title: "Trail Pack",
266+
summary: "Product mapping summary.",
267+
mappedAt: "2026-03-20T12:01:00.000Z",
268+
templatePath: "templates/product.generated-reference.json",
269+
sectionPath: "sections/generated-product-reference.liquid",
270+
sections: [
271+
{
272+
id: "product-hero-1",
273+
type: "product_detail",
274+
heading: "Trail Pack",
275+
body: "Durable pack copy",
276+
ctaLabel: "Add to cart",
277+
ctaHref: "/cart"
278+
}
279+
]
280+
};
281+
282+
await generator.generate({ analysis, mapping });
283+
284+
const sectionContent = await readFile(join(themeRoot, "sections/generated-product-reference.liquid"), "utf8");
285+
const templateContent = await readFile(join(themeRoot, "templates/product.generated-reference.json"), "utf8");
286+
287+
// Bug 4 fix: eyebrow is wrapped in a blank check
288+
expect(sectionContent).toContain("{% if section.settings.eyebrow != blank %}");
289+
290+
// Feature 2 fix: product image is rendered
291+
expect(sectionContent).toContain("product.featured_image");
292+
293+
// Feature 1 fix: mapping_summary is absent from section HTML and schema settings
294+
expect(sectionContent).not.toContain("section.settings.mapping_summary");
295+
expect(sectionContent).not.toContain('"mapping_summary"');
296+
297+
// Feature 1 fix: mapping_summary is absent from the template JSON
298+
expect(templateContent).not.toContain('"mapping_summary"');
299+
});
300+
301+
it("renders collection-page product cards as linked <a> elements and omits mapping_summary", async () => {
302+
const themeRoot = await mkdtemp(join(tmpdir(), "shopify-web-replicator-theme-"));
303+
tempDirectories.push(themeRoot);
304+
305+
const generator = new ShopifyThemeGenerator(themeRoot);
306+
const analysis: ReferenceAnalysis = {
307+
sourceUrl: "https://example.com/collections/summer-gear",
308+
referenceHost: "example.com",
309+
pageType: "collection_page",
310+
title: "Summer Gear",
311+
summary: "Collection page summary.",
312+
analyzedAt: "2026-03-20T12:00:00.000Z",
313+
recommendedSections: ["collection_grid"]
314+
};
315+
const mapping: ThemeMapping = {
316+
sourceUrl: "https://example.com/collections/summer-gear",
317+
title: "Summer Gear",
318+
summary: "Collection mapping summary.",
319+
mappedAt: "2026-03-20T12:01:00.000Z",
320+
templatePath: "templates/collection.generated-reference.json",
321+
sectionPath: "sections/generated-collection-reference.liquid",
322+
sections: [
323+
{
324+
id: "collection-1",
325+
type: "collection_grid",
326+
heading: "Summer Gear",
327+
body: "Collection copy"
328+
}
329+
]
330+
};
331+
332+
await generator.generate({ analysis, mapping });
333+
334+
const sectionContent = await readFile(join(themeRoot, "sections/generated-collection-reference.liquid"), "utf8");
335+
const templateContent = await readFile(join(themeRoot, "templates/collection.generated-reference.json"), "utf8");
336+
337+
// Bug 5 fix: cards use <a href="{{ product.url }}"> instead of <article>
338+
expect(sectionContent).toContain('<a class="generated-collection-reference__card" href="{{ product.url }}">');
339+
expect(sectionContent).not.toContain("<article");
340+
341+
// Feature 1 fix: mapping_summary is absent from section HTML, schema settings, and template JSON
342+
expect(sectionContent).not.toContain("section.settings.mapping_summary");
343+
expect(sectionContent).not.toContain('"mapping_summary"');
344+
expect(templateContent).not.toContain('"mapping_summary"');
345+
});
245346
});

packages/engine/src/orchestrator.ts

Lines changed: 46 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import type {
22
AppRuntimeConfig,
3-
PageType,
4-
ReferenceAnalysis,
53
ReferenceIntake,
64
ReplicationJob,
7-
ReplicationJobSummary,
8-
StoreSetupPlan,
9-
ThemeCheckResult,
10-
ThemeMapping
5+
ReplicationJobSummary
116
} from "@shopify-web-replicator/shared";
127
import { createReplicationJob } from "@shopify-web-replicator/shared";
138

@@ -22,64 +17,7 @@ import { ShopifyStoreSetupGenerator } from "./services/store-setup-generator.js"
2217
import { DeterministicThemeMapper } from "./services/theme-mapper.js";
2318
import { ShopifyThemeGenerator } from "./services/theme-generator.js";
2419
import { ShopifyThemeValidator } from "./services/theme-validator.js";
25-
26-
type Analyzer = {
27-
analyze(input: { referenceUrl: string; pageType?: PageType; notes?: string }): Promise<ReferenceAnalysis>;
28-
};
29-
30-
type Mapper = {
31-
map(input: { analysis: ReferenceAnalysis; referenceUrl: string; notes?: string }): Promise<ThemeMapping>;
32-
};
33-
34-
type Generator = {
35-
generate(input: {
36-
analysis: ReferenceAnalysis;
37-
mapping: ThemeMapping;
38-
}): Promise<{
39-
artifacts: ReplicationJob["artifacts"];
40-
generation: NonNullable<ReplicationJob["generation"]>;
41-
}>;
42-
};
43-
44-
type StoreSetupGenerator = {
45-
generate(input: {
46-
analysis: ReferenceAnalysis;
47-
mapping: ThemeMapping;
48-
}): Promise<{
49-
artifact: ReplicationJob["artifacts"][number];
50-
storeSetup: StoreSetupPlan;
51-
}>;
52-
};
53-
54-
type CommerceGenerator = {
55-
generate(input: {
56-
analysis: ReferenceAnalysis;
57-
mapping: ThemeMapping;
58-
storeSetup: StoreSetupPlan;
59-
}): Promise<{
60-
artifact: ReplicationJob["artifacts"][number];
61-
commerce: NonNullable<ReplicationJob["commerce"]>;
62-
}>;
63-
};
64-
65-
type IntegrationGenerator = {
66-
generate(input: {
67-
analysis: ReferenceAnalysis;
68-
mapping: ThemeMapping;
69-
generation: NonNullable<ReplicationJob["generation"]>;
70-
storeSetup: StoreSetupPlan;
71-
commerce: NonNullable<ReplicationJob["commerce"]>;
72-
artifacts: ReplicationJob["artifacts"];
73-
validation: ThemeCheckResult;
74-
}): Promise<{
75-
artifact: ReplicationJob["artifacts"][number];
76-
integration: NonNullable<ReplicationJob["integration"]>;
77-
}>;
78-
};
79-
80-
type ThemeValidator = {
81-
validate(): Promise<ThemeCheckResult>;
82-
};
20+
import type { Analyzer, CommerceGenerator, Generator, IntegrationGenerator, Mapper, StoreSetupGenerator, ThemeValidator } from "./services/types.js";
8321

8422
export interface ReplicationHandoff {
8523
job: ReplicationJob;
@@ -169,6 +107,50 @@ export class ReplicationOrchestrator {
169107
return job;
170108
}
171109

110+
async retryJob(jobId: string): Promise<ReplicationHandoff> {
111+
const job = await this.repository.getById(jobId);
112+
113+
if (!job) {
114+
throw new Error(`Missing job ${jobId}`);
115+
}
116+
117+
if (job.status !== "failed") {
118+
throw new Error(`Job ${jobId} is not in a failed state`);
119+
}
120+
121+
for (const stage of job.stages) {
122+
if (stage.status === "failed") {
123+
stage.status = "pending";
124+
delete stage.errorMessage;
125+
delete stage.summary;
126+
}
127+
}
128+
129+
const firstPendingStage = job.stages.find((stage) => stage.status === "pending");
130+
if (firstPendingStage) {
131+
firstPendingStage.status = "current";
132+
job.currentStage = firstPendingStage.name;
133+
}
134+
135+
job.status = "in_progress";
136+
delete job.error;
137+
138+
job.artifacts = job.artifacts.map((artifact) =>
139+
artifact.status === "failed" ? { ...artifact, status: "pending" } : artifact
140+
);
141+
142+
job.updatedAt = new Date().toISOString();
143+
await this.repository.save(job);
144+
145+
const updatedJob = await this.runJob(jobId);
146+
147+
return {
148+
job: updatedJob,
149+
runtime: this.#runtime,
150+
nextActions: buildNextActions(updatedJob)
151+
};
152+
}
153+
172154
async replicateStorefront(intake: ReferenceIntake): Promise<ReplicationHandoff> {
173155
const created = await this.createJob(intake);
174156
const job = await this.runJob(created.jobId);

0 commit comments

Comments
 (0)