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); 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) { 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(() => ({ 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();