From 1af6904aa4a161d09aadc5f09683ad7a403da7f0 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 29 Jul 2026 23:07:27 +0000 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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]); From 2f39def662aee09a29d69afa40df36b3fc3a66ef Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 6 Aug 2026 21:58:06 +0000 Subject: [PATCH 09/13] fix: keep slice zone order when canonicalizing models Deep key sorting also sorted a Slices field's choices, which is the slice order shown in the editor, and the field maps inside legacy slices. Rebuild both from the unsorted input, like group fields. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/prismic/models.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index 52db618..1d4d44c 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -1,5 +1,6 @@ import type { CustomType, + DynamicSlices, DynamicWidget, Link, SharedSlice, @@ -7,6 +8,8 @@ import type { type Fields = Record; +type Choices = NonNullable["choices"]>; + export type ContentRelationshipFieldSelection = | string | { @@ -215,6 +218,29 @@ function canonicalizeFields(fields: Record): ) { sorted.config.fields = canonicalizeFields(field.config.fields); } + if ( + field.type === "Slices" && + field.config?.choices && + sorted.type === "Slices" && + sorted.config?.choices + ) { + sorted.config.choices = canonicalizeChoices(field.config.choices); + } + return [id, sorted]; + }), + ); +} + +// Entry order of a slice zone's choices is its slice order, and legacy slices +// hold field maps of their own. +function canonicalizeChoices(choices: Choices): Choices { + return Object.fromEntries( + Object.entries(choices).map(([id, choice]) => { + const sorted = sortKeys(choice); + if (choice.type === "Slice" && sorted.type === "Slice") { + if (choice["non-repeat"]) sorted["non-repeat"] = canonicalizeFields(choice["non-repeat"]); + if (choice.repeat) sorted.repeat = canonicalizeFields(choice.repeat); + } return [id, sorted]; }), ); From 3902c51886208261ba80d2bfa485b58ad58d6cc3 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 6 Aug 2026 21:58:10 +0000 Subject: [PATCH 10/13] test: build non-canonical model files from fixtures Fixtures already hold their keys in a non-canonical order, so writing them to disk gives the same coverage as the scramble helper without restating which maps are position-significant. Cover a slice zone and a legacy slice too. Co-Authored-By: Claude Opus 5 (1M context) --- test/it.ts | 16 -------------- test/pull.test.ts | 51 ++++++++++++++++++++++++++++++++------------- test/status.test.ts | 19 ++++++++--------- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/test/it.ts b/test/it.ts index 8cec591..fa70c1e 100644 --- a/test/it.ts +++ b/test/it.ts @@ -222,22 +222,6 @@ 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 a9fd662..f8b8c7a 100644 --- a/test/pull.test.ts +++ b/test/pull.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { x } from "tinyexec"; import { describe } from "vitest"; -import { buildCustomType, buildSlice, it, scramble } from "./it"; +import { buildCustomType, buildSlice, it } from "./it"; import { deleteCustomType, deleteSlice, @@ -262,9 +262,12 @@ describe("with an isolated repository", () => { token, host, }) => { - // Nested config objects with unsorted keys, plus field maps (tab, group, - // slice primary) whose entry order must be kept as-is. + // Written with unsorted keys at every depth, plus field maps (tab, group, + // slice zone, legacy slice, slice primary) whose entry order must be kept + // as-is. + const slice = buildSlice({ id: "zeta-slice", name: "ZetaSlice" }); const customType = buildCustomType({ + format: "custom", json: { Main: { social_image: { @@ -285,10 +288,26 @@ describe("with an isolated repository", () => { }, }, }, + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { + choices: { + [slice.id]: { type: "SharedSlice" }, + legacy_banner: { + type: "Slice", + fieldset: "Legacy banner", + "non-repeat": { + title: { type: "Text", config: { label: "Title", placeholder: "" } }, + caption: { type: "Text", config: { label: "Caption", placeholder: "" } }, + }, + }, + }, + }, + }, }, }, } as Partial>); - const slice = buildSlice(); slice.variations[0].primary = { title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, subtitle: { type: "Text", config: { placeholder: "Enter a subtitle", label: "Subtitle" } }, @@ -309,12 +328,15 @@ describe("with an isolated repository", () => { // Metadata and config keys are sorted; field order is kept. const writtenType = JSON.parse(pulledType); - expect(Object.keys(writtenType.json.Main)).toEqual(["social_image", "links"]); + expect(Object.keys(writtenType.json.Main)).toEqual(["social_image", "links", "slices"]); 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 choices = writtenType.json.Main.slices.config.choices; + expect(Object.keys(choices)).toEqual([slice.id, "legacy_banner"]); + expect(Object.keys(choices.legacy_banner["non-repeat"])).toEqual(["title", "caption"]); const writtenSlice = JSON.parse(pulledSlice); expect(Object.keys(writtenSlice.variations[0].primary)).toEqual(["title", "subtitle"]); @@ -325,15 +347,16 @@ describe("with an isolated repository", () => { 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); + // The fixtures hold the same models in a different key order, so writing + // them back makes both files non-canonical. Pull counts them 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 unsortedType = JSON.stringify(customType, null, 2); + const unsortedSlice = JSON.stringify(slice, null, 2); + expect(unsortedType).not.toBe(pulledType); + expect(unsortedSlice).not.toBe(pulledSlice); + await writeFile(typePath, unsortedType); + await writeFile(slicePath, unsortedSlice); const blocked = await prismic("pull", ["--repo", repo]); expect(blocked.exitCode).toBe(1); diff --git a/test/status.test.ts b/test/status.test.ts index e5d2947..76a5261 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -6,7 +6,6 @@ import { it, readLocalCustomType, readLocalSlice, - scramble, writeLocalCustomType, writeLocalSlice, } from "./it"; @@ -79,7 +78,10 @@ describe("with an isolated repository", () => { token, host, }) => { + // Written with unsorted keys at every depth, so the fixtures double as the + // non-canonical local files below. const customType = buildCustomType({ + format: "custom", json: { Main: { title: { type: "Text", config: { label: "Title", placeholder: "Enter a title" } }, @@ -117,19 +119,16 @@ describe("with an isolated repository", () => { const pull = await prismic("pull", ["--repo", repo]); expect(pull.exitCode, pull.stderr).toBe(0); - // Hand-edit the local files: reverse key order at every depth, leaving all - // values and the field order unchanged. + // Write the fixtures back over the pulled files. Same models, different + // key order. const pulledType = await readLocalCustomType(project, customType.id); - const scrambledType = scramble(pulledType); - // Confirm the hand-edit really produced a non-canonical file. - expect(JSON.stringify(scrambledType, null, 2)).not.toBe(JSON.stringify(pulledType, null, 2)); - await writeLocalCustomType(project, scrambledType); + expect(JSON.stringify(customType, null, 2)).not.toBe(JSON.stringify(pulledType, null, 2)); + await writeLocalCustomType(project, customType); 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); + expect(JSON.stringify(slice, null, 2)).not.toBe(JSON.stringify(pulledSlice, null, 2)); + await writeLocalSlice(project, slice); // Both sides canonicalize equal, so status must report no changes. const { stdout, stderr, exitCode } = await prismic("status", ["--repo", repo]); From ae9351367eb6c00d72c0126ac6c3902eeae92303 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 6 Aug 2026 22:05:10 +0000 Subject: [PATCH 11/13] test: assert key sorting as well as preserved order The pulled file only proved that field order survives. Assert the sorted key order of the model, a field, a thumbnail, a slice, and a variation, and add a second tab and thumbnail so tab and array order are covered. Co-Authored-By: Claude Opus 5 (1M context) --- test/pull.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/test/pull.test.ts b/test/pull.test.ts index f8b8c7a..ba99f7b 100644 --- a/test/pull.test.ts +++ b/test/pull.test.ts @@ -275,7 +275,10 @@ describe("with an isolated repository", () => { config: { label: "Social image", constraint: { width: 1200, height: 630 }, - thumbnails: [{ name: "small", width: 100, height: 50 }], + thumbnails: [ + { name: "small", width: 100, height: 50 }, + { name: "large", width: 400, height: 200 }, + ], }, }, links: { @@ -306,6 +309,9 @@ describe("with an isolated repository", () => { }, }, }, + Details: { + author: { type: "Text", config: { label: "Author", placeholder: "" } }, + }, }, } as Partial>); slice.variations[0].primary = { @@ -326,13 +332,10 @@ describe("with an isolated repository", () => { const pulledType = await readFile(typePath, "utf8"); const pulledSlice = await readFile(slicePath, "utf8"); - // Metadata and config keys are sorted; field order is kept. + // Tab, field, and slice order is kept. const writtenType = JSON.parse(pulledType); + expect(Object.keys(writtenType.json)).toEqual(["Main", "Details"]); expect(Object.keys(writtenType.json.Main)).toEqual(["social_image", "links", "slices"]); - 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 choices = writtenType.json.Main.slices.config.choices; expect(Object.keys(choices)).toEqual([slice.id, "legacy_banner"]); @@ -340,6 +343,37 @@ describe("with an isolated repository", () => { const writtenSlice = JSON.parse(pulledSlice); expect(Object.keys(writtenSlice.variations[0].primary)).toEqual(["title", "subtitle"]); + // Every other key is sorted, and array order is kept. + expect(Object.keys(writtenType)).toEqual([ + "format", + "id", + "json", + "label", + "repeatable", + "status", + ]); + expect(Object.keys(writtenType.json.Main.slices)).toEqual(["config", "fieldset", "type"]); + expect(Object.keys(writtenType.json.Main.social_image.config.constraint)).toEqual([ + "height", + "width", + ]); + const thumbnails = writtenType.json.Main.social_image.config.thumbnails; + expect(thumbnails.map((thumbnail: { name: string }) => thumbnail.name)).toEqual([ + "small", + "large", + ]); + expect(Object.keys(thumbnails[0])).toEqual(["height", "name", "width"]); + expect(Object.keys(writtenSlice)).toEqual(["id", "name", "type", "variations"]); + expect(Object.keys(writtenSlice.variations[0])).toEqual([ + "description", + "docURL", + "id", + "imageUrl", + "name", + "primary", + "version", + ]); + // 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); From 24020e72bd02bde1493f43e8a167edd0b5b2d203 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 6 Aug 2026 23:33:30 +0000 Subject: [PATCH 12/13] test: check the canonical pull against its fixture Write the unsorted fixture locally before the pull so one run covers both the rewrite and the canonical output. Compare the pulled models to the fixture for content, then assert key order at each kind of object, including the slice zone and a legacy slice. Co-Authored-By: Claude Opus 5 (1M context) --- test/pull.test.ts | 124 +++++++++++++++++++++++----------------------- 1 file changed, 61 insertions(+), 63 deletions(-) diff --git a/test/pull.test.ts b/test/pull.test.ts index ba99f7b..be2c0ef 100644 --- a/test/pull.test.ts +++ b/test/pull.test.ts @@ -1,11 +1,19 @@ import { pascalCase } from "change-case"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { 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, + readLocalCustomType, + readLocalSlice, + writeLocalCustomType, + writeLocalSlice, +} from "./it"; import { deleteCustomType, deleteSlice, @@ -262,10 +270,11 @@ describe("with an isolated repository", () => { token, host, }) => { - // Written with unsorted keys at every depth, plus field maps (tab, group, - // slice zone, legacy slice, slice primary) whose entry order must be kept - // as-is. const slice = buildSlice({ id: "zeta-slice", name: "ZetaSlice" }); + slice.variations[0].primary = { + title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, + subtitle: { type: "Text", config: { placeholder: "Enter a subtitle", label: "Subtitle" } }, + }; const customType = buildCustomType({ format: "custom", json: { @@ -286,8 +295,8 @@ describe("with an isolated repository", () => { config: { label: "Links", fields: { - url: { type: "Text", config: { label: "URL", placeholder: "" } }, - label: { type: "Text", config: { label: "Label", placeholder: "" } }, + url: { type: "Text", config: { placeholder: "", label: "URL" } }, + label: { type: "Text", config: { placeholder: "", label: "Label" } }, }, }, }, @@ -301,8 +310,8 @@ describe("with an isolated repository", () => { type: "Slice", fieldset: "Legacy banner", "non-repeat": { - title: { type: "Text", config: { label: "Title", placeholder: "" } }, - caption: { type: "Text", config: { label: "Caption", placeholder: "" } }, + title: { type: "Text", config: { placeholder: "", label: "Title" } }, + caption: { type: "Text", config: { placeholder: "", label: "Caption" } }, }, }, }, @@ -313,37 +322,25 @@ describe("with an isolated repository", () => { author: { type: "Text", config: { label: "Author", placeholder: "" } }, }, }, - } as Partial>); - slice.variations[0].primary = { - title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, - subtitle: { type: "Text", config: { placeholder: "Enter a subtitle", label: "Subtitle" } }, - }; + }); await Promise.all([ + writeLocalCustomType(project, customType), + writeLocalSlice(project, slice), insertCustomType(customType, { repo, token, host }), insertSlice(slice, { repo, token, host }), ]); - const first = await prismic("pull", ["--repo", repo]); + const first = await prismic("pull", ["--repo", repo, "--force"]); 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"); + // oxlint-disable-next-line typescript-eslint/no-explicit-any + const writtenType: Record = await readLocalCustomType(project, customType.id); + // oxlint-disable-next-line typescript-eslint/no-explicit-any + const writtenSlice: Record | undefined = await readLocalSlice(project, slice.id); + if (!writtenSlice) throw new Error(`Slice "${slice.id}" was not pulled.`); - // Tab, field, and slice order is kept. - const writtenType = JSON.parse(pulledType); - expect(Object.keys(writtenType.json)).toEqual(["Main", "Details"]); - expect(Object.keys(writtenType.json.Main)).toEqual(["social_image", "links", "slices"]); - expect(Object.keys(writtenType.json.Main.links.config.fields)).toEqual(["url", "label"]); - const choices = writtenType.json.Main.slices.config.choices; - expect(Object.keys(choices)).toEqual([slice.id, "legacy_banner"]); - expect(Object.keys(choices.legacy_banner["non-repeat"])).toEqual(["title", "caption"]); - const writtenSlice = JSON.parse(pulledSlice); - expect(Object.keys(writtenSlice.variations[0].primary)).toEqual(["title", "subtitle"]); - - // Every other key is sorted, and array order is kept. + expect(writtenType).toEqual(customType); expect(Object.keys(writtenType)).toEqual([ "format", "id", @@ -352,18 +349,39 @@ describe("with an isolated repository", () => { "repeatable", "status", ]); - expect(Object.keys(writtenType.json.Main.slices)).toEqual(["config", "fieldset", "type"]); + expect(Object.keys(writtenType.json)).toEqual(["Main", "Details"]); + expect(Object.keys(writtenType.json.Main)).toEqual(["social_image", "links", "slices"]); + expect(Object.keys(writtenType.json.Main.social_image.config)).toEqual([ + "constraint", + "label", + "thumbnails", + ]); expect(Object.keys(writtenType.json.Main.social_image.config.constraint)).toEqual([ "height", "width", ]); - const thumbnails = writtenType.json.Main.social_image.config.thumbnails; - expect(thumbnails.map((thumbnail: { name: string }) => thumbnail.name)).toEqual([ - "small", - "large", + expect(Object.keys(writtenType.json.Main.social_image.config.thumbnails[0])).toEqual([ + "height", + "name", + "width", + ]); + expect(Object.keys(writtenType.json.Main.links.config.fields)).toEqual(["url", "label"]); + expect(Object.keys(writtenType.json.Main.links.config.fields.url.config)).toEqual([ + "label", + "placeholder", + ]); + expect(Object.keys(writtenType.json.Main.slices)).toEqual(["config", "fieldset", "type"]); + + const choices = writtenType.json.Main.slices.config.choices; + expect(Object.keys(choices)).toEqual([slice.id, "legacy_banner"]); + expect(Object.keys(choices.legacy_banner)).toEqual(["fieldset", "non-repeat", "type"]); + expect(Object.keys(choices.legacy_banner["non-repeat"])).toEqual(["title", "caption"]); + expect(Object.keys(choices.legacy_banner["non-repeat"].title.config)).toEqual([ + "label", + "placeholder", ]); - expect(Object.keys(thumbnails[0])).toEqual(["height", "name", "width"]); - expect(Object.keys(writtenSlice)).toEqual(["id", "name", "type", "variations"]); + + expect(writtenSlice).toEqual(slice); expect(Object.keys(writtenSlice.variations[0])).toEqual([ "description", "docURL", @@ -373,35 +391,15 @@ describe("with an isolated repository", () => { "primary", "version", ]); + 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]); 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); - - // The fixtures hold the same models in a different key order, so writing - // them back makes both files non-canonical. Pull counts them 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 unsortedType = JSON.stringify(customType, null, 2); - const unsortedSlice = JSON.stringify(slice, null, 2); - expect(unsortedType).not.toBe(pulledType); - expect(unsortedSlice).not.toBe(pulledSlice); - await writeFile(typePath, unsortedType); - await writeFile(slicePath, unsortedSlice); - - 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); + const typeAfter = await readLocalCustomType(project, customType.id); + const sliceAfter = await readLocalSlice(project, slice.id); + expect(JSON.stringify(typeAfter)).toBe(JSON.stringify(writtenType)); + expect(JSON.stringify(sliceAfter)).toBe(JSON.stringify(writtenSlice)); }); }); From d592dae32ebc0a4042e7e5e8c46bea9f76199650 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 7 Aug 2026 00:04:59 +0000 Subject: [PATCH 13/13] test: check status and sync the same way as pull Write the unsorted fixture locally instead of pulling first, so status and push run against a non-canonical file in two CLI runs. Move the Choices type next to the function that uses it. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/prismic/models.ts | 6 ++---- test/status.test.ts | 37 +++++++------------------------------ test/sync.test.ts | 3 --- 3 files changed, 9 insertions(+), 37 deletions(-) diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index 1d4d44c..22f3e5a 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -8,8 +8,6 @@ import type { type Fields = Record; -type Choices = NonNullable["choices"]>; - export type ContentRelationshipFieldSelection = | string | { @@ -231,6 +229,8 @@ function canonicalizeFields(fields: Record): ); } +type Choices = NonNullable["choices"]>; + // Entry order of a slice zone's choices is its slice order, and legacy slices // hold field maps of their own. function canonicalizeChoices(choices: Choices): Choices { @@ -246,8 +246,6 @@ function canonicalizeChoices(choices: Choices): Choices { ); } -// 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 76a5261..2870ff6 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -1,14 +1,6 @@ import { describe } from "vitest"; -import { - buildCustomType, - buildSlice, - it, - readLocalCustomType, - readLocalSlice, - writeLocalCustomType, - writeLocalSlice, -} from "./it"; +import { buildCustomType, buildSlice, it, writeLocalCustomType, writeLocalSlice } from "./it"; import { insertCustomType, insertSlice } from "./prismic"; it("supports --help", async ({ expect, prismic }) => { @@ -78,8 +70,6 @@ describe("with an isolated repository", () => { token, host, }) => { - // Written with unsorted keys at every depth, so the fixtures double as the - // non-canonical local files below. const customType = buildCustomType({ format: "custom", json: { @@ -98,8 +88,8 @@ describe("with an isolated repository", () => { config: { label: "Links", fields: { - url: { type: "Text", config: { label: "URL", placeholder: "" } }, - label: { type: "Text", config: { label: "Label", placeholder: "" } }, + url: { type: "Text", config: { placeholder: "", label: "URL" } }, + label: { type: "Text", config: { placeholder: "", label: "Label" } }, }, }, }, @@ -110,32 +100,19 @@ describe("with an isolated repository", () => { slice.variations[0].primary = { title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } }, }; + expect(Object.keys(customType)).not.toEqual(Object.keys(customType).sort()); + + await writeLocalCustomType(project, customType); + await writeLocalSlice(project, slice); 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); - - // Write the fixtures back over the pulled files. Same models, different - // key order. - const pulledType = await readLocalCustomType(project, customType.id); - expect(JSON.stringify(customType, null, 2)).not.toBe(JSON.stringify(pulledType, null, 2)); - await writeLocalCustomType(project, customType); - - const pulledSlice = await readLocalSlice(project, slice.id); - if (!pulledSlice) throw new Error(`Slice "${slice.id}" was not pulled.`); - expect(JSON.stringify(slice, null, 2)).not.toBe(JSON.stringify(pulledSlice, null, 2)); - await writeLocalSlice(project, slice); - - // 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."); diff --git a/test/sync.test.ts b/test/sync.test.ts index cacf941..0a041c2 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -37,9 +37,6 @@ 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();