From effb0a65c4158311fce1191414716b187fa38eb1 Mon Sep 17 00:00:00 2001 From: Joel Varty Date: Fri, 30 Oct 2026 13:28:20 -0400 Subject: [PATCH 1/4] CI: change rounds in both directions for the live sync test (PROD-3016) After the first sync, the test now runs rounds that alternate direction: odd rounds edit the source and sync forward, even rounds edit the target and reverse-sync. Every round also edits the receiving side (drift) and one item on both sides (a conflict). - ci/permutation/changes.mjs: change catalogue, discovered from whatever each instance holds. Content edit, add, add a linked chain, delete, publish/unpublish; list and component reordering; page edit, add, delete; component removal; model field add, label change and rename. - run.mjs: per-round sync, verify and no-op checks, plus conflict and drift outcomes (kept-and-reported passes, silently kept fails), and apply failures as findings. quick = fixed two-round set; beta and nightly = seeded random rounds, replayable with the printed seed. - Workflow: always installs dependencies (the change engine uses the Management SDK), takes a seed input, longer time budgets. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/sync-permutation.yml | 14 +- ci/README.md | 42 ++- ci/permutation/changes.mjs | 405 +++++++++++++++++++++++++ ci/permutation/run.mjs | 274 +++++++++++++---- 4 files changed, 656 insertions(+), 79 deletions(-) create mode 100644 ci/permutation/changes.mjs diff --git a/.github/workflows/sync-permutation.yml b/.github/workflows/sync-permutation.yml index 88a161c..4102164 100644 --- a/.github/workflows/sync-permutation.yml +++ b/.github/workflows/sync-permutation.yml @@ -36,6 +36,10 @@ on: description: "Test this published @agility/cli version instead of building this branch" required: false default: "" + seed: + description: "Replay a run's random choices (printed in its summary); blank = new seed" + required: false + default: "" concurrency: group: sync-permutation-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} @@ -97,7 +101,7 @@ jobs: - name: Lease source and target id: lease run: | - case "$TIER" in quick) TTL=60 ;; beta) TTL=120 ;; *) TTL=240 ;; esac + case "$TIER" in quick) TTL=90 ;; beta) TTL=150 ;; *) TTL=240 ;; esac node ci/leases.mjs create "$LEASE_PREFIX" source:blog target:blank --ttl "$TTL" --timeout 900 test: @@ -105,7 +109,7 @@ jobs: needs: lease runs-on: ubuntu-latest environment: cli-dev-live - timeout-minutes: 50 + timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.tier == 'nightly') && 180 || 120 }} steps: - uses: actions/checkout@v4 @@ -115,10 +119,13 @@ jobs: cache: "npm" # The CLI under test: this checkout, built; or a published version from npm. + # Always installed: the change engine (ci/permutation/changes.mjs) uses the Management SDK. + - name: Install dependencies + run: npm ci + - name: Build this branch if: env.CLI_VERSION == '' run: | - npm ci npm run build echo "CLI=node $GITHUB_WORKSPACE/dist/index.js" >> "$GITHUB_ENV" @@ -152,6 +159,7 @@ jobs: SOURCE_GUID: ${{ needs.lease.outputs.source_guid }} TARGET_GUID: ${{ needs.lease.outputs.target_guid }} OUT_DIR: ${{ runner.temp }}/permutation-out + SEED: ${{ inputs.seed }} run: node ci/permutation/run.mjs # Only the JSON results: anyone can download a public repo's artifacts, so the CLI's raw diff --git a/ci/README.md b/ci/README.md index b513f7e..40aec5a 100644 --- a/ci/README.md +++ b/ci/README.md @@ -29,15 +29,39 @@ gh run rerun # re-ru ## What it checks -Today (the first slice): - -- **First sync:** `sync` exits 0 and its `--jsonSummary` reports no failed items. -- **verify:** `agility verify` reports that the target matches the source (exit 0). -- **Nothing left to sync:** an immediate `sync --preflight` plans no creates, updates or conflicts. - -Each check is a row in the run's job summary. The JSON results are kept as an artifact for 30 days. -Later slices add generated sources, rounds of changes on both sides, more target types, and -reverse, round-trip and chained syncs. +A run first syncs source **A** to target **B**, then runs rounds of changes in alternating +directions: + +- **Odd rounds** edit A and `sync` A → B. +- **Even rounds** edit B and `reverse-sync` B → A. +- **Every round** also edits the other instance (drift) and one item on both sides (a conflict). + +The changes, in `ci/permutation/changes.mjs`, are discovered from whatever each instance holds: + +| Area | Changes | +| ---------- | -------------------------------------------------------------------------------------------------- | +| Content | edit, add, add a linked item together with an item that links to it, delete, publish and unpublish | +| Ordering | swap two items in a list; reverse the components in a page zone | +| Pages | edit title and menu text, add a page, delete it later | +| Components | remove one from a zone | +| Models | add a field, change a label, rename a field | + +The `quick` tier runs a fixed set covering every area in two rounds. `beta` (4 rounds) and `nightly` +(6 rounds) pick changes at random from a seed printed in the summary. Replay one with +`gh workflow run sync-permutation.yml -f tier=nightly -f seed=`. + +After every sync it checks: + +- **sync:** exit 0 and no failed items in `--jsonSummary`; +- **verify:** `agility verify` finds no differences; +- **noop:** a `--preflight` in the same direction plans no creates, updates or conflicts; +- **conflict:** the item edited on both sides ends at the synced side's value, or the sync reported a + conflict; +- **drift:** an edit made only on the receiving side is overwritten, or reported, never silently kept; +- **apply:** the change itself could be made. + +Nothing stops a run early: every round runs, and each check is a row in the job summary. The JSON +results are kept as an artifact for 30 days. ## Promotion to `latest` diff --git a/ci/permutation/changes.mjs b/ci/permutation/changes.mjs new file mode 100644 index 0000000..b8d142c --- /dev/null +++ b/ci/permutation/changes.mjs @@ -0,0 +1,405 @@ +// Change catalogue for the live sync test: edits an editor might make between syncs. +// +// Every change discovers what to touch from the instance itself (lists, items, pages, models), so +// it works on whatever the source was built from. Each returns a description of what it did, or +// null when the instance has nothing it applies to. Changes write through the Management API, +// the same way the Manager App does. + +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const mgmt = require("@agility/management-sdk"); + +export function client(token) { + return new mgmt.ApiClient({ token, baseUrl: null, refresh_token: null, duration: 3000, retryCount: 500 }); +} + +// Small seeded RNG (mulberry32), so a run's choices replay exactly from its seed. +export function rng(seed) { + let a = seed >>> 0; + const next = () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { next, pick: (arr) => arr[Math.floor(next() * arr.length)], int: (n) => Math.floor(next() * n) }; +} + +const LIST_PARAMS = { + take: 250, + skip: 0, + filter: "", + fields: "", + sortDirection: "", + sortField: "", + showDeleted: false, +}; +const SYSTEM_LISTS = /^Agility(CSSFiles|JavascriptFiles|GlobalCodeTemplates|ModuleCodeTemplates|PageCodeTemplates)$/i; + +// ── Discovery ───────────────────────────────────────────────────────────────────────────────── + +async function lists(c, guid) { + const all = await c.containerMethods.getContainerList(guid); + // Content lists only (contentDefinitionType 1); components are 2. Not the system lists. + return all.filter((x) => x.referenceName && x.contentDefinitionType === 1 && !SYSTEM_LISTS.test(x.referenceName)); +} + +async function items(c, guid, locale, referenceName) { + try { + const r = await c.contentMethods.getContentItems(referenceName, guid, locale, LIST_PARAMS); + // Rows come back nested, keyed by itemContainerID (the content ID; contentItemID is a version). + const rows = (r?.items ?? []).flat(Infinity); + const seen = new Set(); + return rows + .map((x) => ({ ...x, contentID: x.contentID ?? x.itemContainerID })) + .filter((x) => x.contentID > 0 && !seen.has(x.contentID) && seen.add(x.contentID)); + } catch { + return []; + } +} + +async function listsWithItems(c, guid, locale, min = 1) { + const out = []; + for (const l of await lists(c, guid)) { + const its = await items(c, guid, locale, l.referenceName); + if (its.length >= min) out.push({ list: l, items: its }); + } + return out; +} + +async function pages(c, guid, locale) { + const flat = []; + const walk = (nodes) => { + for (const n of nodes ?? []) { + if (n.pageID > 0) flat.push(n); + walk(n.pages); + } + }; + for (const ch of await c.pageMethods.getSitemap(guid, locale)) { + const before = flat.length; + walk(ch.pages); + for (const p of flat.slice(before)) p.channelID = ch.digitalChannelID; + } + return flat; +} + +async function customModels(c, guid) { + const ms = await c.modelMethods.getContentModules(false, guid); + return ms.filter((m) => m.referenceName && !/^Agility|^RichTextArea$|^FormBuilder$|^Form$/i.test(m.referenceName)); +} + +// The first plain text field of an item, the one an editor would most likely change. +function textField(item) { + for (const [k, v] of Object.entries(item.fields ?? {})) { + if (typeof v === "string" && v.length > 0 && v.length < 200 && !/^\d+$/.test(v) && !/^https?:/.test(v)) return k; + } + return null; +} + +// Single-item links ({contentid: N}) in an item's fields, and string companions holding the same ID. +function links(item) { + const out = []; + for (const [k, v] of Object.entries(item.fields ?? {})) { + if (v && typeof v === "object" && !Array.isArray(v) && Number(v.contentid) > 0) + out.push({ field: k, id: Number(v.contentid) }); + } + return out; +} + +async function save(c, guid, locale, item) { + const ids = await c.contentMethods.saveContentItem(item, guid, locale); + const id = Array.isArray(ids) ? ids[0] : ids; + if (!(id > 0)) throw new Error(`save returned ${JSON.stringify(ids)}`); + return id; +} + +const tag = (ctx) => `r${ctx.round}${ctx.side === "target" ? "t" : "s"}`; + +// ── The catalogue ───────────────────────────────────────────────────────────────────────────── +// ctx: { c, guid, locale, round, side: "source"|"target", r: rng, state } +// state persists across rounds per instance: what earlier rounds created, for later deletes. + +export const CHANGES = { + async "content.edit"(ctx) { + const pool = (await listsWithItems(ctx.c, ctx.guid, ctx.locale)).flatMap((x) => x.items); + let full = null; + for (const it of [...pool].sort(() => ctx.r.next() - 0.5)) { + const candidate = await ctx.c.contentMethods.getContentItem(it.contentID, ctx.guid, ctx.locale); + if (textField(candidate)) { + full = candidate; + break; + } + } + if (!full) return null; + const f = textField(full); + full.fields[f] = `${full.fields[f]} (${tag(ctx)})`; + await save(ctx.c, ctx.guid, ctx.locale, full); + return { + area: "content", + change: "edit", + detail: `${full.properties.referenceName} #${full.contentID} field ${f}`, + }; + }, + + async "content.add"(ctx) { + const l = ctx.r.pick(await listsWithItems(ctx.c, ctx.guid, ctx.locale)); + if (!l) return null; + const src = await ctx.c.contentMethods.getContentItem(ctx.r.pick(l.items).contentID, ctx.guid, ctx.locale); + const f = textField(src); + const copy = { ...src, contentID: -1, properties: { ...src.properties, versionID: null, state: null } }; + if (f) copy.fields = { ...src.fields, [f]: `New ${tag(ctx)} ${src.fields[f]}` }; + const id = await save(ctx.c, ctx.guid, ctx.locale, copy); + (ctx.state.created ??= []).push({ kind: "content", id }); + return { area: "content", change: "add", detail: `${l.list.referenceName} new #${id}` }; + }, + + // A new linked item and a new item that links to it, in one round (dependency order, PROD-3025). + async "content.add-chain"(ctx) { + const all = (await listsWithItems(ctx.c, ctx.guid, ctx.locale)).flatMap((x) => x.items); + for (const it of all) { + const parent = await ctx.c.contentMethods.getContentItem(it.contentID, ctx.guid, ctx.locale); + const ln = links(parent)[0]; + if (!ln) continue; + const child = await ctx.c.contentMethods.getContentItem(ln.id, ctx.guid, ctx.locale); + const cf = textField(child); + const newChild = { ...child, contentID: -1, properties: { ...child.properties, versionID: null, state: null } }; + if (cf) newChild.fields = { ...child.fields, [cf]: `Chain ${tag(ctx)} ${child.fields[cf]}` }; + const childId = await save(ctx.c, ctx.guid, ctx.locale, newChild); + const fields = { ...parent.fields, [ln.field]: { ...parent.fields[ln.field], contentid: childId } }; + for (const [k, v] of Object.entries(fields)) if (v === String(ln.id)) fields[k] = String(childId); // companion field + const pf = textField(parent); + if (pf) fields[pf] = `Chain ${tag(ctx)} ${parent.fields[pf]}`; + const parentId = await save(ctx.c, ctx.guid, ctx.locale, { + ...parent, + contentID: -1, + fields, + properties: { ...parent.properties, versionID: null, state: null }, + }); + (ctx.state.created ??= []).push({ kind: "content", id: parentId }, { kind: "content", id: childId }); + return { + area: "content", + change: "add-chain", + detail: `${parent.properties.referenceName} #${parentId} → ${child.properties.referenceName} #${childId}`, + }; + } + return null; + }, + + async "content.delete"(ctx) { + // Prefer something an earlier round created; otherwise an item nothing links to. + const mine = (ctx.state.created ?? []).filter((x) => x.kind === "content"); + let id = mine.length ? mine.pop().id : null; + if (!id) { + const all = (await listsWithItems(ctx.c, ctx.guid, ctx.locale, 3)).flatMap((x) => x.items); + const full = await Promise.all( + all.map((i) => ctx.c.contentMethods.getContentItem(i.contentID, ctx.guid, ctx.locale)) + ); + const linked = new Set(full.flatMap((i) => links(i).map((l) => l.id))); + id = ctx.r.pick(full.filter((i) => !linked.has(i.contentID)))?.contentID; + } + if (!id) return null; + await ctx.c.contentMethods.deleteContent(id, ctx.guid, ctx.locale); + return { area: "content", change: "delete", detail: `#${id}` }; + }, + + async "content.reorder"(ctx) { + const l = ctx.r.pick(await listsWithItems(ctx.c, ctx.guid, ctx.locale, 2)); + if (!l) return null; + const [a, b] = [l.items[0], l.items[l.items.length - 1]]; + const fa = await ctx.c.contentMethods.getContentItem(a.contentID, ctx.guid, ctx.locale); + const fb = await ctx.c.contentMethods.getContentItem(b.contentID, ctx.guid, ctx.locale); + const oa = fa.properties.itemOrder; + fa.properties.itemOrder = fb.properties.itemOrder; + fb.properties.itemOrder = oa; + await save(ctx.c, ctx.guid, ctx.locale, fa); + await save(ctx.c, ctx.guid, ctx.locale, fb); + return { + area: "ordering", + change: "list-swap", + detail: `${l.list.referenceName} #${a.contentID} ↔ #${b.contentID}`, + }; + }, + + async "content.publish-toggle"(ctx) { + const pool = (await listsWithItems(ctx.c, ctx.guid, ctx.locale)).flatMap((x) => x.items); + const it = ctx.r.pick(pool); + if (!it) return null; + const published = it.properties?.state === 2; + if (published) await ctx.c.contentMethods.unPublishContent(it.contentID, ctx.guid, ctx.locale); + else await ctx.c.contentMethods.publishContent(it.contentID, ctx.guid, ctx.locale); + return { area: "content", change: published ? "unpublish" : "publish", detail: `#${it.contentID}` }; + }, + + async "page.edit"(ctx) { + const p = ctx.r.pick(await pages(ctx.c, ctx.guid, ctx.locale)); + if (!p) return null; + const page = await ctx.c.pageMethods.getPage(p.pageID, ctx.guid, ctx.locale); + page.title = `${page.title} (${tag(ctx)})`; + page.menuText = `${page.menuText ?? page.title} (${tag(ctx)})`; + await ctx.c.pageMethods.savePage(page, ctx.guid, ctx.locale); + return { area: "pages", change: "edit", detail: `page ${page.name} #${page.pageID}` }; + }, + + async "page.component-reorder"(ctx) { + for (const p of await pages(ctx.c, ctx.guid, ctx.locale)) { + const page = await ctx.c.pageMethods.getPage(p.pageID, ctx.guid, ctx.locale); + const zone = Object.keys(page.zones ?? {}).find((z) => page.zones[z]?.length >= 2); + if (!zone) continue; + page.zones[zone] = [...page.zones[zone]].reverse(); + await ctx.c.pageMethods.savePage(page, ctx.guid, ctx.locale); + return { area: "ordering", change: "components-reversed", detail: `page ${page.name} zone ${zone}` }; + } + return null; + }, + + async "page.component-remove"(ctx) { + for (const p of await pages(ctx.c, ctx.guid, ctx.locale)) { + const page = await ctx.c.pageMethods.getPage(p.pageID, ctx.guid, ctx.locale); + const zone = Object.keys(page.zones ?? {}).find((z) => page.zones[z]?.length >= 2); + if (!zone) continue; + const removed = page.zones[zone].pop(); + await ctx.c.pageMethods.savePage(page, ctx.guid, ctx.locale); + return { area: "components", change: "remove", detail: `page ${page.name} zone ${zone}: ${removed?.module}` }; + } + return null; + }, + + async "page.add"(ctx) { + const all = await pages(ctx.c, ctx.guid, ctx.locale); + let src = null; + for (const p of all) { + const full = await ctx.c.pageMethods.getPage(p.pageID, ctx.guid, ctx.locale); + if (full.pageType === "static" && full.templateName) { + src = { ...full, channelID: p.channelID }; + break; + } + } + if (!src) return null; + const name = `perm-${tag(ctx)}-${ctx.r.int(1e6)}`; + // getPage doesn't return the channel; without one the insert fails on FK_DigitalChannel_PageItemContainer. + const page = { + ...src, + pageID: -1, + name, + path: null, + title: `Perm page ${tag(ctx)}`, + menuText: `Perm ${tag(ctx)}`, + zones: {}, + properties: null, + }; + const ids = await ctx.c.pageMethods.savePage(page, ctx.guid, ctx.locale, -1, -1); + const id = Array.isArray(ids) ? ids[0] : ids; + if (!(id > 0)) throw new Error(`savePage returned ${JSON.stringify(ids)}`); + (ctx.state.created ??= []).push({ kind: "page", id }); + return { area: "pages", change: "add", detail: `page ${name} #${id}` }; + }, + + async "page.delete"(ctx) { + const mine = (ctx.state.created ?? []).filter((x) => x.kind === "page"); + const p = mine.pop(); + if (!p) return null; + ctx.state.created = ctx.state.created.filter((x) => x !== p); + await ctx.c.pageMethods.deletePage(p.id, ctx.guid, ctx.locale); + return { area: "pages", change: "delete", detail: `page #${p.id}` }; + }, + + async "model.field-add"(ctx) { + const m = ctx.r.pick(await customModels(ctx.c, ctx.guid)); + if (!m) return null; + const full = await ctx.c.modelMethods.getContentModel(m.id, ctx.guid); + const name = `Perm${tag(ctx).toUpperCase()}${ctx.r.int(1000)}`; + full.fields.push({ + name, + label: `Perm ${tag(ctx)}`, + type: "Text", + settings: {}, + itemOrder: full.fields.length, + fieldID: null, + }); + await ctx.c.modelMethods.saveModel(full, ctx.guid); + (ctx.state.fields ??= []).push({ model: m.id, name }); + return { area: "models", change: "field-add", detail: `${full.referenceName}.${name}` }; + }, + + async "model.field-label"(ctx) { + const m = ctx.r.pick(await customModels(ctx.c, ctx.guid)); + if (!m) return null; + const full = await ctx.c.modelMethods.getContentModel(m.id, ctx.guid); + const f = ctx.r.pick(full.fields); + if (!f) return null; + f.label = `${f.label} (${tag(ctx)})`; + await ctx.c.modelMethods.saveModel(full, ctx.guid); + return { area: "models", change: "field-label", detail: `${full.referenceName}.${f.name}` }; + }, + + // Renaming a field's reference name is what editors fear most: content is keyed by it. + async "model.field-rename"(ctx) { + const added = (ctx.state.fields ?? []).pop(); + const models = await customModels(ctx.c, ctx.guid); + const m = added ? models.find((x) => x.id === added.model) : ctx.r.pick(models); + if (!m) return null; + const full = await ctx.c.modelMethods.getContentModel(m.id, ctx.guid); + const f = added + ? full.fields.find((x) => x.name === added.name) + : ctx.r.pick(full.fields.filter((x) => x.type === "Text")); + if (!f) return null; + const old = f.name; + f.name = `${old}Renamed${tag(ctx).toUpperCase()}`; + await ctx.c.modelMethods.saveModel(full, ctx.guid); + return { area: "models", change: "field-rename", detail: `${full.referenceName}.${old} → ${f.name}` }; + }, +}; + +// Changes made on the other instance than the one being synced from, in the same round. +export async function drift(ctx) { + return CHANGES["content.edit"]({ ...ctx }); +} + +// The same item edited on both sides with different values: a conflict. +export async function conflict(srcCtx, dstCtx, mappingLookup) { + const pool = (await listsWithItems(srcCtx.c, srcCtx.guid, srcCtx.locale)).flatMap((x) => x.items); + for (const it of pool) { + const dstId = mappingLookup(it.contentID); + if (!dstId) continue; + const s = await srcCtx.c.contentMethods.getContentItem(it.contentID, srcCtx.guid, srcCtx.locale); + const f = textField(s); + if (!f) continue; + const d = await dstCtx.c.contentMethods.getContentItem(dstId, dstCtx.guid, dstCtx.locale); + if (typeof d.fields?.[f] !== "string") continue; + const sv = `Conflict ${tag(srcCtx)} source`; + const dv = `Conflict ${tag(srcCtx)} target`; + s.fields[f] = sv; + d.fields[f] = dv; + await save(srcCtx.c, srcCtx.guid, srcCtx.locale, s); + await save(dstCtx.c, dstCtx.guid, dstCtx.locale, d); + return { + area: "conflict", + change: "both-sides-edit", + detail: `#${it.contentID} ↔ #${dstId} field ${f}`, + srcId: it.contentID, + dstId, + field: f, + sv, + dv, + }; + } + return null; +} + +// Read one item's field on an instance (for checking conflict and drift outcomes). +export async function readField(c, guid, locale, id, field) { + try { + return (await c.contentMethods.getContentItem(id, guid, locale))?.fields?.[field]; + } catch { + return undefined; + } +} + +export const QUICK_ROUNDS = [ + // Round 1, source → target + ["content.edit", "content.add-chain", "content.reorder", "page.edit", "page.component-reorder", "model.field-add"], + // Round 2, target → source (reverse sync) + ["content.edit", "content.delete", "page.component-remove", "page.add", "model.field-label", "model.field-rename"], +]; diff --git a/ci/permutation/run.mjs b/ci/permutation/run.mjs index b45a5cb..44bfde5 100644 --- a/ci/permutation/run.mjs +++ b/ci/permutation/run.mjs @@ -1,25 +1,37 @@ #!/usr/bin/env node // CLI sync permutation test runner (PROD-3015, PROD-3016). // -// Slice 0/1: one source (the dev "blog" template) synced to one new, empty target. Checks: -// first-sync `sync` exits 0 and its JSON summary reports no failures -// verify `verify` exits 0 (target matches source) -// resync-noop a `sync --preflight` straight after plans no creates, updates or conflicts -// Later slices add the generator (PROD-3017), change rounds (PROD-3018), the expected-result -// model (PROD-3019), more target types and directions. Each check becomes a row in the grid. +// Syncs a source (A) to a target (B), then runs rounds of changes in alternating directions: +// round 0 first sync A → B +// odd edit A, sync A → B (`sync`) +// even edit B, sync B → A (`reverse-sync`, same A/B pair and mappings) +// Every round also edits the other instance (drift) and one item on both sides (a conflict). // -// Env: SOURCE_GUID, TARGET_GUID, AGILITY_TOKEN (a token that can read and write both instances), CLI (command that runs the -// CLI under test, e.g. "node /path/dist/index.js"), OUT_DIR (results; default ./permutation-out). -// Exit 0 when every check passes, 1 otherwise. +// After each sync it checks: +// sync exit 0, no failed items +// verify `agility verify` finds no differences +// noop a preflight in the same direction plans no creates, updates or conflicts +// conflict the both-sides item ends at the synced side's value, or the sync reported a conflict +// drift the other side's own edit was overwritten, or reported; never silently kept +// apply:* the change itself could be made (an API rejection is a finding too) +// Nothing stops the run early: every round runs and every result is recorded. +// +// Env: SOURCE_GUID, TARGET_GUID, AGILITY_TOKEN, CLI (command for the CLI under test), +// TIER (quick|beta|nightly), SEED (default random, printed), LOCALE (default en-us), +// OUT_DIR (default ./permutation-out). Exit 0 when every check passes, 1 otherwise. import { spawnSync } from "node:child_process"; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import { CHANGES, QUICK_ROUNDS, client, conflict, drift, readField, rng } from "./changes.mjs"; -const { SOURCE_GUID, TARGET_GUID, AGILITY_TOKEN, CLI } = process.env; +const { SOURCE_GUID: A, TARGET_GUID: B, AGILITY_TOKEN, CLI } = process.env; +const TIER = process.env.TIER || "quick"; +const LOCALE = process.env.LOCALE || "en-us"; +const SEED = Number(process.env.SEED) || Math.floor(Math.random() * 2 ** 31); const OUT = resolve(process.env.OUT_DIR || "permutation-out"); const WORK = join(OUT, "work"); // the CLI writes agility-files/ (pulls, mappings, logs) under its cwd -for (const [k, v] of Object.entries({ SOURCE_GUID, TARGET_GUID, AGILITY_TOKEN, CLI })) { +for (const [k, v] of Object.entries({ SOURCE_GUID: A, TARGET_GUID: B, AGILITY_TOKEN, CLI })) { if (!v) { console.error(`::error::${k} is not set`); process.exit(1); @@ -27,91 +39,219 @@ for (const [k, v] of Object.entries({ SOURCE_GUID, TARGET_GUID, AGILITY_TOKEN, C } mkdirSync(WORK, { recursive: true }); -const pair = [`--sourceGuid=${SOURCE_GUID}`, `--targetGuid=${TARGET_GUID}`]; +const r = rng(SEED); +const api = client(AGILITY_TOKEN); +const pair = [`--sourceGuid=${A}`, `--targetGuid=${B}`]; const results = []; +const rounds = []; +const state = { [A]: {}, [B]: {} }; function cli(step, args) { const summary = join(OUT, `${step}.json`); const [cmd, ...pre] = CLI.split(" "); console.log(`\n▶ ${step}: agility ${args.join(" ")}`); const started = Date.now(); - const r = spawnSync(cmd, [...pre, ...args, "--headless", `--jsonSummary=${summary}`], { + const p = spawnSync(cmd, [...pre, ...args, "--headless", `--jsonSummary=${summary}`], { cwd: WORK, stdio: "inherit", env: { ...process.env, AGILITY_TOKEN }, // the token goes by env, never on the command line timeout: 60 * 60 * 1000, }); const json = existsSync(summary) ? JSON.parse(readFileSync(summary, "utf8")) : null; - return { exitCode: r.status ?? -1, json, seconds: Math.round((Date.now() - started) / 1000) }; + return { exitCode: p.status ?? -1, json, seconds: Math.round((Date.now() - started) / 1000) }; } -function record(check, pass, detail, seconds) { - results.push({ - area: "all", - change: "none", - target: "new-empty", - direction: "forward", - check, - pass, - detail, - seconds, - }); - console.log(`${pass ? "✅" : "❌"} ${check}: ${detail}`); +function record(round, direction, check, pass, detail, seconds = 0) { + results.push({ round, direction, check, pass, detail, seconds }); + console.log(`${pass ? "✅" : "❌"} [r${round} ${direction}] ${check}: ${detail}`); } -// 1. First sync to a new, empty target. -const sync = cli("first-sync", ["sync", ...pair, "--autoPublish=both"]); -const failed = sync.json?.totals?.failed ?? "?"; -record( - "first-sync", - sync.exitCode === 0 && failed === 0, - `exit ${sync.exitCode}, ${sync.json?.totals?.successful ?? "?"} ok, ${failed} failed, ${sync.json?.warnings?.length ?? "?"} warnings`, - sync.seconds -); +// Content ID on the other instance, from the sync's own mapping file (one file serves both directions). +function mapped(fromSourceSide) { + const file = join(WORK, "agility-files", "mappings", `${A}-${B}`, LOCALE, "item", "mappings.json"); + const rows = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : []; + return (id) => { + const row = rows.find((m) => (fromSourceSide ? m.sourceContentID === id : m.targetContentID === id)); + return row ? (fromSourceSide ? row.targetContentID : row.sourceContentID) : null; + }; +} -// 2. Verify the target against the source. -const verify = cli("verify", ["verify", ...pair]); -record( - "verify", - verify.exitCode === 0, - { 0: "target matches source", 1: "differences found", 2: "could not verify" }[verify.exitCode] ?? - `exit ${verify.exitCode}`, - verify.seconds -); +// One sync in a direction, with a plan before it, then verify and the no-op preflight. +function syncAndCheck(round, direction) { + const cmd = direction === "A→B" ? "sync" : "reverse-sync"; + const plan = cli(`r${round}-plan`, [cmd, ...pair, "--preflight"]); + const s = cli(`r${round}-sync`, [cmd, ...pair, "--autoPublish=both"]); + const failed = s.json?.totals?.failed ?? "?"; + const opErr = s.json?.operationErrors?.[0]?.error; + record( + round, + direction, + "sync", + s.exitCode === 0 && failed === 0, + `exit ${s.exitCode}, ${s.json?.totals?.successful ?? "?"} ok, ${failed} failed${opErr ? `; ${String(opErr).slice(0, 160)}` : ""}`, + s.seconds + ); + for (const f of (s.json?.failures ?? []).slice(0, 8)) { + record(round, direction, `sync-failure:${f.type ?? "item"}`, false, `${f.name}: ${String(f.error).slice(0, 180)}`); + } -// 3. A second sync must change nothing: preflight plans it without writing. -const again = cli("resync-noop", ["sync", ...pair, "--preflight"]); -const t = again.json?.preflight?.totals; -record( - "resync-noop", - again.exitCode === 0 && t && t.create === 0 && t.update === 0 && t.conflict === 0, - t - ? `would create ${t.create}, update ${t.update}, conflict ${t.conflict}, invalid ${t.invalid}` - : `exit ${again.exitCode}, no preflight plan`, - again.seconds -); + const v = cli(`r${round}-verify`, ["verify", ...pair]); + const findings = v.json?.findings ?? []; + const kinds = [...new Set(findings.map((x) => x.kind ?? x.type ?? x.code).filter(Boolean))].slice(0, 6).join(", "); + record( + round, + direction, + "verify", + v.exitCode === 0, + { 0: "match", 1: `${findings.length || "some"} differences${kinds ? ` (${kinds})` : ""}`, 2: "could not verify" }[ + v.exitCode + ] ?? `exit ${v.exitCode}`, + v.seconds + ); + + const n = cli(`r${round}-noop`, [cmd, ...pair, "--preflight"]); + const t = n.json?.preflight?.totals; + record( + round, + direction, + "noop", + n.exitCode === 0 && !!t && t.create === 0 && t.update === 0 && t.conflict === 0, + t ? `would create ${t.create}, update ${t.update}, conflict ${t.conflict}` : `exit ${n.exitCode}, no plan`, + n.seconds + ); + return { planConflicts: plan.json?.preflight?.totals?.conflict ?? 0 }; +} + +// ── Round 0: first sync ─────────────────────────────────────────────────────────────────────── +syncAndCheck(0, "A→B"); + +// ── Change rounds ───────────────────────────────────────────────────────────────────────────── +const names = Object.keys(CHANGES); +const plan = + TIER === "quick" + ? QUICK_ROUNDS + : Array.from({ length: TIER === "nightly" ? 6 : 4 }, () => + Array.from({ length: 3 + r.int(3) }, () => r.pick(names)) + ); + +for (let i = 0; i < plan.length; i++) { + const round = i + 1; + const forward = round % 2 === 1; + const direction = forward ? "A→B" : "B→A"; + const from = forward ? A : B; + const to = forward ? B : A; + const fromCtx = { + c: api, + guid: from, + locale: LOCALE, + round, + side: forward ? "source" : "target", + r, + state: state[from], + }; + const toCtx = { c: api, guid: to, locale: LOCALE, round, side: forward ? "target" : "source", r, state: state[to] }; + const applied = []; + + for (const name of plan[i]) { + try { + const did = await CHANGES[name](fromCtx); + if (did) applied.push({ name, ...did }); + else console.log(` (skipped ${name}: nothing it applies to)`); + } catch (e) { + record(round, direction, `apply:${name}`, false, String(e?.message ?? e).slice(0, 200)); + } + } + + let drifted = null; + try { + drifted = await drift(toCtx); + if (drifted) applied.push({ name: "drift", ...drifted, area: "drift" }); + } catch (e) { + record(round, direction, "apply:drift", false, String(e?.message ?? e).slice(0, 200)); + } + + let both = null; + try { + both = await conflict(fromCtx, toCtx, mapped(forward)); + if (both) applied.push({ name: "conflict", ...both }); + } catch (e) { + record(round, direction, "apply:conflict", false, String(e?.message ?? e).slice(0, 200)); + } + + rounds.push({ round, direction, changes: applied.map(({ sv, dv, ...rest }) => rest) }); + console.log( + `\n── round ${round} (${direction}): ${applied.map((a) => `${a.area}/${a.change}`).join(", ") || "no changes"}` + ); + + const { planConflicts } = syncAndCheck(round, direction); -// Results: JSON for later tooling, a table for the job summary. -const cliVersion = sync.json?.run?.cliVersion ?? verify.json?.run?.cliVersion ?? "unknown"; + if (both) { + const after = await readField(api, to, LOCALE, both.dstId, both.field); + const synced = after === both.sv; + record( + round, + direction, + "conflict", + synced || planConflicts > 0, + synced + ? "the synced side's value won" + : planConflicts > 0 + ? `kept, and preflight reported ${planConflicts} conflict(s)` + : `silently kept the other side's value` + ); + } + if (drifted) { + const id = Number(/#(\d+)/.exec(drifted.detail)?.[1]); + const field = /field (\S+)/.exec(drifted.detail)?.[1]; + const otherId = mapped(!forward)(id); + const [mine, theirs] = await Promise.all([ + readField(api, to, LOCALE, id, field), + otherId ? readField(api, from, LOCALE, otherId, field) : undefined, + ]); + const same = mine === theirs; + record( + round, + direction, + "drift", + same || planConflicts > 0, + same + ? "overwritten to match" + : planConflicts > 0 + ? "kept, and reported as a conflict" + : "the other side's edit was silently kept; the instances differ" + ); + } +} + +// ── Results ─────────────────────────────────────────────────────────────────────────────────── +let cliVersion = "unknown"; +try { + cliVersion = JSON.parse(readFileSync(join(OUT, "r0-sync.json"), "utf8")).run.cliVersion; +} catch {} writeFileSync( join(OUT, "results.json"), - JSON.stringify({ cliVersion, source: SOURCE_GUID, target: TARGET_GUID, results }, null, 2) + JSON.stringify({ cliVersion, tier: TIER, seed: SEED, source: A, target: B, rounds, results }, null, 2) ); -const rows = results.map( - (r) => - `| ${r.check} | ${r.target} | ${r.direction} | ${r.pass ? "✅ pass" : "❌ fail"} | ${r.detail} | ${r.seconds}s |` -); +const failed = results.filter((x) => !x.pass); const md = [ - `### CLI sync permutation test: CLI ${cliVersion}`, + `### Sync permutation test: CLI ${cliVersion}, tier ${TIER}, seed ${SEED}`, + "", + `A \`${A}\` ↔ B \`${B}\`: ${results.length} checks, **${failed.length} failed**. Replay with \`SEED=${SEED} TIER=${TIER}\`.`, "", - `Source \`${SOURCE_GUID}\` → target \`${TARGET_GUID}\` (dev leases, deleted after the run).`, + "| Round | Direction | Changes |", + "|---|---|---|", + ...rounds.map((x) => `| ${x.round} | ${x.direction} | ${x.changes.map((c) => `${c.area}/${c.change}`).join(", ")} |`), "", - "| Check | Target | Direction | Result | Detail | Time |", + "| Round | Direction | Check | Result | Detail | Time |", "|---|---|---|---|---|---|", - ...rows, + ...results.map( + (x) => + `| ${x.round} | ${x.direction} | ${x.check} | ${x.pass ? "✅" : "❌"} | ${String(x.detail).replace(/\|/g, "\\|")} | ${x.seconds}s |` + ), "", ].join("\n"); if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); +writeFileSync(join(OUT, "summary.md"), md); -process.exit(results.every((r) => r.pass) ? 0 : 1); +process.exit(failed.length ? 1 : 0); From f01fe3f0287256fd35e3fdc426051609409c52d9 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 30 Oct 2026 13:54:11 -0400 Subject: [PATCH 2/4] PROD-3043: sync a dynamic page's empty title as empty, not its name The page payload filled a missing title with menuText, then the page name, so a dynamic page without a title of its own got its name on the target (e.g. 'posts-dynamic') and verify reported pageFieldMismatch on every run. A dynamic page's title comes from dynamic.titleFormula, and the Management API accepts it empty (checked on a clone: saved with an empty title, stored as ''), so a dynamic page now sends its source title as is. Other page types keep the fallback. A target that already took the name keeps it until its source page changes, or a sync with --overwrite. Co-Authored-By: Claude Opus 5.5 --- src/lib/pushers/page-pusher/process-page.ts | 4 ++- .../page-pusher/tests/process-page.test.ts | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/lib/pushers/page-pusher/process-page.ts b/src/lib/pushers/page-pusher/process-page.ts index 927c19d..4b934b6 100644 --- a/src/lib/pushers/page-pusher/process-page.ts +++ b/src/lib/pushers/page-pusher/process-page.ts @@ -392,7 +392,9 @@ export async function processPage({ // CRITICAL FIX: Ensure every page has a valid title field // Folder pages often don't have titles, but API requires them - const pageTitle = page.title || page.menuText || page.name || "Untitled Page"; + // PROD-3043: except a dynamic page. Its title comes from dynamic.titleFormula, the API accepts + // it empty, and a source page without one must stay without one rather than take its name. + const pageTitle = page.pageType === "dynamic" ? (page.title ?? "") : page.title || page.menuText || page.name || "Untitled Page"; const pageJSON = JSON.stringify(page, null, 2); const pageCopy = JSON.parse(pageJSON) as mgmtApi.PageItem; // Create a copy to avoid modifying original diff --git a/src/lib/pushers/page-pusher/tests/process-page.test.ts b/src/lib/pushers/page-pusher/tests/process-page.test.ts index d1af91b..715b051 100644 --- a/src/lib/pushers/page-pusher/tests/process-page.test.ts +++ b/src/lib/pushers/page-pusher/tests/process-page.test.ts @@ -353,6 +353,33 @@ describe("processPage — successful batch save", () => { expect(result.status).toBe("success"); }); + describe("title (PROD-3043)", () => { + function savedTitle(page: any): Promise { + const { TemplateMapper } = require("lib/mappers/template-mapper"); + TemplateMapper.mockImplementation(() => ({ + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), + getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), + })); + mockPoll.mockResolvedValue({ failedItems: [], successItems: [makePage({ pageID: 200 })] }); + mockExtract.mockReturnValue({ successfulItems: [{ newId: 200, newItem: { processedItemVersionID: 5 } }], failedItems: [] }); + const props = makeProps({ page, pageMapper: makePageMapper({ hasSourceChanged: jest.fn().mockReturnValue(true) }) }); + return processPage(props).then(() => props.apiClient.pageMethods.savePage.mock.calls[0][0].title); + } + + it("sends a dynamic page's empty title as empty, not its name", async () => { + await expect(savedTitle(makePage({ pageType: "dynamic", name: "posts-dynamic", title: null, menuText: "Posts" }))).resolves.toBe(""); + }); + + it("sends a dynamic page's own title when it has one", async () => { + await expect(savedTitle(makePage({ pageType: "dynamic", name: "posts-dynamic", title: "Posts" }))).resolves.toBe("Posts"); + }); + + it("still falls back to the menu text or name for a static page without a title", async () => { + await expect(savedTitle(makePage({ title: null, menuText: "Menu" }))).resolves.toBe("Menu"); + await expect(savedTitle(makePage({ title: "", menuText: null, name: "about" }))).resolves.toBe("about"); + }); + }); + it("calls pageMapper.addMapping after a successful save", async () => { const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ From 05105d7e09d3f040b3a3a69cdda746dea7cbcca6 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 30 Oct 2026 13:54:11 -0400 Subject: [PATCH 3/4] PROD-3042: advance container mappings a sync's page saves made stale Saving a page (UpdatePageItem) aligns the requiresApproval setting of the non-shared containers of its components with the page's, and stamps lastModifiedDate on each container it changes. The container pusher compares that date with the mapping, so on the next run those containers looked changed on the target and every following sync wanted --overwrite. After the page push, when it saved a page, the containers are re-read (getContainerList) and each mapping that was in sync with the target pull at the start of the run, and has a newer target date now, takes the new date. A container already changed on the target before the run keeps its conflict. Bookkeeping only: an API error is a logged warning. On a clone pair whose containers already match their pages' setting no date moves and nothing is advanced; the two-direction permutation test is where the reported case shows up. Co-Authored-By: Claude Opus 5.5 --- src/lib/mappers/refresh-container-dates.ts | 99 +++++++++++++++ .../tests/refresh-container-dates.test.ts | 113 ++++++++++++++++++ src/lib/pushers/orchestrate-pushers.ts | 8 ++ 3 files changed, 220 insertions(+) create mode 100644 src/lib/mappers/refresh-container-dates.ts create mode 100644 src/lib/mappers/tests/refresh-container-dates.test.ts diff --git a/src/lib/mappers/refresh-container-dates.ts b/src/lib/mappers/refresh-container-dates.ts new file mode 100644 index 0000000..75f110b --- /dev/null +++ b/src/lib/mappers/refresh-container-dates.ts @@ -0,0 +1,99 @@ +/** + * PROD-3042: after a sync saves pages, advance the container mappings the page saves made look + * "changed on the target". + * + * Saving a page (UpdatePageItem) aligns the "requires approval" setting of the non-shared + * containers of its components with the page's, and stamps lastModifiedDate on each one it changes. + * The container pusher compares a target container's lastModifiedDate with the one in its mapping, + * so on the next run those containers looked edited on the target, and every following sync needed + * --overwrite. That's the same family as PROD-2604 (models) and PROD-2628 (pages in other locales). + * + * A container that was in sync with its mapping when the run started, and has a newer date now, + * was changed by this run, so its mapping takes the new date. A container already changed on the + * target before the run is left alone, so a real target edit still shows as a conflict. + */ + +import { parse } from "date-fns"; +import { state, getApiClient, getLoggerForGuid } from "../../core/state"; +import { fileOperations } from "../../core/fileOperations"; +import { GuidDataLoader } from "../pushers/guid-data-loader"; + +const DATE_FORMAT = "MM/dd/yyyy hh:mma"; + +function toTime(value: string | undefined | null): number | undefined { + if (!value) return undefined; + const time = parse(value, DATE_FORMAT, new Date()).getTime(); + return Number.isNaN(time) ? undefined : time; +} + +export interface ContainerDateUpdate { + targetContentViewID: number; + from: string; + to: string; +} + +/** + * Work out which container mapping records to advance, and advance them in place. + * + * @param records container mapping records (targetContentViewID, targetLastModifiedDate) + * @param pulledDates target container ID -> lastModifiedDate in the target pull, i.e. at the start of the run + * @param currentDates target container ID -> lastModifiedDate now, after the run's writes + */ +export function advanceContainerDates( + records: any[], + pulledDates: Map, + currentDates: Map +): ContainerDateUpdate[] { + const updates: ContainerDateUpdate[] = []; + for (const record of records || []) { + const id = Number(record?.targetContentViewID); + const mapped = toTime(record?.targetLastModifiedDate); + const current = currentDates.get(id); + const currentTime = toTime(current); + if (!(id > 0) || mapped === undefined || currentTime === undefined || currentTime <= mapped) continue; + + // Not in the pull (e.g. created this run): its mapping was written by this run. + const pulled = toTime(pulledDates.get(id)); + const inSyncAtStart = pulled === undefined || pulled <= mapped; + if (!inSyncAtStart) continue; + + updates.push({ targetContentViewID: id, from: record.targetLastModifiedDate, to: current! }); + record.targetLastModifiedDate = current; + } + return updates; +} + +/** Re-read the target's containers and advance the mappings this run's page saves made stale. */ +export async function refreshContainerDatesAfterPageSaves(sourceGuid: string, targetGuid: string, locale: string): Promise { + if (state.preflight) return 0; + const logger = getLoggerForGuid(sourceGuid); + const fileOps = new fileOperations(targetGuid); + const records = fileOps.getMappingFile("containers", sourceGuid, targetGuid, null); + if (!Array.isArray(records) || records.length === 0) return 0; + + try { + const pulled = await new GuidDataLoader(targetGuid).loadCompleteGuidEntities(locale); + const pulledDates = new Map( + (pulled.containers || []).map((c: any) => [Number(c.contentViewID), c.lastModifiedDate] as [number, string]) + ); + const current = await getApiClient().containerMethods.getContainerList(targetGuid); + const currentDates = new Map( + (current || []).map((c: any) => [Number(c.contentViewID), c.lastModifiedDate] as [number, string]) + ); + + const updates = advanceContainerDates(records, pulledDates, currentDates); + if (updates.length > 0) { + fileOps.saveMappingFile(records, "containers", sourceGuid, targetGuid); + logger?.info( + `Container mappings: ${updates.length} advanced to the date this sync's page saves set (` + + updates.map((u) => u.targetContentViewID).join(", ") + + ")" + ); + } + return updates.length; + } catch (error: any) { + // Bookkeeping only: the sync itself succeeded. The cost of failing here is a false conflict next run. + logger?.warning(`Could not refresh container mapping dates after page saves: ${error?.message || error}`); + return 0; + } +} diff --git a/src/lib/mappers/tests/refresh-container-dates.test.ts b/src/lib/mappers/tests/refresh-container-dates.test.ts new file mode 100644 index 0000000..fd7484c --- /dev/null +++ b/src/lib/mappers/tests/refresh-container-dates.test.ts @@ -0,0 +1,113 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { state, resetState, setState } from "../../../core/state"; +import { advanceContainerDates, refreshContainerDatesAfterPageSaves } from "../refresh-container-dates"; + +const T0 = "10/30/2026 10:00AM"; // recorded at the last sync +const T1 = "10/30/2026 10:05AM"; // set by this run's page save +const TX = "10/30/2026 09:30AM"; // older + +const record = (id: number, date: string) => ({ sourceContentViewID: id - 100, targetContentViewID: id, targetLastModifiedDate: date }); + +describe("advanceContainerDates", () => { + it("advances a container that was in sync at the start and was bumped during the run", () => { + const records = [record(101, T0)]; + + const updates = advanceContainerDates(records, new Map([[101, T0]]), new Map([[101, T1]])); + + expect(updates).toEqual([{ targetContentViewID: 101, from: T0, to: T1 }]); + expect(records[0].targetLastModifiedDate).toBe(T1); + }); + + it("keeps the conflict for a container already changed on the target before the run", () => { + // The pull already had a date after the mapping: a real target edit. + const records = [record(101, T0)]; + + const updates = advanceContainerDates(records, new Map([[101, T1]]), new Map([[101, "10/30/2026 10:10AM"]])); + + expect(updates).toEqual([]); + expect(records[0].targetLastModifiedDate).toBe(T0); + }); + + it("leaves a container whose date didn't move", () => { + const records = [record(101, T0)]; + + expect(advanceContainerDates(records, new Map([[101, T0]]), new Map([[101, T0]]))).toEqual([]); + }); + + it("counts a pulled date older than the mapping as in sync", () => { + const records = [record(101, T0)]; + + expect(advanceContainerDates(records, new Map([[101, TX]]), new Map([[101, T1]]))).toHaveLength(1); + }); + + it("treats a container missing from the pull (created this run) as in sync", () => { + const records = [record(101, T0)]; + + expect(advanceContainerDates(records, new Map(), new Map([[101, T1]]))).toHaveLength(1); + }); + + it("skips records it can't read and containers no longer on the target", () => { + const records = [record(101, "not a date"), record(102, T0), { targetContentViewID: 0, targetLastModifiedDate: T0 }]; + + expect(advanceContainerDates(records, new Map(), new Map([[101, T1]]))).toEqual([]); + }); +}); + +describe("refreshContainerDatesAfterPageSaves", () => { + const SRC = "src-1"; + const TGT = "tgt-1"; + let root: string; + const mappingFile = () => path.join(root, "mappings", `${SRC}-${TGT}`, "containers", "mappings.json"); + + const writeJson = (rel: string, data: any) => { + const file = path.join(root, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data)); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "container-dates-")); + // 130: a page's component container, in sync. 131: edited on the target before this run. + writeJson(`${TGT}/containers/130.json`, { contentViewID: 130, referenceName: "home_heading", lastModifiedDate: T0 }); + writeJson(`${TGT}/containers/131.json`, { contentViewID: 131, referenceName: "posts", lastModifiedDate: T1 }); + writeJson(`mappings/${SRC}-${TGT}/containers/mappings.json`, [record(130, T0), record(131, T0)]); + + resetState(); + setState({ rootPath: root, sourceGuid: SRC, targetGuid: TGT, locales: "en-us", isSync: true }); + state.cachedApiClient = { + containerMethods: { + getContainerList: jest.fn(async () => [ + { contentViewID: 130, lastModifiedDate: T1 }, + { contentViewID: 131, lastModifiedDate: "10/30/2026 10:10AM" }, + ]), + }, + } as any; + }); + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it("advances only the container the page save bumped, and saves the mapping", async () => { + const count = await refreshContainerDatesAfterPageSaves(SRC, TGT, "en-us"); + + expect(count).toBe(1); + const saved = JSON.parse(fs.readFileSync(mappingFile(), "utf8")); + expect(saved.find((r: any) => r.targetContentViewID === 130).targetLastModifiedDate).toBe(T1); + expect(saved.find((r: any) => r.targetContentViewID === 131).targetLastModifiedDate).toBe(T0); + }); + + it("does nothing in preflight", async () => { + state.preflight = true; + + expect(await refreshContainerDatesAfterPageSaves(SRC, TGT, "en-us")).toBe(0); + expect((state.cachedApiClient as any).containerMethods.getContainerList).not.toHaveBeenCalled(); + }); + + it("is bookkeeping only: an API error doesn't throw", async () => { + (state.cachedApiClient as any).containerMethods.getContainerList.mockRejectedValueOnce(new Error("busy")); + + await expect(refreshContainerDatesAfterPageSaves(SRC, TGT, "en-us")).resolves.toBe(0); + }); +}); diff --git a/src/lib/pushers/orchestrate-pushers.ts b/src/lib/pushers/orchestrate-pushers.ts index 67b6577..32c62c2 100644 --- a/src/lib/pushers/orchestrate-pushers.ts +++ b/src/lib/pushers/orchestrate-pushers.ts @@ -402,6 +402,14 @@ export class Pushers { } } + // PROD-3042: a page save can bump the modified date of its components' containers. Advance the + // mappings of containers that were in sync before this run, so the next run doesn't see them + // as changed on the target. + if (phases.some((p) => p.operation === "pushPages" && p.successful > 0)) { + const { refreshContainerDatesAfterPageSaves } = await import("../mappers/refresh-container-dates"); + await refreshContainerDatesAfterPageSaves(sourceGuid, targetGuid, locales[0]); + } + // --syncDeletes runs last: every push has repointed what it could, so the targets that are // still only reachable from a deleted source can go, dependents first. if (state.syncDeletes) { From 29b82986d7e2af5803c0235519a8bc68f51d3b12 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 30 Oct 2026 13:57:53 -0400 Subject: [PATCH 4/4] PROD-2992: test --syncDeletes on reverse-sync A delete made on the target is propagated to the source by reverse-sync --syncDeletes: same pair and mapping files, deletes on the original source, mappings removed in their forward orientation. The sync harness gains a reverse switch that runs the CLI's own enableReverseSync. The test fails without --syncDeletes on the reverse run. Co-Authored-By: Claude Opus 5.5 --- src/tests/sync/helpers/run-sync.ts | 8 +++++++- src/tests/sync/sync-deletes.test.ts | 31 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/tests/sync/helpers/run-sync.ts b/src/tests/sync/helpers/run-sync.ts index 7a7783d..0a07e9f 100644 --- a/src/tests/sync/helpers/run-sync.ts +++ b/src/tests/sync/helpers/run-sync.ts @@ -2,7 +2,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { state, resetState, setState } from "core/state"; +import { state, resetState, setState, enableReverseSync } from "core/state"; import { Pushers, PushResults } from "lib/pushers/orchestrate-pushers"; import type { JsonSummaryPhase } from "core/json-summary"; @@ -88,6 +88,11 @@ export class SyncHarness { private readonly preflight: boolean; /** Extra CLI state applied on every run; reassign between runs to change flags for run 2. */ stateOverrides: Record; + /** + * Run as `reverse-sync`: same pair and mapping files, pushing target -> source. Set it between + * runs, after projecting the target to disk (it is the source of a reverse run). + */ + reverse = false; constructor(opts: RunSyncOptions) { this.sourceGuid = opts.sourceGuid ?? "src-1"; @@ -137,6 +142,7 @@ export class SyncHarness { preflight: this.preflight, ...this.stateOverrides, }); + if (this.reverse) enableReverseSync(); state.cachedApiClient = this.fake.asApiClient(); state.availableLocales = [...this.locales]; this.wireBatchLayer(); diff --git a/src/tests/sync/sync-deletes.test.ts b/src/tests/sync/sync-deletes.test.ts index 31170fd..116ceb4 100644 --- a/src/tests/sync/sync-deletes.test.ts +++ b/src/tests/sync/sync-deletes.test.ts @@ -124,6 +124,37 @@ describe("--syncDeletes through the orchestrator", () => { expect(run2.phases.find((p) => p.operation === "syncDeletes")).toMatchObject({ successful: 2, failed: 0 }); }); + it("deletes on reverse-sync too: what was deleted on the target goes from the source", async () => { + harness = new SyncHarness({ elements: "Models,Containers", fixture: fixture() }); + await harness.run(); + // A reverse run pushes into the original source, so give the fake the source's entities. + [POST_MODEL, AUTHOR_MODEL, NEWS_MODEL].forEach((m) => harness.fake.seedModel(harness.sourceGuid, { ...m })); + [POSTS_CONTAINER, AUTHORS_CONTAINER].forEach((c) => harness.fake.seedContainer(harness.sourceGuid, { ...c })); + + // Delete Author and its container on the target, then pull the target (the reverse run's source). + const target = harness.fake.instance(harness.targetGuid); + const authorTarget = harness.readMappings("models")!.find((m) => m.sourceID === 11)!.targetID; + const authorsContainerTarget = harness.readMappings("containers")!.find((m) => m.sourceContentViewID === 201)!.targetContentViewID; + target.containers.delete(authorsContainerTarget); + target.models.delete(authorTarget); + fs.rmSync(path.join(harness.rootPath, harness.targetGuid), { recursive: true, force: true }); + harness.fake.projectToDisk(harness.rootPath, harness.targetGuid); + harness.fake.resetCalls(); + + harness.reverse = true; + harness.stateOverrides = { syncDeletes: true }; + const run2 = await harness.run(); + + const source = harness.fake.instance(harness.sourceGuid); + expect(source.models.has(11)).toBe(false); + expect(source.containers.has(201)).toBe(false); + expect(Array.from(source.models.keys()).sort()).toEqual([10, 12]); + expect(run2.phases.find((p) => p.operation === "syncDeletes")).toMatchObject({ successful: 2, failed: 0 }); + // The mapping files keep the forward orientation (source = A) and lose the pair. + expect(harness.readMappings("models")!.map((m) => m.sourceID).sort()).toEqual([10, 12]); + expect(harness.readMappings("containers")!.map((m) => m.sourceContentViewID)).toEqual([200]); + }); + it("leaves source deletes alone without the flag", async () => { harness = new SyncHarness({ elements: "Models,Containers", fixture: fixture() }); await harness.run();