fix(sdk): harden version synchronization
This commit is contained in:
parent
bb905db75c
commit
427e2b8263
|
|
@ -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 <path>`); crate `op-mcp`
|
||||
- **Iconify catalog (Rust assets):** from `packages/`: `bun run generate-iconify-catalog`
|
||||
- **Bump SDK versions:** from `packages/`: `bun run bump <version>` (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
|
||||
|
||||
|
|
|
|||
|
|
@ -516,7 +516,8 @@ cargo run -p op-cli -- <args> # 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 <version> # 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
|
||||
|
|
|
|||
|
|
@ -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 <version>` (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/`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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}.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue