From 427e2b82633784780ac1ef07128fe5f814d2283a Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Wed, 15 Jul 2026 21:10:08 +0800 Subject: [PATCH] fix(sdk): harden version synchronization --- CLAUDE.md | 2 +- README.md | 3 +- packages/CLAUDE.md | 2 +- packages/scripts/sync-version.mjs | 331 +++++++++++++++++++++--- packages/scripts/sync-version.test.mjs | 338 +++++++++++++++++++++++++ 5 files changed, 644 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 91ffa7959..607f0cd51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ Tooling is **Cargo** (Rust — the product). The root has **no `package.json`**; - **CLI:** `cargo build -p op-cli` → binary `op` - **MCP server:** built into the desktop/web host (`--mcp `); crate `op-mcp` - **Iconify catalog (Rust assets):** from `packages/`: `bun run generate-iconify-catalog` -- **Bump SDK versions:** from `packages/`: `bun run bump ` (syncs the SDK `package.json`s; Rust versions live in Cargo.toml) +- **Sync SDK versions:** from `packages/`: `bun run sync-version` (reads the canonical version from root `Cargo.toml`); verify with `bun run sync-version:check` ## Architecture diff --git a/README.md b/README.md index 6875f9bf0..4df774b4d 100644 --- a/README.md +++ b/README.md @@ -516,7 +516,8 @@ cargo run -p op-cli -- # CLI (binary: op) # Web SDK / JS tooling (run from packages/) cd packages && bun run lint # Lint the web SDK (oxlint); also: bun run format cd packages && bun run generate-iconify-catalog # Regenerate the Rust icon catalog assets -cd packages && bun run bump # Sync SDK package.json versions +cd packages && bun run sync-version # Sync SDK versions from root Cargo.toml +cd packages && bun run sync-version:check # Verify SDK versions match root Cargo.toml ``` ### Rust workspace details diff --git a/packages/CLAUDE.md b/packages/CLAUDE.md index 784683d64..1d514b31c 100644 --- a/packages/CLAUDE.md +++ b/packages/CLAUDE.md @@ -10,7 +10,7 @@ Run these from `packages/`: - **Lint / format the SDK:** `bun run lint` (oxlint) / `bun run format` (oxfmt). - **Iconify catalog (Rust assets):** `bun run generate-iconify-catalog` — `scripts/generate-iconify-catalog.mjs` reads `@iconify-json/*` and writes `crates/op-editor-ui/assets/iconify-catalog-{core,brands}.json` (the icon catalog embedded in / served by the Rust web target). -- **Bump SDK versions:** `bun run bump ` (syncs the SDK `package.json`s; Rust versions live in Cargo.toml). +- **Sync SDK versions:** `bun run sync-version` reads the canonical version from root `Cargo.toml` and updates all SDK consumers; verify with `bun run sync-version:check`. ## op-web-sdk (`op-web-sdk/`) diff --git a/packages/scripts/sync-version.mjs b/packages/scripts/sync-version.mjs index a865b9709..1aad5a743 100644 --- a/packages/scripts/sync-version.mjs +++ b/packages/scripts/sync-version.mjs @@ -4,8 +4,8 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); -const packagesRoot = resolve(scriptDirectory, '..'); -const repositoryRoot = resolve(packagesRoot, '..'); +const defaultPackagesRoot = resolve(scriptDirectory, '..'); +const defaultRepositoryRoot = resolve(defaultPackagesRoot, '..'); const manifestPaths = [ 'package.json', @@ -21,6 +21,7 @@ const sdkEntryPaths = [ ]; const sdkWorkspaceNames = ['op-web-sdk', 'op-web-sdk-react', 'op-web-sdk-vue']; +const managedPaths = [...manifestPaths, ...sdkEntryPaths, 'bun.lock']; const versionExportPattern = /^([\t ]*)export const VERSION = '([^'\r\n]*)';([\t ]*)$/gm; @@ -30,8 +31,179 @@ export function renderPackageManifest(source, version) { return `${JSON.stringify(manifest, null, 2)}\n`; } +function maskSdkCommentsAndTemplates(source) { + const masked = source.split(''); + let state = 'code'; + + const mask = (index) => { + if (source[index] !== '\n' && source[index] !== '\r') { + masked[index] = ' '; + } + }; + + function maskQuoted(start, quote) { + let index = start; + while (index < source.length) { + const character = source[index]; + mask(index); + if (index > start && character === quote) { + return index + 1; + } + if (character === '\\') { + mask(index + 1); + index += 2; + } else { + index += 1; + } + } + return index; + } + + function maskLineComment(start) { + let index = start; + while (index < source.length && source[index] !== '\n' && source[index] !== '\r') { + mask(index); + index += 1; + } + return index; + } + + function maskBlockComment(start) { + let index = start; + while (index < source.length) { + mask(index); + if (source[index] === '*' && source[index + 1] === '/') { + mask(index + 1); + return index + 2; + } + index += 1; + } + return index; + } + + function maskTemplateExpression(start) { + let depth = 1; + let index = start; + while (index < source.length) { + const character = source[index]; + const next = source[index + 1]; + if (character === "'" || character === '"') { + index = maskQuoted(index, character); + continue; + } + if (character === '`') { + index = maskTemplateLiteral(index); + continue; + } + if (character === '/' && next === '/') { + index = maskLineComment(index); + continue; + } + if (character === '/' && next === '*') { + index = maskBlockComment(index); + continue; + } + + mask(index); + if (character === '{') { + depth += 1; + } else if (character === '}') { + depth -= 1; + index += 1; + if (depth === 0) { + return index; + } + continue; + } + index += 1; + } + return index; + } + + function maskTemplateLiteral(start) { + mask(start); + let index = start + 1; + while (index < source.length) { + const character = source[index]; + const next = source[index + 1]; + if (character === '\\') { + mask(index); + mask(index + 1); + index += 2; + } else if (character === '`') { + mask(index); + return index + 1; + } else if (character === '$' && next === '{') { + mask(index); + mask(index + 1); + index = maskTemplateExpression(index + 2); + } else { + mask(index); + index += 1; + } + } + return index; + } + + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + const next = source[index + 1]; + + if (state === 'single-quote' || state === 'double-quote') { + if (character === '\\') { + index += 1; + } else if ( + (state === 'single-quote' && character === "'") || + (state === 'double-quote' && character === '"') + ) { + state = 'code'; + } + continue; + } + + if (state === 'line-comment') { + if (character === '\n' || character === '\r') { + state = 'code'; + } else { + mask(index); + } + continue; + } + + if (state === 'block-comment') { + mask(index); + if (character === '*' && next === '/') { + mask(index + 1); + index += 1; + state = 'code'; + } + continue; + } + + if (character === "'") { + state = 'single-quote'; + } else if (character === '"') { + state = 'double-quote'; + } else if (character === '`') { + index = maskTemplateLiteral(index) - 1; + } else if (character === '/' && next === '/') { + mask(index); + mask(index + 1); + index += 1; + state = 'line-comment'; + } else if (character === '/' && next === '*') { + mask(index); + mask(index + 1); + index += 1; + state = 'block-comment'; + } + } + + return masked.join(''); +} + function sdkVersionMatches(source) { - return [...source.matchAll(versionExportPattern)]; + return [...maskSdkCommentsAndTemplates(source).matchAll(versionExportPattern)]; } export function renderSdkEntry(source, version) { @@ -40,9 +212,9 @@ export function renderSdkEntry(source, version) { throw new Error(`Expected exactly one public VERSION export, found ${matches.length}`); } - return source.replace(versionExportPattern, (_declaration, prefix, _current, suffix) => { - return `${prefix}export const VERSION = '${version}';${suffix}`; - }); + const [declaration, prefix, _current, suffix] = matches[0]; + const start = matches[0].index; + return `${source.slice(0, start)}${prefix}export const VERSION = '${version}';${suffix}${source.slice(start + declaration.length)}`; } function skipTrivia(source, start) { @@ -233,7 +405,7 @@ function repositoryPath(packageRelativePath) { return `packages/${packageRelativePath}`; } -async function readConsumers() { +async function readConsumers(packagesRoot) { const manifestConsumers = await Promise.all( manifestPaths.map(async (path) => { const source = await readFile(resolve(packagesRoot, path), 'utf8'); @@ -265,7 +437,7 @@ async function readConsumers() { return [...manifestConsumers, ...sdkConsumers, ...lockConsumers]; } -async function renderVersionedFiles(version) { +async function renderVersionedFiles(version, packagesRoot) { const manifests = await Promise.all( manifestPaths.map(async (path) => { const absolutePath = resolve(packagesRoot, path); @@ -299,13 +471,20 @@ async function renderVersionedFiles(version) { return [...manifests, ...sdkEntries]; } -function canonicalVersion() { - const output = execFileSync('sh', ['scripts/workspace-version.sh', 'Cargo.toml'], { - cwd: repositoryRoot, +function defaultRunCommand(command, arguments_, { cwd, captureOutput = false }) { + return execFileSync(command, arguments_, { + cwd, encoding: 'utf8', - stdio: ['ignore', 'pipe', 'inherit'], + stdio: captureOutput ? ['ignore', 'pipe', 'inherit'] : 'inherit', }); - const version = output.trim(); +} + +async function canonicalVersion(repositoryRoot, runCommand) { + const output = await runCommand('sh', ['scripts/workspace-version.sh', 'Cargo.toml'], { + cwd: repositoryRoot, + captureOutput: true, + }); + const version = String(output).trim(); if (version.length === 0 || /\s/.test(version)) { throw new Error('scripts/workspace-version.sh returned an invalid version'); } @@ -321,34 +500,128 @@ function reportDrift(drift) { } } -export async function main(arguments_) { - const check = arguments_.length === 1 && arguments_[0] === '--check'; - if (arguments_.length > 0 && !check) { - throw new Error('Usage: node scripts/sync-version.mjs [--check]'); +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function snapshotManagedFiles(packagesRoot) { + return Promise.all( + managedPaths.map(async (path) => ({ + absolutePath: resolve(packagesRoot, path), + contents: await readFile(resolve(packagesRoot, path)), + })), + ); +} + +async function writeManagedFiles(files, writeManagedFile) { + const results = await Promise.allSettled( + files.map(({ absolutePath, contents }) => writeManagedFile(absolutePath, contents)), + ); + const failure = results.find((result) => result.status === 'rejected'); + if (failure !== undefined) { + throw failure.reason; + } +} + +async function restoreManagedFiles(snapshot, writeManagedFile) { + await writeManagedFiles(snapshot, writeManagedFile); +} + +function postWriteValidationError(drift) { + const consumers = drift + .map(({ path, expectedVersion, actualVersion }) => { + return `${path} (expected ${expectedVersion}, actual ${actualVersion ?? 'missing or invalid'})`; + }) + .join('; '); + return new Error(`Post-write validation failed: ${consumers}`); +} + +export function parseArguments(arguments_) { + if (arguments_.length === 0) { + return 'write'; + } + if (arguments_.length === 1 && arguments_[0] === '--check') { + return 'check'; + } + throw new Error('Usage: node scripts/sync-version.mjs [--check]'); +} + +export async function synchronizeVersions({ + mode, + repositoryRoot = defaultRepositoryRoot, + packagesRoot = defaultPackagesRoot, + runCommand = defaultRunCommand, + writeManagedFile = writeFile, +}) { + if (mode !== 'write' && mode !== 'check') { + throw new Error(`Unknown synchronization mode: ${mode}`); } - const version = canonicalVersion(); - if (!check) { - const rendered = await renderVersionedFiles(version); - await Promise.all( - rendered - .filter(({ source, output }) => source !== output) - .map(({ absolutePath, output }) => writeFile(absolutePath, output)), - ); - execFileSync('bun', ['install', '--lockfile-only'], { + const version = await canonicalVersion(repositoryRoot, runCommand); + if (mode === 'check') { + const drift = collectVersionDrift(version, await readConsumers(packagesRoot)); + return { version, drift }; + } + + const rendered = await renderVersionedFiles(version, packagesRoot); + const snapshot = await snapshotManagedFiles(packagesRoot); + try { + await runCommand('bun', ['--version'], { cwd: packagesRoot, - stdio: 'inherit', + captureOutput: true, + }); + } catch (error) { + throw new Error(`Bun preflight failed: ${errorMessage(error)}. Install Bun and retry.`, { + cause: error, }); } - const drift = collectVersionDrift(version, await readConsumers()); + try { + await writeManagedFiles( + rendered + .filter(({ source, output }) => source !== output) + .map(({ absolutePath, output }) => ({ absolutePath, contents: output })), + writeManagedFile, + ); + try { + await runCommand('bun', ['install', '--lockfile-only'], { + cwd: packagesRoot, + captureOutput: false, + }); + } catch (error) { + throw new Error(`Bun lockfile regeneration failed: ${errorMessage(error)}`, { + cause: error, + }); + } + + const drift = collectVersionDrift(version, await readConsumers(packagesRoot)); + if (drift.length > 0) { + throw postWriteValidationError(drift); + } + return { version, drift }; + } catch (error) { + try { + await restoreManagedFiles(snapshot, writeManagedFile); + } catch (rollbackError) { + throw new Error( + `Version synchronization failed (${errorMessage(error)}) and rollback failed (${errorMessage(rollbackError)}). Restore the managed package files from version control.`, + { cause: rollbackError }, + ); + } + throw error; + } +} + +export async function main(arguments_) { + const mode = parseArguments(arguments_); + const { version, drift } = await synchronizeVersions({ mode }); if (drift.length > 0) { reportDrift(drift); process.exitCode = 1; return; } - const action = check ? 'Verified' : 'Synchronized'; + const action = mode === 'check' ? 'Verified' : 'Synchronized'; console.log(`${action} package and SDK versions at ${version}.`); } diff --git a/packages/scripts/sync-version.test.mjs b/packages/scripts/sync-version.test.mjs index 194c014cf..c686f2cb2 100644 --- a/packages/scripts/sync-version.test.mjs +++ b/packages/scripts/sync-version.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import test from 'node:test'; +import * as syncVersion from './sync-version.mjs'; + import { collectVersionDrift, inspectBunLockWorkspaceVersions, @@ -8,6 +13,95 @@ import { renderSdkEntry, } from './sync-version.mjs'; +const fixtureManagedPaths = [ + 'package.json', + 'op-web-sdk/package.json', + 'op-web-sdk-react/package.json', + 'op-web-sdk-vue/package.json', + 'op-web-sdk/src/index.ts', + 'op-web-sdk-react/src/index.ts', + 'op-web-sdk-vue/src/index.ts', + 'bun.lock', +]; + +function fixtureLockfile(version) { + return `{ + "lockfileVersion": 1, + "workspaces": { + "": { "name": "@zseven-w/openpencil-packages" }, + "op-web-sdk": { "version": "${version}" }, + "op-web-sdk-react": { "version": "${version}" }, + "op-web-sdk-vue": { "version": "${version}" }, + }, +} +`; +} + +async function createRepositoryFixture(t, version = '1.0.0') { + const repositoryRoot = await mkdtemp(join(tmpdir(), 'sync-version-')); + const packagesRoot = join(repositoryRoot, 'packages'); + t.after(() => rm(repositoryRoot, { recursive: true, force: true })); + + const files = { + 'package.json': `${JSON.stringify({ name: 'fixture-root', version }, null, 2)}\n`, + 'op-web-sdk/package.json': `${JSON.stringify({ name: 'op-web-sdk', version }, null, 2)}\n`, + 'op-web-sdk-react/package.json': `${JSON.stringify({ name: 'op-web-sdk-react', version }, null, 2)}\n`, + 'op-web-sdk-vue/package.json': `${JSON.stringify({ name: 'op-web-sdk-vue', version }, null, 2)}\n`, + 'op-web-sdk/src/index.ts': `export const VERSION = '${version}';\n`, + 'op-web-sdk-react/src/index.ts': `export const VERSION = '${version}';\n`, + 'op-web-sdk-vue/src/index.ts': `export const VERSION = '${version}';\n`, + 'bun.lock': fixtureLockfile(version), + }; + + await Promise.all( + Object.entries(files).map(async ([path, contents]) => { + const absolutePath = join(packagesRoot, path); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, contents); + }), + ); + + return { + repositoryRoot, + packagesRoot, + managedPaths: fixtureManagedPaths.map((path) => join(packagesRoot, path)), + }; +} + +async function readManagedFiles(paths) { + return Promise.all(paths.map((path) => readFile(path))); +} + +function createCommandRunner(packagesRoot, version = '2.3.4', { regenerateLock = true } = {}) { + const calls = []; + return { + calls, + runCommand: async (command, arguments_) => { + calls.push([command, ...arguments_]); + if (command === 'sh') { + return `${version}\n`; + } + if (arguments_[0] === '--version') { + return '1.3.11\n'; + } + if (arguments_[0] === 'install') { + if (regenerateLock) { + await writeFile(join(packagesRoot, 'bun.lock'), fixtureLockfile(version)); + } + return ''; + } + throw new Error(`Unexpected command: ${command} ${arguments_.join(' ')}`); + }, + }; +} + +test('argument parsing selects write or check mode and rejects unknown arguments', () => { + assert.equal(syncVersion.parseArguments([]), 'write'); + assert.equal(syncVersion.parseArguments(['--check']), 'check'); + assert.throws(() => syncVersion.parseArguments(['1.2.3']), /usage/i); + assert.throws(() => syncVersion.parseArguments(['--check', '--extra']), /usage/i); +}); + test('package manifest rendering changes only the top-level version and ends with a newline', () => { const input = `{ "name": "example", @@ -69,6 +163,76 @@ export const VERSION = '2.3.4'; ); }); +test('SDK entry rendering ignores declaration-shaped lines inside block comments', () => { + const input = `/* +export const VERSION = 'documentation-only'; +*/ +export const VERSION = '1.0.0'; +`; + + assert.equal( + renderSdkEntry(input, '2.3.4'), + `/* +export const VERSION = 'documentation-only'; +*/ +export const VERSION = '2.3.4'; +`, + ); +}); + +test('SDK entry rendering ignores declaration-shaped lines inside template literals', () => { + const input = `const documentation = \` +export const VERSION = 'documentation-only'; +\`; +export const VERSION = '1.0.0'; +`; + + assert.equal( + renderSdkEntry(input, '2.3.4'), + `const documentation = \` +export const VERSION = 'documentation-only'; +\`; +export const VERSION = '2.3.4'; +`, + ); +}); + +test('SDK entry rendering ignores declarations inside nested template literals', () => { + const input = `const documentation = \` +\${\`nested +export const VERSION = 'documentation-only'; +\`} +\`; +export const VERSION = '1.0.0'; +`; + + assert.equal( + renderSdkEntry(input, '2.3.4'), + `const documentation = \` +\${\`nested +export const VERSION = 'documentation-only'; +\`} +\`; +export const VERSION = '2.3.4'; +`, + ); +}); + +test('SDK entry rendering does not treat comment markers inside quoted strings as comments', () => { + const input = `const blockMarker = '/*'; +const lineMarker = "//"; +export const VERSION = '1.0.0'; +`; + + assert.equal( + renderSdkEntry(input, '2.3.4'), + `const blockMarker = '/*'; +const lineMarker = "//"; +export const VERSION = '2.3.4'; +`, + ); +}); + test('Bun lock inspection finds every versioned SDK workspace and ignores the root workspace', () => { const lockfile = `{ "workspaces": { @@ -183,3 +347,177 @@ test('an already synchronized in-memory repository reports no drift', () => { [], ); }); + +test('check mode reports stale paths without changing managed files', async (t) => { + const fixture = await createRepositoryFixture(t); + const before = await readManagedFiles(fixture.managedPaths); + const runner = createCommandRunner(fixture.packagesRoot); + + const result = await syncVersion.synchronizeVersions({ + mode: 'check', + ...fixture, + runCommand: runner.runCommand, + }); + + assert.equal(result.version, '2.3.4'); + assert.deepEqual( + result.drift.map(({ path }) => path), + [ + 'packages/package.json', + 'packages/op-web-sdk/package.json', + 'packages/op-web-sdk-react/package.json', + 'packages/op-web-sdk-vue/package.json', + 'packages/op-web-sdk/src/index.ts', + 'packages/op-web-sdk-react/src/index.ts', + 'packages/op-web-sdk-vue/src/index.ts', + 'packages/bun.lock#workspaces.op-web-sdk', + 'packages/bun.lock#workspaces.op-web-sdk-react', + 'packages/bun.lock#workspaces.op-web-sdk-vue', + ], + ); + assert.deepEqual(await readManagedFiles(fixture.managedPaths), before); + assert.deepEqual(runner.calls, [['sh', 'scripts/workspace-version.sh', 'Cargo.toml']]); +}); + +test('write mode synchronizes every consumer and is idempotent', async (t) => { + const fixture = await createRepositoryFixture(t); + const runner = createCommandRunner(fixture.packagesRoot); + + const first = await syncVersion.synchronizeVersions({ + mode: 'write', + ...fixture, + runCommand: runner.runCommand, + }); + const afterFirst = await readManagedFiles(fixture.managedPaths); + const second = await syncVersion.synchronizeVersions({ + mode: 'write', + ...fixture, + runCommand: runner.runCommand, + }); + + assert.deepEqual(first.drift, []); + assert.deepEqual(second.drift, []); + assert.deepEqual(await readManagedFiles(fixture.managedPaths), afterFirst); + await Promise.all( + fixture.managedPaths.slice(0, 4).map(async (path) => { + assert.equal(JSON.parse(await readFile(path, 'utf8')).version, '2.3.4'); + }), + ); + await Promise.all( + fixture.managedPaths.slice(4, 7).map(async (path) => { + assert.match(await readFile(path, 'utf8'), /VERSION = '2\.3\.4'/); + }), + ); + assert.deepEqual( + inspectBunLockWorkspaceVersions(await readFile(fixture.managedPaths[7], 'utf8')), + { + 'op-web-sdk': '2.3.4', + 'op-web-sdk-react': '2.3.4', + 'op-web-sdk-vue': '2.3.4', + }, + ); + assert.equal(runner.calls.filter(([, argument]) => argument === 'install').length, 2); +}); + +test('write mode performs a Bun preflight before making any changes', async (t) => { + const fixture = await createRepositoryFixture(t); + const before = await readManagedFiles(fixture.managedPaths); + const calls = []; + const runCommand = async (command, arguments_) => { + calls.push([command, ...arguments_]); + if (command === 'sh') { + return '2.3.4\n'; + } + if (arguments_[0] === '--version') { + throw new Error('bun unavailable'); + } + throw new Error('Bun install should not run after a failed preflight'); + }; + + await assert.rejects( + syncVersion.synchronizeVersions({ + mode: 'write', + ...fixture, + runCommand, + }), + /bun.*preflight.*unavailable/i, + ); + assert.deepEqual(await readManagedFiles(fixture.managedPaths), before); + assert.deepEqual(calls, [ + ['sh', 'scripts/workspace-version.sh', 'Cargo.toml'], + ['bun', '--version'], + ]); +}); + +test('write mode restores every managed file when Bun lock regeneration fails', async (t) => { + const fixture = await createRepositoryFixture(t); + const before = await readManagedFiles(fixture.managedPaths); + const runCommand = async (command, arguments_) => { + if (command === 'sh') { + return '2.3.4\n'; + } + if (arguments_[0] === '--version') { + return '1.3.11\n'; + } + await writeFile(join(fixture.packagesRoot, 'bun.lock'), 'partially regenerated\n'); + throw new Error('install exploded'); + }; + + await assert.rejects( + syncVersion.synchronizeVersions({ + mode: 'write', + ...fixture, + runCommand, + }), + /lockfile regeneration failed.*install exploded/i, + ); + assert.deepEqual(await readManagedFiles(fixture.managedPaths), before); +}); + +test('write mode restores every managed file when post-write validation fails', async (t) => { + const fixture = await createRepositoryFixture(t); + const before = await readManagedFiles(fixture.managedPaths); + const runner = createCommandRunner(fixture.packagesRoot, '2.3.4', { + regenerateLock: false, + }); + + await assert.rejects( + syncVersion.synchronizeVersions({ + mode: 'write', + ...fixture, + runCommand: runner.runCommand, + }), + /post-write validation failed.*bun\.lock/i, + ); + assert.deepEqual(await readManagedFiles(fixture.managedPaths), before); +}); + +test('write mode waits for sibling writes to settle before rollback', async (t) => { + const fixture = await createRepositoryFixture(t); + const before = await readManagedFiles(fixture.managedPaths); + const runner = createCommandRunner(fixture.packagesRoot); + const attempts = new Map(); + const writeManagedFile = async (path, contents) => { + const attempt = attempts.get(path) ?? 0; + attempts.set(path, attempt + 1); + if (attempt === 0 && path === fixture.managedPaths[0]) { + throw new Error('simulated write failure'); + } + if (attempt === 0 && path === fixture.managedPaths[1]) { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 30)); + } + await writeFile(path, contents); + }; + + await assert.rejects( + syncVersion.synchronizeVersions({ + mode: 'write', + ...fixture, + runCommand: runner.runCommand, + writeManagedFile, + }), + /simulated write failure/i, + ); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 40)); + assert.deepEqual(await readManagedFiles(fixture.managedPaths), before); +});