From 1af6904aa4a161d09aadc5f09683ad7a403da7f0 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 29 Jul 2026 23:07:27 +0000 Subject: [PATCH 1/8] fix: canonicalize nested model config objects Model canonicalization sorted keys only one level deep, so structurally identical models with reordered keys inside nested config objects (e.g. an Image field's constraint) compared as different, causing pull to rewrite unchanged files. Co-Authored-By: Claude Fable 5 --- src/lib/prismic/models.ts | 20 ++++++++++----- test/status.test.ts | 54 ++++++++++++++++++++++++++------------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index 4de3aec..667f15b 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -196,8 +196,10 @@ export function canonicalizeSlice(model: SharedSlice): SharedSlice { ...sortKeys(model), variations: model.variations.map((variation) => { const sorted = sortKeys(variation); - if (sorted.primary) sorted.primary = canonicalizeFields(sorted.primary); - if (sorted.items) sorted.items = canonicalizeFields(sorted.items); + // Field position is significant, so restore each field map's original + // entry order after the recursive sort. + if (variation.primary) sorted.primary = canonicalizeFields(variation.primary); + if (variation.items) sorted.items = canonicalizeFields(variation.items); return sorted; }), }; @@ -207,10 +209,10 @@ function canonicalizeFields(fields: Record): return Object.fromEntries( Object.entries(fields).map(([id, field]) => { const sorted = sortKeys(field); - if ("config" in sorted && sorted.config) { - sorted.config = sortKeys(sorted.config); - const group = sorted.config as { fields?: Fields }; - if (group.fields) group.fields = canonicalizeFields(group.fields); + // Field position is significant, so restore a group's original field + // order after the recursive sort. + if (field.type === "Group" && field.config?.fields) { + sorted.config = { ...sorted.config, fields: canonicalizeFields(field.config.fields) }; } return [id, sorted]; }), @@ -218,8 +220,12 @@ function canonicalizeFields(fields: Record): } function sortKeys(object: T): T { + if (Array.isArray(object)) return object.map(sortKeys) as T; + if (object === null || typeof object !== "object") return object; return Object.fromEntries( - Object.entries(object as Record).sort(([a], [b]) => a.localeCompare(b)), + Object.entries(object) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => [key, sortKeys(value)]), ) as T; } diff --git a/test/status.test.ts b/test/status.test.ts index a4dca38..c56df6a 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -3,6 +3,7 @@ import { describe } from "vitest"; import { buildCustomType, buildSlice, it, readLocalCustomType, writeLocalCustomType } from "./it"; import { insertCustomType, insertSlice } from "./prismic"; + it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("status", ["--help"]); expect(exitCode, stderr).toBe(0); @@ -62,7 +63,7 @@ describe("with an isolated repository", () => { expect(stdout).toContain("prismic pull"); }); - it("reports in-sync when local only reorders metadata and config keys", async ({ + it("reports in-sync when local only reorders keys at any depth", async ({ expect, project, prismic, @@ -70,11 +71,28 @@ describe("with an isolated repository", () => { token, host, }) => { - // A field with multiple config keys, so config key order can be reordered. const customType = buildCustomType({ json: { Main: { title: { type: "Text", config: { label: "Title", placeholder: "Enter a title" } }, + social_image: { + type: "Image", + config: { + label: "Social image", + constraint: { width: 1200, height: 630 }, + thumbnails: [{ name: "small", width: 100, height: 50 }], + }, + }, + links: { + type: "Group", + config: { + label: "Links", + fields: { + url: { type: "Text", config: { label: "URL", placeholder: "" } }, + label: { type: "Text", config: { label: "Label", placeholder: "" } }, + }, + }, + }, }, }, } as Partial>); @@ -84,24 +102,12 @@ describe("with an isolated repository", () => { const pull = await prismic("pull", ["--repo", repo]); expect(pull.exitCode, pull.stderr).toBe(0); - // Hand-edit the local file: reverse the order of metadata keys and of each - // field's config keys, leaving all values and the field order unchanged. + // Hand-edit the local file: reverse key order at every depth, leaving all + // values and the field order unchanged. const pulled = await readLocalCustomType(project, customType.id); - // Pull writes the canonical (sorted-key) form, not the raw API key order. - expect(Object.keys(pulled)).toEqual(Object.keys(pulled).sort()); - const canonical = JSON.stringify(pulled, null, 2); - for (const fields of Object.values(pulled.json)) { - for (const field of Object.values(fields)) { - const f = field as { config?: Record }; - if (f.config) { - expect(Object.keys(f.config)).toEqual(Object.keys(f.config).sort()); - f.config = Object.fromEntries(Object.entries(f.config).reverse()); - } - } - } - const scrambled = Object.fromEntries(Object.entries(pulled).reverse()) as typeof pulled; + const scrambled = scramble(pulled); // Confirm the hand-edit really produced a non-canonical file. - expect(JSON.stringify(scrambled, null, 2)).not.toBe(canonical); + expect(JSON.stringify(scrambled, null, 2)).not.toBe(JSON.stringify(pulled, null, 2)); await writeLocalCustomType(project, scrambled); // Both sides canonicalize equal, so status must report no changes. @@ -132,3 +138,15 @@ describe("with an isolated repository", () => { expect(stdout).toContain(`${customType.id} (custom type)`); }); }); + +// Reverses object key order at every depth, except field maps (`json` tabs +// and group `fields`), whose entry order is position-significant. +function scramble(value: T, keepOrder = 0): T { + if (Array.isArray(value)) return value.map((child) => scramble(child)) as T; + if (value === null || typeof value !== "object") return value; + const entries = Object.entries(value).map(([key, child]) => [ + key, + scramble(child, key === "json" ? 2 : key === "fields" ? 1 : keepOrder - 1), + ]); + return Object.fromEntries(keepOrder > 0 ? entries : entries.reverse()); +} From dcc6f9b5e35edd02cd4a3d2fbbed3451cc7b76ce Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 29 Jul 2026 23:12:33 +0000 Subject: [PATCH 2/8] fix: support TypeScript <5.9 in group field canonicalization Co-Authored-By: Claude Fable 5 --- src/lib/prismic/models.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index 667f15b..fc53733 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -210,9 +210,12 @@ function canonicalizeFields(fields: Record): Object.entries(fields).map(([id, field]) => { const sorted = sortKeys(field); // Field position is significant, so restore a group's original field - // order after the recursive sort. + // order after the recursive sort. The cast is for TypeScript <5.9, + // which does not narrow `sorted` alongside `field`. if (field.type === "Group" && field.config?.fields) { - sorted.config = { ...sorted.config, fields: canonicalizeFields(field.config.fields) }; + (sorted as { config: { fields: Fields } }).config.fields = canonicalizeFields( + field.config.fields, + ); } return [id, sorted]; }), From fa32d3c70cabc612e02c0407e8c80d4d731a9024 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 29 Jul 2026 23:18:10 +0000 Subject: [PATCH 3/8] refactor: consolidate canonicalization comments Co-Authored-By: Claude Fable 5 --- src/lib/prismic/models.ts | 8 +++----- test/status.test.ts | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index fc53733..ad64f8e 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -196,8 +196,6 @@ export function canonicalizeSlice(model: SharedSlice): SharedSlice { ...sortKeys(model), variations: model.variations.map((variation) => { const sorted = sortKeys(variation); - // Field position is significant, so restore each field map's original - // entry order after the recursive sort. if (variation.primary) sorted.primary = canonicalizeFields(variation.primary); if (variation.items) sorted.items = canonicalizeFields(variation.items); return sorted; @@ -209,9 +207,7 @@ function canonicalizeFields(fields: Record): return Object.fromEntries( Object.entries(fields).map(([id, field]) => { const sorted = sortKeys(field); - // Field position is significant, so restore a group's original field - // order after the recursive sort. The cast is for TypeScript <5.9, - // which does not narrow `sorted` alongside `field`. + // The cast is for TypeScript <5.9, which does not narrow `sorted` alongside `field`. if (field.type === "Group" && field.config?.fields) { (sorted as { config: { fields: Fields } }).config.fields = canonicalizeFields( field.config.fields, @@ -222,6 +218,8 @@ function canonicalizeFields(fields: Record): ); } +// Sorts keys recursively. Entry order of field maps encodes field position, +// so callers restore those from the unsorted input. function sortKeys(object: T): T { if (Array.isArray(object)) return object.map(sortKeys) as T; if (object === null || typeof object !== "object") return object; diff --git a/test/status.test.ts b/test/status.test.ts index c56df6a..11ff3ab 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -3,7 +3,6 @@ import { describe } from "vitest"; import { buildCustomType, buildSlice, it, readLocalCustomType, writeLocalCustomType } from "./it"; import { insertCustomType, insertSlice } from "./prismic"; - it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("status", ["--help"]); expect(exitCode, stderr).toBe(0); From 3f75a9b3f6db8c41698b550739cfb7e2a3a78f59 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 29 Jul 2026 23:41:18 +0000 Subject: [PATCH 4/8] refactor: remove type cast in group field canonicalization Co-Authored-By: Claude Fable 5 --- src/lib/prismic/models.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index ad64f8e..52db618 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -207,11 +207,13 @@ function canonicalizeFields(fields: Record): return Object.fromEntries( Object.entries(fields).map(([id, field]) => { const sorted = sortKeys(field); - // The cast is for TypeScript <5.9, which does not narrow `sorted` alongside `field`. - if (field.type === "Group" && field.config?.fields) { - (sorted as { config: { fields: Fields } }).config.fields = canonicalizeFields( - field.config.fields, - ); + if ( + field.type === "Group" && + field.config?.fields && + sorted.type === "Group" && + sorted.config?.fields + ) { + sorted.config.fields = canonicalizeFields(field.config.fields); } return [id, sorted]; }), From e87830f05b88ecae6f016f07b047592025c0b6e6 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 30 Jul 2026 00:26:53 +0000 Subject: [PATCH 5/8] feat: rewrite non-canonical model files on pull Co-Authored-By: Claude Fable 5 --- src/commands/pull.ts | 6 ++---- test/pull.test.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/commands/pull.ts b/src/commands/pull.ts index d0aa8fd..85ff1c2 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -88,8 +88,7 @@ export default createCommand(config, async ({ values }) => { localCustomTypes.map((customType) => customType.model), { getKey: (model) => model.id, - equals: (a, b) => - JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)), + equals: (remote, local) => JSON.stringify(canonicalizeCustomType(remote)) === JSON.stringify(local), }, ); const sliceOps = diffArrays( @@ -97,8 +96,7 @@ export default createCommand(config, async ({ values }) => { localSlices.map((slice) => slice.model), { getKey: (model) => model.id, - equals: (a, b) => - JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)), + equals: (remote, local) => JSON.stringify(canonicalizeSlice(remote)) === JSON.stringify(local), }, ); diff --git a/test/pull.test.ts b/test/pull.test.ts index a306739..3b12e51 100644 --- a/test/pull.test.ts +++ b/test/pull.test.ts @@ -1,5 +1,5 @@ import { pascalCase } from "change-case"; -import { writeFile, mkdir } from "node:fs/promises"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; import { sep } from "node:path"; import { fileURLToPath } from "node:url"; import { x } from "tinyexec"; @@ -250,6 +250,31 @@ it.sequential("removes route when page type is deleted", async ({ await expect(project).not.toHaveRoute({ type: customType.id }); }); +it.sequential("rewrites model files whose key order is not canonical", async ({ + expect, + project, + prismic, + repo, + token, + host, +}) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const first = await prismic("pull", ["--repo", repo]); + expect(first.exitCode, first.stderr).toBe(0); + + const modelPath = new URL(`customtypes/${customType.id}/index.json`, project); + const canonical = await readFile(modelPath, "utf8"); + const reversed = Object.fromEntries(Object.entries(JSON.parse(canonical)).reverse()); + await writeFile(modelPath, JSON.stringify(reversed, null, 2)); + + const second = await prismic("pull", ["--repo", repo, "--force"]); + expect(second.exitCode, second.stderr).toBe(0); + expect(second.stdout).toContain("updated 1"); + expect(await readFile(modelPath, "utf8")).toBe(canonical); +}); + it.sequential("blocks pull when local model files have uncommitted changes", async ({ expect, project, From eafe9b106b7a2bb2b030fe12c5b4840605ebf3b3 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 30 Jul 2026 02:53:34 +0000 Subject: [PATCH 6/8] fix: ignore key order when comparing models during sync Sync compared raw remote JSON against canonical local files, so any remote change re-synced every model whose key order differed. Sync now uses pull's comparison: the remote model's canonical form against the local file as parsed. Co-Authored-By: Claude Fable 5 --- src/commands/sync.ts | 9 ++++++++- test/sync.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 20c79c4..2174292 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -9,6 +9,7 @@ import { createCommand, type CommandConfig, CommandError } from "../lib/command" import { diffArrays } from "../lib/diff"; import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; import { completeOnboardingStepsSilently } from "../lib/prismic/clients/repository"; +import { canonicalizeCustomType, canonicalizeSlice } from "../lib/prismic/models"; import { getRepositoryName } from "../project"; import { trackCommandStart, trackCommandEnd } from "../tracking"; @@ -83,7 +84,11 @@ export default createCommand(config, async ({ values }) => { const changed: string[] = []; - const sliceOps = diffArrays(remoteSlices, localSliceModels, { getKey: (m) => m.id }); + const sliceOps = diffArrays(remoteSlices, localSliceModels, { + getKey: (m) => m.id, + equals: (remote, local) => + JSON.stringify(canonicalizeSlice(remote)) === JSON.stringify(local), + }); if (sliceOps.insert.length + sliceOps.update.length + sliceOps.delete.length > 0) { for (const slice of sliceOps.update) { await adapter.updateSlice(slice); @@ -99,6 +104,8 @@ export default createCommand(config, async ({ values }) => { const customTypeOps = diffArrays(remoteCustomTypes, localCustomTypeModels, { getKey: (m) => m.id, + equals: (remote, local) => + JSON.stringify(canonicalizeCustomType(remote)) === JSON.stringify(local), }); if ( customTypeOps.insert.length + customTypeOps.update.length + customTypeOps.delete.length > diff --git a/test/sync.test.ts b/test/sync.test.ts index 1bfb8da..cacf941 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -36,5 +36,17 @@ describe("with an isolated repository", () => { await expect(project).toContainCustomType(customType); await expect(project).toContainSlice(slice); + + // A later remote change must only re-sync models that actually changed. + // The custom type's local file has canonical key order while the remote + // returns its own order, so this fails if sync compares raw JSON. + const outputLengthBeforeSliceB = output().length; + const newOutput = () => output().slice(outputLengthBeforeSliceB); + const sliceB = buildSlice(); + await insertSlice(sliceB, { repo, token, host }); + + await expect.poll(newOutput, { timeout: 30_000 }).toContain("Changes detected in slices"); + expect(newOutput()).not.toContain("custom types"); + await expect(project).toContainSlice(sliceB); }, 60_000); }); From dc2e6175ff4792e1fa95da0d16783a617493538e Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 30 Jul 2026 02:53:39 +0000 Subject: [PATCH 7/8] test: cover pull idempotence, field order, and key-order no-ops Pull now has a test that pulled files keep field order, a second pull changes nothing, and non-canonical files (types and slices) are rewritten in canonical form. The status reorder test also covers a slice and checks that push writes nothing. The scramble helper moves to test/it.ts and handles slice field maps. Co-Authored-By: Claude Fable 5 --- test/it.ts | 16 ++++++ test/pull.test.ts | 123 +++++++++++++++++++++++++++++++++++--------- test/status.test.ts | 55 +++++++++++++------- 3 files changed, 150 insertions(+), 44 deletions(-) diff --git a/test/it.ts b/test/it.ts index 813fb6f..e3a3513 100644 --- a/test/it.ts +++ b/test/it.ts @@ -218,6 +218,22 @@ export function buildSlice(overrides?: Partial): SharedSlice { }; } +// Reverses object key order at every depth, except field maps (`json` tabs, +// group `fields`, and slice variation `primary`/`items`), whose entry order is +// position-significant. +export function scramble(value: T, keepOrder = 0): T { + if (Array.isArray(value)) return value.map((child) => scramble(child)) as T; + if (value === null || typeof value !== "object") return value; + const entries = Object.entries(value).map(([key, child]) => [ + key, + scramble( + child, + key === "json" ? 2 : ["fields", "primary", "items"].includes(key) ? 1 : keepOrder - 1, + ), + ]); + return Object.fromEntries(keepOrder > 0 ? entries : entries.reverse()); +} + export async function writeLocalCustomType(project: URL, model: CustomType): Promise { const path = new URL(`customtypes/${model.id}/index.json`, project); await mkdir(new URL(".", path), { recursive: true }); diff --git a/test/pull.test.ts b/test/pull.test.ts index 3b12e51..8c996a6 100644 --- a/test/pull.test.ts +++ b/test/pull.test.ts @@ -3,8 +3,9 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { sep } from "node:path"; import { fileURLToPath } from "node:url"; import { x } from "tinyexec"; +import { describe } from "vitest"; -import { buildCustomType, buildSlice, it } from "./it"; +import { buildCustomType, buildSlice, it, scramble } from "./it"; import { deleteCustomType, deleteSlice, @@ -250,29 +251,103 @@ it.sequential("removes route when page type is deleted", async ({ await expect(project).not.toHaveRoute({ type: customType.id }); }); -it.sequential("rewrites model files whose key order is not canonical", async ({ - expect, - project, - prismic, - repo, - token, - host, -}) => { - const customType = buildCustomType(); - await insertCustomType(customType, { repo, token, host }); - - const first = await prismic("pull", ["--repo", repo]); - expect(first.exitCode, first.stderr).toBe(0); - - const modelPath = new URL(`customtypes/${customType.id}/index.json`, project); - const canonical = await readFile(modelPath, "utf8"); - const reversed = Object.fromEntries(Object.entries(JSON.parse(canonical)).reverse()); - await writeFile(modelPath, JSON.stringify(reversed, null, 2)); - - const second = await prismic("pull", ["--repo", repo, "--force"]); - expect(second.exitCode, second.stderr).toBe(0); - expect(second.stdout).toContain("updated 1"); - expect(await readFile(modelPath, "utf8")).toBe(canonical); +describe("with an isolated repository", () => { + it.scoped({ isolateRepo: true }); + + it("writes canonical model files that later pulls leave untouched", async ({ + expect, + project, + prismic, + repo, + token, + host, + }) => { + // Nested config objects with unsorted keys, plus field maps (tab, group, + // slice primary) whose entry order must be kept as-is. + const customType = buildCustomType({ + json: { + Main: { + title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, + social_image: { + type: "Image", + config: { + label: "Social image", + constraint: { width: 1200, height: 630 }, + thumbnails: [{ name: "small", width: 100, height: 50 }], + }, + }, + links: { + type: "Group", + config: { + label: "Links", + fields: { + url: { type: "Text", config: { label: "URL", placeholder: "" } }, + label: { type: "Text", config: { label: "Label", placeholder: "" } }, + }, + }, + }, + }, + }, + } as Partial>); + const slice = buildSlice(); + slice.variations[0].primary = { + title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, + image: { type: "Image", config: { label: "Image", constraint: { width: 800, height: 600 } } }, + }; + + await Promise.all([ + insertCustomType(customType, { repo, token, host }), + insertSlice(slice, { repo, token, host }), + ]); + + const first = await prismic("pull", ["--repo", repo]); + expect(first.exitCode, first.stderr).toBe(0); + + const typePath = new URL(`customtypes/${customType.id}/index.json`, project); + const slicePath = new URL(`slices/${pascalCase(slice.name)}/model.json`, project); + const pulledType = await readFile(typePath, "utf8"); + const pulledSlice = await readFile(slicePath, "utf8"); + + // Metadata and config keys are sorted; field order is kept. + const writtenType = JSON.parse(pulledType); + expect(Object.keys(writtenType)).toEqual(Object.keys(writtenType).sort()); + expect(Object.keys(writtenType.json.Main)).toEqual(["title", "social_image", "links"]); + expect(Object.keys(writtenType.json.Main.social_image.config.constraint)).toEqual([ + "height", + "width", + ]); + expect(Object.keys(writtenType.json.Main.links.config.fields)).toEqual(["url", "label"]); + const writtenSlice = JSON.parse(pulledSlice); + expect(Object.keys(writtenSlice.variations[0].primary)).toEqual(["title", "image"]); + + // A second pull with no changes on either side must not touch the files. + const second = await prismic("pull", ["--repo", repo]); + expect(second.exitCode, second.stderr).toBe(0); + expect(second.stdout).toContain("Already up to date."); + expect(await readFile(typePath, "utf8")).toBe(pulledType); + expect(await readFile(slicePath, "utf8")).toBe(pulledSlice); + + // Files with non-canonical key order count as updates. This project has + // no git repo to protect local edits, so a plain pull refuses; --force + // writes the files back in canonical form. + const scrambledType = JSON.stringify(scramble(JSON.parse(pulledType)), null, 2); + const scrambledSlice = JSON.stringify(scramble(JSON.parse(pulledSlice)), null, 2); + expect(scrambledType).not.toBe(pulledType); + expect(scrambledSlice).not.toBe(pulledSlice); + await writeFile(typePath, scrambledType); + await writeFile(slicePath, scrambledSlice); + + const blocked = await prismic("pull", ["--repo", repo]); + expect(blocked.exitCode).toBe(1); + expect(blocked.stderr).toContain("--force"); + + const rewrite = await prismic("pull", ["--repo", repo, "--force"]); + expect(rewrite.exitCode, rewrite.stderr).toBe(0); + expect(rewrite.stdout).toContain("updated 1, deleted 0 types"); + expect(rewrite.stdout).toContain("updated 1, deleted 0 slices"); + expect(await readFile(typePath, "utf8")).toBe(pulledType); + expect(await readFile(slicePath, "utf8")).toBe(pulledSlice); + }); }); it.sequential("blocks pull when local model files have uncommitted changes", async ({ diff --git a/test/status.test.ts b/test/status.test.ts index 11ff3ab..e5d2947 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -1,6 +1,15 @@ import { describe } from "vitest"; -import { buildCustomType, buildSlice, it, readLocalCustomType, writeLocalCustomType } from "./it"; +import { + buildCustomType, + buildSlice, + it, + readLocalCustomType, + readLocalSlice, + scramble, + writeLocalCustomType, + writeLocalSlice, +} from "./it"; import { insertCustomType, insertSlice } from "./prismic"; it("supports --help", async ({ expect, prismic }) => { @@ -62,7 +71,7 @@ describe("with an isolated repository", () => { expect(stdout).toContain("prismic pull"); }); - it("reports in-sync when local only reorders keys at any depth", async ({ + it("reports in-sync and push writes nothing when local only reorders keys", async ({ expect, project, prismic, @@ -95,24 +104,42 @@ describe("with an isolated repository", () => { }, }, } as Partial>); - await insertCustomType(customType, { repo, token, host }); + const slice = buildSlice(); + slice.variations[0].primary = { + title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, + }; + await Promise.all([ + insertCustomType(customType, { repo, token, host }), + insertSlice(slice, { repo, token, host }), + ]); // Pull writes the canonical form to disk. const pull = await prismic("pull", ["--repo", repo]); expect(pull.exitCode, pull.stderr).toBe(0); - // Hand-edit the local file: reverse key order at every depth, leaving all + // Hand-edit the local files: reverse key order at every depth, leaving all // values and the field order unchanged. - const pulled = await readLocalCustomType(project, customType.id); - const scrambled = scramble(pulled); + const pulledType = await readLocalCustomType(project, customType.id); + const scrambledType = scramble(pulledType); // Confirm the hand-edit really produced a non-canonical file. - expect(JSON.stringify(scrambled, null, 2)).not.toBe(JSON.stringify(pulled, null, 2)); - await writeLocalCustomType(project, scrambled); + expect(JSON.stringify(scrambledType, null, 2)).not.toBe(JSON.stringify(pulledType, null, 2)); + await writeLocalCustomType(project, scrambledType); + + const pulledSlice = await readLocalSlice(project, slice.id); + if (!pulledSlice) throw new Error(`Slice "${slice.id}" was not pulled.`); + const scrambledSlice = scramble(pulledSlice); + expect(JSON.stringify(scrambledSlice, null, 2)).not.toBe(JSON.stringify(pulledSlice, null, 2)); + await writeLocalSlice(project, scrambledSlice); // Both sides canonicalize equal, so status must report no changes. const { stdout, stderr, exitCode } = await prismic("status", ["--repo", repo]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("Already up to date."); + + // Push uses the same comparison, so it must not update the remote models. + const push = await prismic("push", ["--repo", repo]); + expect(push.exitCode, push.stderr).toBe(0); + expect(push.stdout).toContain("Already up to date."); }); it("reports differing models when local and remote disagree", async ({ @@ -137,15 +164,3 @@ describe("with an isolated repository", () => { expect(stdout).toContain(`${customType.id} (custom type)`); }); }); - -// Reverses object key order at every depth, except field maps (`json` tabs -// and group `fields`), whose entry order is position-significant. -function scramble(value: T, keepOrder = 0): T { - if (Array.isArray(value)) return value.map((child) => scramble(child)) as T; - if (value === null || typeof value !== "object") return value; - const entries = Object.entries(value).map(([key, child]) => [ - key, - scramble(child, key === "json" ? 2 : key === "fields" ? 1 : keepOrder - 1), - ]); - return Object.fromEntries(keepOrder > 0 ? entries : entries.reverse()); -} From b3ace0c0a695e5ae92dd465716ceca4fa91f3a2f Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 31 Jul 2026 17:27:42 +0000 Subject: [PATCH 8/8] test: simplify canonical pull test Co-Authored-By: Claude Fable 5 --- test/pull.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/pull.test.ts b/test/pull.test.ts index 8c996a6..a9fd662 100644 --- a/test/pull.test.ts +++ b/test/pull.test.ts @@ -267,7 +267,6 @@ describe("with an isolated repository", () => { const customType = buildCustomType({ json: { Main: { - title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, social_image: { type: "Image", config: { @@ -292,7 +291,7 @@ describe("with an isolated repository", () => { const slice = buildSlice(); slice.variations[0].primary = { title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, - image: { type: "Image", config: { label: "Image", constraint: { width: 800, height: 600 } } }, + subtitle: { type: "Text", config: { placeholder: "Enter a subtitle", label: "Subtitle" } }, }; await Promise.all([ @@ -310,15 +309,14 @@ describe("with an isolated repository", () => { // Metadata and config keys are sorted; field order is kept. const writtenType = JSON.parse(pulledType); - expect(Object.keys(writtenType)).toEqual(Object.keys(writtenType).sort()); - expect(Object.keys(writtenType.json.Main)).toEqual(["title", "social_image", "links"]); + expect(Object.keys(writtenType.json.Main)).toEqual(["social_image", "links"]); expect(Object.keys(writtenType.json.Main.social_image.config.constraint)).toEqual([ "height", "width", ]); expect(Object.keys(writtenType.json.Main.links.config.fields)).toEqual(["url", "label"]); const writtenSlice = JSON.parse(pulledSlice); - expect(Object.keys(writtenSlice.variations[0].primary)).toEqual(["title", "image"]); + expect(Object.keys(writtenSlice.variations[0].primary)).toEqual(["title", "subtitle"]); // A second pull with no changes on either side must not touch the files. const second = await prismic("pull", ["--repo", repo]);