mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 20:55:28 +02:00
778 lines
43 KiB
JavaScript
778 lines
43 KiB
JavaScript
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execFileSync, spawn, spawnSync } = require('child_process');
|
|
|
|
const LEDGER = path.join(__dirname, '..', '..', 'skills', 'story-lifecycle', 'scripts', 'ledger.js');
|
|
const { resolveLedgerPath } = require(LEDGER);
|
|
|
|
const EPIC = `# Epic: Auth flow
|
|
|
|
**Slug:** auth-flow
|
|
**Status:** ready
|
|
|
|
## Stories
|
|
|
|
<!-- Derived from story files — regenerated by status and on every transition -->
|
|
| ID | Title | Status |
|
|
| --- | --- | --- |
|
|
`;
|
|
|
|
function story(id, title, { sprint = 'unassigned', status = 'todo', points = 3 } = {}) {
|
|
return `# Story: ${title}\n\n**ID:** ${id}\n**Epic:** auth-flow\n**Sprint:** ${sprint}\n**Status:** ${status}\n**Points:** ${points}\n`;
|
|
}
|
|
|
|
function run(root, args, env = {}) {
|
|
const r = spawnSync(process.execPath, [LEDGER, ...args, '--root', root], { encoding: 'utf8', env: { ...process.env, ...env } });
|
|
return { code: r.status, out: r.stdout ? JSON.parse(r.stdout) : null, err: r.stderr };
|
|
}
|
|
|
|
function runAsync(root, args) {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [LEDGER, ...args, '--root', root]);
|
|
let out = '';
|
|
child.stdout.on('data', (d) => { out += d; });
|
|
child.on('close', (code) => resolve({ code, out }));
|
|
});
|
|
}
|
|
|
|
function writeJson(root, name, value) {
|
|
const file = path.join(root, name);
|
|
fs.writeFileSync(file, JSON.stringify(value));
|
|
return file;
|
|
}
|
|
|
|
const ownedTemps = new Set();
|
|
function tempDir(prefix) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
ownedTemps.add(dir);
|
|
return dir;
|
|
}
|
|
|
|
function newRepo() {
|
|
const root = tempDir('story-ledger-');
|
|
execFileSync('git', ['init', '-q', root]);
|
|
assert.strictEqual(run(root, ['init']).code, 0);
|
|
const input = writeJson(root, 'epic.json', { create: { 'epics/auth-flow.md': EPIC } });
|
|
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
|
|
return root;
|
|
}
|
|
|
|
function createStories(root, titles, tag = 'batch') {
|
|
const { ids } = run(root, ['allocate', '--epic', 'auth-flow', '--count', String(titles.length)]).out;
|
|
const create = Object.fromEntries(ids.map((id, i) => [`${id}.md`, story(id, titles[i])]));
|
|
return { ids, input: writeJson(root, `${tag}.json`, { create }) };
|
|
}
|
|
|
|
// Windows allows symlinks only with admin rights or Developer Mode; skip rather than fail there.
|
|
class Skip extends Error {}
|
|
function symlink(target, file, type) {
|
|
try {
|
|
fs.symlinkSync(target, file, type);
|
|
} catch (err) {
|
|
if (err.code === 'EPERM' && process.platform === 'win32') throw new Skip('symlinks not permitted');
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const read = (root, rel) => fs.readFileSync(path.join(root, '.stories', rel), 'utf8');
|
|
const storyFiles = (root) => fs.readdirSync(path.join(root, '.stories')).filter((f) => /-\d+\.md$/.test(f)).sort();
|
|
|
|
const LOCK_DENIAL_MARKER = 'FOREIGN_LOCK_CREATE_DENIED ';
|
|
function windowsLockCreateDenied(result, lock, platform = process.platform) {
|
|
if (platform !== 'win32' || result.status !== 1) return false;
|
|
try {
|
|
const line = result.stderr.split('\n').find(text => text.startsWith(LOCK_DENIAL_MARKER));
|
|
if (!line) return false;
|
|
const denied = JSON.parse(line.slice(LOCK_DENIAL_MARKER.length));
|
|
return ['EPERM', 'EACCES', 'EBUSY'].includes(denied.code) && denied.syscall === 'open' && denied.path === lock &&
|
|
result.stderr.split('\n').some(text => /(?:EPERM|EACCES|EBUSY).*\b(?:open|lstat)\b/.test(text) && text.includes(`'${lock}'`));
|
|
} catch { return false; }
|
|
}
|
|
|
|
function assertRefusedLockWriter(root, ids, input) {
|
|
const filename = `${ids[0]}.md`;
|
|
assert.deepStrictEqual(storyFiles(root), [filename]);
|
|
assert.strictEqual(read(root, filename), JSON.parse(fs.readFileSync(input, 'utf8')).create[filename]);
|
|
assert.ok(!fs.readdirSync(path.join(root, '.stories')).some(name => name === '.lock' || name === '.txn'));
|
|
const state = run(root, ['status']);
|
|
assert.strictEqual(state.code, 0, state.err);
|
|
assert.strictEqual(state.out.recoveryPending, false);
|
|
assert.deepStrictEqual(state.out.drift, []);
|
|
}
|
|
|
|
function snapshot(dir) {
|
|
if (!fs.existsSync(dir)) return null;
|
|
return Object.fromEntries(fs.readdirSync(dir, { withFileTypes: true })
|
|
.sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
const name = entry.name;
|
|
const file = path.join(dir, name);
|
|
if (entry.isDirectory()) return [name, snapshot(file)];
|
|
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
try {
|
|
assert.ok(fs.fstatSync(fd).isFile(), 'snapshot requires a regular file');
|
|
return [name, fs.readFileSync(fd, 'base64')];
|
|
} finally { fs.closeSync(fd); }
|
|
}));
|
|
}
|
|
|
|
function withoutHardlinks(root, args, { expectedAncestor = null, code = 'ENOTSUP' } = {}) {
|
|
const preload = path.join(root, 'unsupported-links.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); const path = require('path');
|
|
fs.linkSync = function (source) {
|
|
const ancestor = ${JSON.stringify(expectedAncestor)};
|
|
if (ancestor && path.dirname(path.dirname(source)) !== ancestor) {
|
|
throw new Error('probe used wrong filesystem: ' + source);
|
|
}
|
|
throw Object.assign(new Error('injected unsupported hardlinks'), { code: ${JSON.stringify(code)} });
|
|
};`);
|
|
const r = spawnSync(process.execPath, ['-r', preload, LEDGER, ...args], { encoding: 'utf8' });
|
|
return { code: r.status, out: r.stdout ? JSON.parse(r.stdout) : null, err: r.stderr };
|
|
}
|
|
|
|
// One full create-stories run (allocate + apply) in a separate process.
|
|
const WRITER = `
|
|
const { execFileSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const [ledger, root, tag] = process.argv.slice(1);
|
|
const run = (...a) => JSON.parse(execFileSync(process.execPath, [ledger, ...a, '--root', root]));
|
|
const { ids } = run('allocate', '--epic', 'auth-flow', '--count', '3');
|
|
const create = Object.fromEntries(ids.map((id) => [id + '.md',
|
|
'# Story: ' + tag + ' ' + id + '\\n\\n**ID:** ' + id + '\\n**Epic:** auth-flow\\n**Sprint:** unassigned\\n**Status:** todo\\n**Points:** 1\\n']));
|
|
fs.writeFileSync(root + '/' + tag + '.json', JSON.stringify({ create }));
|
|
run('apply', '--input', root + '/' + tag + '.json');
|
|
`;
|
|
|
|
const tests = [
|
|
['unsupported storage refuses init on the nearest existing ancestor without creating the ledger', () => {
|
|
const root = tempDir('story-storage-');
|
|
const intended = path.join(root, 'missing', 'repo');
|
|
const result = withoutHardlinks(root, ['init', '--root', intended], { expectedAncestor: root });
|
|
assert.strictEqual(result.code, 2, result.err);
|
|
assert.match(result.err, /hardlink.*ENOTSUP/);
|
|
assert.deepStrictEqual(fs.readdirSync(root), ['unsupported-links.js']);
|
|
assert.ok(!fs.existsSync(path.join(root, 'missing')));
|
|
}],
|
|
|
|
['unsupported storage refuses every write before touching ledger files or ID reservations', () => {
|
|
const root = newRepo();
|
|
const { input } = createStories(root, ['Unpublished']);
|
|
fs.writeFileSync(path.join(root, '.stories', '.hardlink-probe-user'), 'keep user file');
|
|
const ledgerBefore = snapshot(path.join(root, '.stories'));
|
|
const idsBefore = snapshot(path.join(root, '.git', 'story-lifecycle'));
|
|
for (const args of [['init'], ['allocate', '--epic', 'auth-flow'],
|
|
['apply', '--input', input], ['reconcile'], ['recover']]) {
|
|
const result = withoutHardlinks(root, [...args, '--root', root], {
|
|
expectedAncestor: path.join(root, '.stories'), code: 'EPERM',
|
|
});
|
|
assert.strictEqual(result.code, 2, `${args}: ${result.err}`);
|
|
assert.match(result.err, /hardlink.*EPERM/);
|
|
assert.deepStrictEqual(snapshot(path.join(root, '.stories')), ledgerBefore);
|
|
assert.deepStrictEqual(snapshot(path.join(root, '.git', 'story-lifecycle')), idsBefore);
|
|
}
|
|
}],
|
|
|
|
['storage change leaves pending recovery untouched and status truthful until supported recovery', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Partially published']);
|
|
assert.strictEqual(run(root, ['apply', '--input', input], { STORY_LEDGER_CRASH_AFTER: '1' }).code, 70);
|
|
const before = snapshot(path.join(root, '.stories'));
|
|
const result = withoutHardlinks(root, ['recover', '--root', root]);
|
|
assert.strictEqual(result.code, 2, result.err);
|
|
assert.match(result.err, /hardlink.*ENOTSUP/);
|
|
assert.deepStrictEqual(snapshot(path.join(root, '.stories')), before, 'no partial rollback or lock cleanup');
|
|
const status = withoutHardlinks(root, ['status', '--root', root]);
|
|
assert.strictEqual(status.code, 0, status.err);
|
|
assert.strictEqual(status.out.recoveryPending, true);
|
|
assert.strictEqual(status.out.projectionAvailable, false);
|
|
assert.match(status.out.recoveryAdvice, /storage.*manual/i);
|
|
assert.deepStrictEqual(snapshot(path.join(root, '.stories')), before, 'status must be read-only');
|
|
assert.ok(fs.existsSync(path.join(root, '.stories', `${ids[0]}.md`)));
|
|
assert.strictEqual(run(root, ['recover']).code, 0);
|
|
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
|
|
assert.deepStrictEqual(run(root, ['status']).out.drift, []);
|
|
}],
|
|
|
|
['create-stories writes story files and regenerates the epic table', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Login', 'Logout']);
|
|
assert.deepStrictEqual(ids, ['auth-flow-1', 'auth-flow-2']);
|
|
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
|
|
assert.ok(read(root, 'epics/auth-flow.md').includes('| auth-flow-2 | Logout | todo |'));
|
|
assert.ok(fs.existsSync(path.join(root, '.stories', '.gitignore')));
|
|
}],
|
|
|
|
['two concurrent writers get distinct IDs and neither overwrites the other', async () => {
|
|
const root = newRepo();
|
|
const codes = await Promise.all(['a', 'b'].map((tag) => new Promise((resolve) => {
|
|
spawn(process.execPath, ['-e', WRITER, LEDGER, root, tag], { stdio: 'inherit' }).on('close', resolve);
|
|
})));
|
|
assert.deepStrictEqual(codes, [0, 0]);
|
|
const files = storyFiles(root);
|
|
assert.strictEqual(files.length, 6, `expected 6 distinct story files, got ${files}`);
|
|
const epic = read(root, 'epics/auth-flow.md');
|
|
for (const f of files) assert.ok(epic.includes(`| ${f.slice(0, -3)} |`), `epic table missing ${f}`);
|
|
assert.deepStrictEqual(run(root, ['status']).out.drift, []);
|
|
}],
|
|
|
|
['a held lock makes the second writer wait, fail cleanly, then succeed on retry', async () => {
|
|
const root = newRepo();
|
|
const { input } = createStories(root, ['Login']);
|
|
const lock = path.join(root, '.stories', '.lock');
|
|
fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, host: os.hostname(), at: 'now' }));
|
|
fs.mkdirSync(path.join(root, '.stories', '.txn'));
|
|
assert.strictEqual(run(root, ['status']).out.recoveryPending, false, 'in-flight batch of a live writer is not interrupted');
|
|
const blocked = await runAsync(root, ['apply', '--input', input, '--wait-ms', '200']);
|
|
assert.strictEqual(blocked.code, 3, 'expected lock-timeout exit code');
|
|
assert.deepStrictEqual(storyFiles(root), []);
|
|
fs.rmSync(lock);
|
|
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
|
|
assert.deepStrictEqual(storyFiles(root), ['auth-flow-1.md']);
|
|
}],
|
|
|
|
['creating an existing story ID is refused without overwrite', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Login']);
|
|
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
|
|
const again = writeJson(root, 'again.json', { create: { [`${ids[0]}.md`]: story(ids[0], 'Clobber') } });
|
|
assert.strictEqual(run(root, ['apply', '--input', again]).code, 4);
|
|
assert.ok(read(root, `${ids[0]}.md`).includes('Login'));
|
|
}],
|
|
|
|
['unallocated story IDs are refused', () => {
|
|
const root = newRepo();
|
|
const input = writeJson(root, 'raw.json', { create: { 'auth-flow-9.md': story('auth-flow-9', 'Raw') } });
|
|
assert.notStrictEqual(run(root, ['apply', '--input', input]).code, 0);
|
|
assert.deepStrictEqual(storyFiles(root), []);
|
|
}],
|
|
|
|
['an interrupted batch is rolled back completely by the next writer', () => {
|
|
const root = newRepo();
|
|
const before = read(root, 'epics/auth-flow.md');
|
|
const { input } = createStories(root, ['A', 'B', 'C']);
|
|
const crashed = run(root, ['apply', '--input', input], { STORY_LEDGER_CRASH_AFTER: '2' });
|
|
assert.strictEqual(crashed.code, 70);
|
|
assert.strictEqual(storyFiles(root).length, 2, 'fixture must leave a partial batch on disk');
|
|
assert.strictEqual(run(root, ['status']).out.recoveryPending, true);
|
|
assert.strictEqual(run(root, ['recover']).out.rolledBack, 4);
|
|
assert.deepStrictEqual(storyFiles(root), []);
|
|
assert.strictEqual(read(root, 'epics/auth-flow.md'), before);
|
|
assert.ok(!fs.existsSync(path.join(root, '.stories', '.lock')));
|
|
}],
|
|
|
|
['plan-sprint assigns stories and derives the sprint table and total', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Login', 'Logout']);
|
|
run(root, ['apply', '--input', input]);
|
|
const sprint = '# Sprint 1\n\n## Stories\n\n<!-- Derived from story files — regenerated by status and on every transition -->\n| ID | Title | Points | Status |\n| --- | --- | --- | --- |\n\n## Total Points: 0\n';
|
|
const plan = writeJson(root, 'plan.json', {
|
|
create: { 'sprints/sprint-1.md': sprint },
|
|
update: Object.fromEntries(ids.map((id, i) => [`${id}.md`, story(id, ['Login', 'Logout'][i], { sprint: '1', points: 5 })])),
|
|
});
|
|
assert.strictEqual(run(root, ['apply', '--input', plan]).code, 0);
|
|
const s = read(root, 'sprints/sprint-1.md');
|
|
assert.ok(s.includes('| auth-flow-1 | Login | 5 | todo |'));
|
|
assert.ok(s.includes('## Total Points: 10'));
|
|
}],
|
|
|
|
['status reports drift read-only; reconcile repairs the derived tables', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Login']);
|
|
run(root, ['apply', '--input', input]);
|
|
fs.writeFileSync(path.join(root, '.stories', `${ids[0]}.md`), story(ids[0], 'Login', { status: 'in-progress' }));
|
|
const epicBefore = read(root, 'epics/auth-flow.md');
|
|
assert.deepStrictEqual(run(root, ['status']).out.drift, ['epics/auth-flow.md']);
|
|
assert.strictEqual(read(root, 'epics/auth-flow.md'), epicBefore, 'status must not write');
|
|
assert.strictEqual(run(root, ['reconcile']).code, 0);
|
|
assert.ok(read(root, 'epics/auth-flow.md').includes('| auth-flow-1 | Login | in-progress |'));
|
|
}],
|
|
|
|
['git worktrees of one clone share ID allocation', () => {
|
|
const root = newRepo();
|
|
execFileSync('git', ['-C', root, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
|
|
const wt = path.join(tempDir('story-worktree-'), 'checkout');
|
|
execFileSync('git', ['-C', root, 'worktree', 'add', '-q', wt]);
|
|
run(wt, ['init']);
|
|
run(wt, ['apply', '--input', writeJson(wt, 'epic.json', { create: { 'epics/auth-flow.md': EPIC } })]);
|
|
const a = run(root, ['allocate', '--epic', 'auth-flow', '--count', '2']).out.ids;
|
|
const b = run(wt, ['allocate', '--epic', 'auth-flow', '--count', '2']).out.ids;
|
|
assert.deepStrictEqual([...a, ...b], ['auth-flow-1', 'auth-flow-2', 'auth-flow-3', 'auth-flow-4']);
|
|
}],
|
|
|
|
['concurrent allocation from two worktrees never hands out the same ID', async () => {
|
|
const root = newRepo();
|
|
execFileSync('git', ['-C', root, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
|
|
const wt = path.join(tempDir('story-worktree-'), 'checkout');
|
|
execFileSync('git', ['-C', root, 'worktree', 'add', '-q', wt]);
|
|
run(wt, ['init']);
|
|
run(wt, ['apply', '--input', writeJson(wt, 'epic.json', { create: { 'epics/auth-flow.md': EPIC } })]);
|
|
// Each worktree has its own lock, so only the shared exclusive-create markers prevent duplicates.
|
|
const results = await Promise.all(Array.from({ length: 32 }, (_, i) => runAsync(i % 2 ? wt : root, ['allocate', '--epic', 'auth-flow', '--count', '5'])));
|
|
const ids = results.flatMap((r) => JSON.parse(r.out).ids);
|
|
assert.strictEqual(ids.length, 160);
|
|
assert.strictEqual(new Set(ids).size, 160, 'duplicate story IDs allocated across worktrees');
|
|
}],
|
|
|
|
['symlinked ledger directories are refused before any write', () => {
|
|
const root = newRepo();
|
|
const outside = tempDir('story-outside-');
|
|
fs.rmSync(path.join(root, '.stories', 'sprints'), { recursive: true });
|
|
symlink(outside, path.join(root, '.stories', 'sprints'), 'dir');
|
|
const input = writeJson(root, 's.json', { create: { 'sprints/sprint-1.md': '# Sprint 1\n' } });
|
|
const r = run(root, ['apply', '--input', input]);
|
|
assert.strictEqual(r.code, 2);
|
|
assert.match(r.err, /symlinked ledger directory/);
|
|
assert.deepStrictEqual(fs.readdirSync(outside), []);
|
|
}],
|
|
|
|
['recover rejects journal backups outside .txn/', () => {
|
|
const root = newRepo();
|
|
const txn = path.join(root, '.stories', '.txn');
|
|
fs.writeFileSync(path.join(root, 'private.txt'), 'SECRET');
|
|
fs.mkdirSync(txn);
|
|
fs.writeFileSync(path.join(txn, 'journal.json'), JSON.stringify({ entries: [{ path: 'epics/auth-flow.md', backup: '../../private.txt' }] }));
|
|
assert.strictEqual(run(root, ['recover']).code, 2);
|
|
assert.ok(!read(root, 'epics/auth-flow.md').includes('SECRET'));
|
|
}],
|
|
|
|
['recover never writes through a symlinked ledger target', () => {
|
|
const root = newRepo();
|
|
const txn = path.join(root, '.stories', '.txn');
|
|
const victim = path.join(root, 'victim.txt');
|
|
fs.writeFileSync(victim, 'ORIGINAL');
|
|
symlink(victim, path.join(root, '.stories', 'auth-flow-1.md'));
|
|
fs.mkdirSync(txn);
|
|
fs.writeFileSync(path.join(txn, 'backup-0'), 'PLANTED');
|
|
fs.writeFileSync(path.join(txn, 'journal.json'), JSON.stringify({ entries: [{ path: 'auth-flow-1.md', backup: 'backup-0' }] }));
|
|
assert.strictEqual(run(root, ['recover']).code, 2);
|
|
assert.strictEqual(fs.readFileSync(victim, 'utf8'), 'ORIGINAL');
|
|
}],
|
|
|
|
['symlinked ledger files are never read into or written through the ledger', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Login', 'Logout']);
|
|
run(root, ['apply', '--input', input]);
|
|
const outside = path.join(root, 'other-repo-story.md');
|
|
fs.writeFileSync(outside, story(ids[1], 'PRIVATE-TITLE'));
|
|
fs.rmSync(path.join(root, '.stories', `${ids[1]}.md`));
|
|
symlink(outside, path.join(root, '.stories', `${ids[1]}.md`));
|
|
const status = run(root, ['status']);
|
|
assert.strictEqual(status.code, 2);
|
|
assert.match(status.err, /symlinked ledger file/);
|
|
assert.strictEqual(run(root, ['reconcile']).code, 2);
|
|
const update = writeJson(root, 'u.json', { update: { [`${ids[1]}.md`]: story(ids[1], 'Logout', { status: 'in-progress' }) } });
|
|
assert.strictEqual(run(root, ['apply', '--input', update]).code, 2);
|
|
assert.ok(!read(root, 'epics/auth-flow.md').includes('PRIVATE-TITLE'));
|
|
assert.strictEqual(fs.readFileSync(outside, 'utf8'), story(ids[1], 'PRIVATE-TITLE'));
|
|
}],
|
|
|
|
['recover never follows symlinks planted in .txn/', () => {
|
|
const root = newRepo();
|
|
const txn = path.join(root, '.stories', '.txn');
|
|
const outside = path.join(root, 'outside.txt');
|
|
fs.writeFileSync(outside, 'ORIGINAL');
|
|
fs.mkdirSync(txn);
|
|
fs.writeFileSync(path.join(txn, 'journal.json'), JSON.stringify({ entries: [{ path: 'auth-flow-1.md', backup: 'backup-0' }] }));
|
|
symlink(outside, path.join(txn, 'backup-0'));
|
|
assert.strictEqual(run(root, ['recover']).code, 2, 'symlinked backup must be refused');
|
|
assert.ok(!fs.existsSync(path.join(root, '.stories', 'auth-flow-1.md')));
|
|
|
|
fs.rmSync(path.join(txn, 'backup-0'));
|
|
fs.writeFileSync(path.join(txn, 'backup-0'), 'GOOD');
|
|
symlink(outside, path.join(txn, 'restore.tmp'));
|
|
assert.strictEqual(run(root, ['recover']).code, 0);
|
|
assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'ORIGINAL');
|
|
assert.ok(fs.lstatSync(path.join(root, '.stories', 'auth-flow-1.md')).isFile());
|
|
}],
|
|
|
|
['a leftover .lock.break makes writers time out instead of spinning', () => {
|
|
const root = newRepo();
|
|
fs.writeFileSync(path.join(root, '.stories', '.lock'), JSON.stringify({ pid: 2 ** 22 + 1, host: os.hostname(), at: 'stale' }));
|
|
fs.writeFileSync(path.join(root, '.stories', '.lock.break'), '1');
|
|
const r = spawnSync(process.execPath, [LEDGER, 'reconcile', '--root', root, '--wait-ms', '200'], { encoding: 'utf8', timeout: 5000 });
|
|
assert.strictEqual(r.status, 3, `expected lock timeout, got ${r.status} ${r.signal}`);
|
|
assert.match(r.stderr, /\.lock\.break is held/);
|
|
}],
|
|
|
|
['symlinked .ids registry is refused outside git', () => {
|
|
const root = newRepo();
|
|
fs.rmSync(path.join(root, '.git'), { recursive: true });
|
|
const outside = tempDir('story-ids-');
|
|
symlink(outside, path.join(root, '.stories', '.ids'), 'dir');
|
|
assert.strictEqual(run(root, ['allocate', '--epic', 'auth-flow']).code, 2);
|
|
assert.deepStrictEqual(fs.readdirSync(outside), []);
|
|
}],
|
|
|
|
['a batch naming the same file twice is refused', () => {
|
|
const root = newRepo();
|
|
const input = writeJson(root, 'dup.json', { create: { 'sprints\\sprint-1.md': '# A\n', 'sprints/sprint-1.md': '# B\n' } });
|
|
const r = run(root, ['apply', '--input', input]);
|
|
assert.strictEqual(r.code, 2);
|
|
assert.match(r.err, /duplicate path/);
|
|
assert.ok(!fs.existsSync(path.join(root, '.stories', 'sprints', 'sprint-1.md')));
|
|
}],
|
|
|
|
['a waiter that saw a stale lock never deletes the live lock that replaced it', () => {
|
|
const root = newRepo();
|
|
const lock = path.join(root, '.stories', '.lock');
|
|
fs.writeFileSync(lock, JSON.stringify({ pid: 2 ** 22 + 1, host: os.hostname(), at: 'stale' }));
|
|
// Preload: right after the waiter reads the stale lock, another writer wins and holds a live lock.
|
|
const preload = path.join(root, 'race.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); const orig = fs.openSync; let raced = false; const observed = new Map();
|
|
fs.openSync = function (p, ...a) { const fd = orig.call(fs, p, ...a);
|
|
if (String(p).endsWith('.lock') && a[0] === (fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))) observed.set(fd, p);
|
|
return fd; };
|
|
const close = fs.closeSync; fs.closeSync = function (fd) { const p = observed.get(fd); observed.delete(fd); close.call(fs, fd);
|
|
if (!raced && p) { raced = true; fs.unlinkSync(p);
|
|
fs.writeFileSync(p, JSON.stringify({ pid: ${process.pid}, host: require('os').hostname(), at: 'live' }), { flag: 'wx' }); }
|
|
};`);
|
|
const r = spawnSync(process.execPath, ['-r', preload, LEDGER, 'allocate', '--epic', 'auth-flow', '--root', root, '--wait-ms', '200'], { encoding: 'utf8' });
|
|
assert.strictEqual(r.status, 3, `expected lock timeout, got ${r.status}: ${r.stderr}`);
|
|
assert.ok(fs.readFileSync(lock, 'utf8').includes('live'), 'live lock was deleted');
|
|
}],
|
|
|
|
['story status moves forward one step at a time and new stories start as todo', () => {
|
|
const root = newRepo();
|
|
const [id] = run(root, ['allocate', '--epic', 'auth-flow']).out.ids;
|
|
const put = (kind, status) => run(root, ['apply', '--input', writeJson(root, 't.json', { [kind]: { [`${id}.md`]: story(id, 'Login', { status }) } })]).code;
|
|
assert.strictEqual(put('create', 'done'), 2);
|
|
assert.strictEqual(put('create', 'todo'), 0);
|
|
assert.strictEqual(put('update', 'done'), 2, 'todo -> done skips steps');
|
|
for (const status of ['in-progress', 'in-progress', 'review', 'done']) assert.strictEqual(put('update', status), 0, status);
|
|
assert.strictEqual(put('update', 'todo'), 2, 'done -> todo moves backward');
|
|
assert.ok(read(root, 'epics/auth-flow.md').includes(`| ${id} | Login | done |`));
|
|
}],
|
|
|
|
['stories can only be assigned to an existing sprint', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Login']);
|
|
run(root, ['apply', '--input', input]);
|
|
for (const sprint of ['2', 'bogus']) {
|
|
const plan = writeJson(root, 'p.json', { update: { [`${ids[0]}.md`]: story(ids[0], 'Login', { sprint }) } });
|
|
assert.strictEqual(run(root, ['apply', '--input', plan]).code, 2, sprint);
|
|
}
|
|
fs.writeFileSync(path.join(root, '.stories', `${ids[0]}.md`), story(ids[0], 'Login', { sprint: '7' }));
|
|
const status = run(root, ['status']);
|
|
assert.strictEqual(status.code, 2);
|
|
assert.match(status.err, /sprint 7 does not exist/);
|
|
}],
|
|
|
|
['story points must be one of 1, 2, 3, 5, 8 so sprint totals stay exact', () => {
|
|
const root = newRepo();
|
|
const [id] = run(root, ['allocate', '--epic', 'auth-flow']).out.ids;
|
|
for (const points of ['bogus', '4', '-3', '1e3', '0', '']) {
|
|
const input = writeJson(root, 'pts.json', { create: { [`${id}.md`]: story(id, 'Login', { points }) } });
|
|
assert.strictEqual(run(root, ['apply', '--input', input]).code, 2, `Points=${points}`);
|
|
}
|
|
assert.deepStrictEqual(storyFiles(root), []);
|
|
fs.writeFileSync(path.join(root, '.stories', `${id}.md`), story(id, 'Login', { points: 'bogus' }));
|
|
const status = run(root, ['status']);
|
|
assert.strictEqual(status.code, 2);
|
|
assert.match(status.err, /Points must be one of/);
|
|
}],
|
|
|
|
['git-common allocation registry refuses a symlinked parent', () => {
|
|
const root = newRepo();
|
|
const outside = tempDir('story-outside-');
|
|
symlink(outside, path.join(root, '.git', 'story-lifecycle'), 'dir');
|
|
const result = run(root, ['allocate', '--epic', 'auth-flow']);
|
|
assert.strictEqual(result.code, 2, result.err);
|
|
assert.deepStrictEqual(fs.readdirSync(outside), []);
|
|
}],
|
|
|
|
['allocation count and lock wait must be bounded integers', () => {
|
|
const root = newRepo();
|
|
for (const count of ['1.5', 'NaN', 'Infinity']) {
|
|
assert.strictEqual(run(root, ['allocate', '--epic', 'auth-flow', '--count', count]).code, 2);
|
|
}
|
|
for (const value of ['NaN', 'Infinity', '-1', '1.5', '60001']) {
|
|
assert.strictEqual(run(root, ['allocate', '--epic', 'auth-flow', '--wait-ms', value]).code, 2);
|
|
}
|
|
}],
|
|
|
|
['allocation rejects unsafe existing numeric IDs instead of looping forever', () => {
|
|
const root = newRepo();
|
|
const registry = path.join(root, '.git', 'story-lifecycle', 'ids');
|
|
fs.mkdirSync(registry, { recursive: true });
|
|
for (const n of ['9007199254740992', '9007199254740991']) {
|
|
const marker = path.join(registry, `auth-flow-${n}`);
|
|
fs.writeFileSync(marker, '');
|
|
const result = spawnSync(process.execPath, [LEDGER, 'allocate', '--root', root,
|
|
'--epic', 'auth-flow', '--count', '2'], { encoding: 'utf8', timeout: 2000 });
|
|
assert.strictEqual(result.status, 2, result.stderr || result.error?.message);
|
|
assert.match(result.stderr, /safe integer/);
|
|
assert.deepStrictEqual(fs.readdirSync(registry), [`auth-flow-${n}`]);
|
|
fs.rmSync(marker);
|
|
}
|
|
}],
|
|
|
|
['init never follows a symlink planted at the ignore-file write boundary', () => {
|
|
const root = tempDir('story-init-race-');
|
|
const victim = path.join(root, 'victim.txt');
|
|
fs.writeFileSync(victim, 'ORIGINAL');
|
|
const preload = path.join(root, 'race.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); let planted = false;
|
|
const plant = (p) => { if (!planted && String(p).split(require('path').sep).join('/').endsWith('/.stories/.gitignore')) {
|
|
planted = true; fs.symlinkSync(${JSON.stringify(victim)}, p); } };
|
|
const exists = fs.existsSync; fs.existsSync = function (p) { const result = exists.call(fs, p);
|
|
if (!result) plant(p); return result; };
|
|
const open = fs.openSync; fs.openSync = function (p, ...args) { plant(p); return open.call(fs, p, ...args); };`);
|
|
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'init', '--root', root], { encoding: 'utf8' });
|
|
assert.strictEqual(fs.readFileSync(victim, 'utf8'), 'ORIGINAL', result.stderr);
|
|
assert.notStrictEqual(result.status, 0, 'a planted symlink must fail closed');
|
|
}],
|
|
|
|
['interrupted story-before-new-sprint batch reports recovery without projecting partial state', () => {
|
|
for (const interrupted of [false, true]) {
|
|
const root = newRepo();
|
|
const [id] = run(root, ['allocate', '--epic', 'auth-flow']).out.ids;
|
|
const before = read(root, 'epics/auth-flow.md');
|
|
const input = writeJson(root, 'sprint-batch.json', { create: {
|
|
[`${id}.md`]: story(id, 'Assigned', { sprint: '1' }),
|
|
'sprints/sprint-1.md': '# Sprint 1\n\n<!-- Derived from story files -->\n| ID | Title | Points | Status |\n| --- | --- | --- | --- |\n\n## Total Points: 0\n',
|
|
} });
|
|
const result = run(root, ['apply', '--input', input], interrupted ? { STORY_LEDGER_CRASH_AFTER: '1' } : {});
|
|
assert.strictEqual(result.code, interrupted ? 70 : 0, result.err);
|
|
if (interrupted) {
|
|
const partialStory = read(root, `${id}.md`);
|
|
const status = run(root, ['status']);
|
|
assert.strictEqual(status.code, 0, status.err);
|
|
assert.strictEqual(status.out.recoveryPending, true);
|
|
assert.strictEqual(status.out.projectionAvailable, false);
|
|
assert.strictEqual(read(root, `${id}.md`), partialStory, 'status must be read-only');
|
|
assert.strictEqual(run(root, ['recover']).code, 0);
|
|
assert.deepStrictEqual(storyFiles(root), []);
|
|
assert.strictEqual(read(root, 'epics/auth-flow.md'), before);
|
|
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
|
|
}
|
|
const status = run(root, ['status']);
|
|
assert.strictEqual(status.code, 0, status.err);
|
|
assert.strictEqual(status.out.recoveryPending, false);
|
|
assert.deepStrictEqual(status.out.drift, []);
|
|
assert.ok(read(root, 'sprints/sprint-1.md').includes(`| ${id} | Assigned | 3 | todo |`));
|
|
}
|
|
}],
|
|
|
|
['a competing create at publication is preserved through failure and recovery', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Ours']);
|
|
const target = path.join(root, '.stories', `${ids[0]}.md`);
|
|
const preload = path.join(root, 'publish-race.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); let planted = false;
|
|
const plant = (target) => { if (!planted && target === ${JSON.stringify(target)}) {
|
|
planted = true; fs.writeFileSync(target, 'COMPETING OWNER', {flag:'wx'}); } };
|
|
for (const name of ['renameSync', 'linkSync']) { const original = fs[name]; fs[name] = function (source, target, ...a) {
|
|
if (String(source).split(require('path').sep).join('/').includes('/.txn/new-')) plant(target); return original.call(fs, source, target, ...a); }; }`);
|
|
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
|
|
assert.notStrictEqual(result.status, 0, 'publication must not overwrite another owner');
|
|
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'COMPETING OWNER');
|
|
assert.strictEqual(run(root, ['recover']).code, 0);
|
|
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'COMPETING OWNER', 'rollback must not delete another owner');
|
|
}],
|
|
|
|
['staged writes refuse symlinks planted just before open', () => {
|
|
const root = newRepo();
|
|
const { input } = createStories(root, ['Ours']);
|
|
const victim = path.join(root, 'victim.txt'); fs.writeFileSync(victim, 'ORIGINAL');
|
|
const preload = path.join(root, 'stage-race.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); let planted = false;
|
|
const plant = (p) => { if (!planted && String(p).split(require('path').sep).join('/').endsWith('/.txn/new-0')) {
|
|
planted = true; fs.symlinkSync(${JSON.stringify(victim)}, p); } };
|
|
const open = fs.openSync; fs.openSync = function (p, ...a) { plant(p); return open.call(fs, p, ...a); };
|
|
const write = fs.writeFileSync; fs.writeFileSync = function (p, ...a) { plant(p); return write.call(fs, p, ...a); };`);
|
|
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
|
|
assert.notStrictEqual(result.status, 0);
|
|
assert.strictEqual(fs.readFileSync(victim, 'utf8'), 'ORIGINAL');
|
|
assert.strictEqual(run(root, ['recover']).code, 0);
|
|
assert.deepStrictEqual(storyFiles(root), []);
|
|
}],
|
|
|
|
['concurrent attempts to publish one allocated story have one owner', async () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Single owner']);
|
|
const results = await Promise.all([runAsync(root, ['apply', '--input', input]), runAsync(root, ['apply', '--input', input])]);
|
|
assert.deepStrictEqual(results.map((r) => r.code).sort(), [0, 4]);
|
|
assert.deepStrictEqual(storyFiles(root), [`${ids[0]}.md`]);
|
|
assert.ok(read(root, `${ids[0]}.md`).includes('Single owner'));
|
|
assert.deepStrictEqual(run(root, ['status']).out.drift, []);
|
|
}],
|
|
|
|
['release leaves a replacement lock owned by another writer intact', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Ours']);
|
|
const lock = path.join(root, '.stories', '.lock');
|
|
const preload = path.join(root, 'lock-owner-race.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.rmSync; let replaced = false;
|
|
fs.rmSync = function (p, ...args) { const existed = fs.existsSync(p); const result = original.call(fs, p, ...args);
|
|
if (existed && !replaced && String(p).split(require('path').sep).join('/').endsWith('/.stories/.txn')) { replaced = true; fs.unlinkSync(${JSON.stringify(lock)});
|
|
try { fs.writeFileSync(${JSON.stringify(lock)}, JSON.stringify({pid:${process.pid},host:require('os').hostname(),at:'replacement'}), {flag:'wx'}); }
|
|
catch (error) { process.stderr.write(${JSON.stringify(LOCK_DENIAL_MARKER)} + JSON.stringify({code:error.code,syscall:error.syscall,path:error.path}) + '\\n'); throw error; } }
|
|
return result; };`);
|
|
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
|
|
// Some Windows Node versions prevent re-creating an open delete-pending
|
|
// lock. The injected foreign writer is rejected before it can take ownership.
|
|
if (windowsLockCreateDenied(result, lock)) return assertRefusedLockWriter(root, ids, input);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const replacement = fs.readFileSync(lock, 'utf8');
|
|
assert.deepStrictEqual(JSON.parse(replacement), { pid: process.pid, host: os.hostname(), at: 'replacement' });
|
|
assert.strictEqual(run(root, ['status']).code, 0);
|
|
assert.strictEqual(fs.readFileSync(lock, 'utf8'), replacement);
|
|
}],
|
|
|
|
['lock release survives inode reuse after an early descriptor close', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Inode owner']);
|
|
const lock = path.join(root, '.stories', '.lock');
|
|
const preload = path.join(root, 'lock-inode-reuse.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); let fd, oldStat, closed = false, replaced = false;
|
|
const open = fs.openSync; fs.openSync = function (p, flags, ...a) {
|
|
const result = open.call(fs, p, flags, ...a);
|
|
if (String(p) === ${JSON.stringify(lock)} && (flags & fs.constants.O_EXCL)) {
|
|
fd = result; oldStat = fs.fstatSync(result); closed = false;
|
|
} return result; };
|
|
const close = fs.closeSync; fs.closeSync = function (value) {
|
|
if (value === fd) closed = true; return close.call(fs, value); };
|
|
const remove = fs.rmSync; fs.rmSync = function (p, ...a) {
|
|
const existed = fs.existsSync(p); const result = remove.call(fs, p, ...a);
|
|
if (existed && !replaced && String(p).split(require('path').sep).join('/').endsWith('/.stories/.txn')) {
|
|
replaced = true; fs.unlinkSync(${JSON.stringify(lock)});
|
|
try { fs.writeFileSync(${JSON.stringify(lock)}, JSON.stringify({ pid: ${process.pid}, host: require('os').hostname(), at: 'replacement' }), { flag: 'wx' }); }
|
|
catch (error) { process.stderr.write(${JSON.stringify(LOCK_DENIAL_MARKER)} + JSON.stringify({code:error.code,syscall:error.syscall,path:error.path}) + '\\n'); throw error; }
|
|
} return result; };
|
|
const stat = fs.lstatSync; fs.lstatSync = function (p, ...a) {
|
|
const result = stat.call(fs, p, ...a);
|
|
return replaced && closed && String(p) === ${JSON.stringify(lock)} ? oldStat : result;
|
|
};`);
|
|
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
|
|
// Some Windows Node versions prevent re-creating an open delete-pending
|
|
// lock. The injected foreign writer is rejected before it can take ownership.
|
|
if (windowsLockCreateDenied(result, lock)) return assertRefusedLockWriter(root, ids, input);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const replacement = fs.readFileSync(lock, 'utf8');
|
|
assert.deepStrictEqual(JSON.parse(replacement), { pid: process.pid, host: os.hostname(), at: 'replacement' });
|
|
assert.strictEqual(run(root, ['status']).code, 0);
|
|
assert.strictEqual(fs.readFileSync(lock, 'utf8'), replacement);
|
|
}],
|
|
|
|
['delete-pending cleanup may mask a proven foreign-open denial without losing the owner close', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Committed owner']);
|
|
const lock = path.join(root, '.stories', '.lock');
|
|
const proof = path.join(root, 'owner-close.json');
|
|
const preload = path.join(root, 'delete-pending.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); let owner, pending = false, ownerClosed = false;
|
|
const lock = ${JSON.stringify(lock)};
|
|
const error = syscall => Object.assign(new Error("EPERM: operation not permitted, " + syscall + " '" + lock + "'"), {code:'EPERM',syscall,path:lock});
|
|
const open = fs.openSync; fs.openSync = function (p, flags, ...args) {
|
|
const fd = open.call(fs, p, flags, ...args); if (p === lock && (flags & fs.constants.O_EXCL)) owner = fd; return fd; };
|
|
const remove = fs.rmSync; fs.rmSync = function (p, ...args) {
|
|
const existed = fs.existsSync(p); const result = remove.call(fs, p, ...args);
|
|
if (existed && !pending && String(p).split(require('path').sep).join('/').endsWith('/.stories/.txn')) {
|
|
fs.unlinkSync(lock); pending = true; const denied = error('open');
|
|
process.stderr.write(${JSON.stringify(LOCK_DENIAL_MARKER)} + JSON.stringify(denied) + '\\n'); throw denied;
|
|
} return result; };
|
|
const stat = fs.lstatSync; fs.lstatSync = function (p, ...args) { if (pending && p === lock) throw error('lstat'); return stat.call(fs, p, ...args); };
|
|
const close = fs.closeSync; fs.closeSync = function (fd) {
|
|
const result = close.call(fs, fd); if (pending && fd === owner && !ownerClosed) {
|
|
ownerClosed = true;
|
|
let code; try { fs.fstatSync(fd); } catch (closed) { code = closed.code; }
|
|
fs.writeFileSync(${JSON.stringify(proof)}, JSON.stringify({closed:code}));
|
|
} return result; };`);
|
|
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
|
|
assert.strictEqual(result.status, 1, result.stderr);
|
|
assert.match(result.stderr, /EPERM: operation not permitted, lstat/);
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(proof, 'utf8')), { closed: 'EBADF' });
|
|
assert.strictEqual(windowsLockCreateDenied(result, lock, 'win32'), true, result.stderr);
|
|
assert.strictEqual(windowsLockCreateDenied(result, lock, 'linux'), false);
|
|
assert.strictEqual(windowsLockCreateDenied(result, `${lock}.break`, 'win32'), false);
|
|
const unmarked = { ...result, stderr: result.stderr.split('\n').filter(line => !line.startsWith(LOCK_DENIAL_MARKER)).join('\n') };
|
|
assert.strictEqual(windowsLockCreateDenied(unmarked, lock, 'win32'), false);
|
|
assertRefusedLockWriter(root, ids, input);
|
|
}],
|
|
|
|
['recovery can itself be interrupted and retried without losing rollback ownership', () => {
|
|
const root = newRepo();
|
|
const { ids, input } = createStories(root, ['Before']);
|
|
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
|
|
const before = read(root, `${ids[0]}.md`);
|
|
const change = writeJson(root, 'update.json', { update: { [`${ids[0]}.md`]: story(ids[0], 'After', { status: 'in-progress' }) } });
|
|
assert.strictEqual(run(root, ['apply', '--input', change], { STORY_LEDGER_CRASH_AFTER: '1' }).code, 70);
|
|
const preload = path.join(root, 'recover-crash.js');
|
|
fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.renameSync;
|
|
fs.renameSync = function (source, ...args) { const result = original.call(fs, source, ...args);
|
|
if (String(source).split(require('path').sep).join('/').endsWith('/.txn/restore.tmp')) process.exit(70); return result; };`);
|
|
const recovery = spawnSync(process.execPath, ['-r', preload, LEDGER, 'recover', '--root', root], { encoding: 'utf8' });
|
|
assert.strictEqual(recovery.status, 70);
|
|
const retry = run(root, ['recover']);
|
|
assert.strictEqual(retry.code, 0, retry.err);
|
|
assert.strictEqual(read(root, `${ids[0]}.md`), before);
|
|
assert.deepStrictEqual(run(root, ['status']).out.drift, []);
|
|
}],
|
|
|
|
['Windows sharing denial during lock publication is retried within the deadline', () => {
|
|
const root = newRepo();
|
|
const preload = path.join(root, 'sharing-retry.js');
|
|
fs.writeFileSync(preload, `require('path'); Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
const fs = require('fs'); const original = fs.openSync; let attempts = 0;
|
|
fs.openSync = function (p, flags, ...args) {
|
|
if (String(p).endsWith('.lock') && (flags & fs.constants.O_EXCL) && attempts++ < 2) {
|
|
throw Object.assign(new Error('simulated delete-pending sharing denial'), { code: 'EPERM' });
|
|
} return original.call(fs, p, flags, ...args); };`);
|
|
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'allocate', '--root', root,
|
|
'--epic', 'auth-flow', '--wait-ms', '1000'], { encoding: 'utf8', timeout: 3000 });
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(JSON.parse(result.stdout).ids, ['auth-flow-1']);
|
|
}],
|
|
|
|
['permanent Windows sharing denial expires without creating an allocation', () => {
|
|
const root = newRepo();
|
|
const preload = path.join(root, 'sharing-denied.js');
|
|
fs.writeFileSync(preload, `require('path'); Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
const fs = require('fs'); const original = fs.openSync;
|
|
fs.openSync = function (p, flags, ...args) {
|
|
if (String(p).endsWith('.lock') && (flags & fs.constants.O_EXCL)) {
|
|
throw Object.assign(new Error('simulated permanent sharing denial'), { code: 'EACCES' });
|
|
} return original.call(fs, p, flags, ...args); };`);
|
|
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'allocate', '--root', root,
|
|
'--epic', 'auth-flow', '--wait-ms', '40'], { encoding: 'utf8', timeout: 2000 });
|
|
assert.strictEqual(result.status, 3, result.stderr || result.error?.message);
|
|
assert.match(result.stderr, /Windows file access EACCES/);
|
|
assert.ok(!fs.existsSync(path.join(root, '.git', 'story-lifecycle', 'ids', 'auth-flow-1')));
|
|
}],
|
|
|
|
['ledger paths are confined to .stories/ file shapes', () => {
|
|
const dir = path.join(os.tmpdir(), '.stories');
|
|
for (const bad of ['../x-1.md', 'epics/../../x.md', 'Auth-1.md', 'a/b-1.md', 'epics/a.txt', 'sprints/sprint-0.md', '/abs-1.md']) {
|
|
assert.throws(() => resolveLedgerPath(dir, bad), /invalid ledger path/, bad);
|
|
}
|
|
assert.ok(resolveLedgerPath(dir, 'epics\\auth-flow.md').endsWith(path.join('epics', 'auth-flow.md')));
|
|
}],
|
|
];
|
|
|
|
(async () => {
|
|
console.log('\n=== Testing story-lifecycle ledger behaviour ===\n');
|
|
let passed = 0;
|
|
let failed = 0;
|
|
for (const [name, fn] of tests) {
|
|
try {
|
|
await fn();
|
|
console.log(` ✓ ${name}`);
|
|
passed++;
|
|
} catch (error) {
|
|
if (error instanceof Skip) {
|
|
console.log(` - ${name} (skipped: ${error.message})`);
|
|
continue;
|
|
}
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${error.message}`);
|
|
failed++;
|
|
} finally {
|
|
for (const dir of ownedTemps) fs.rmSync(dir, { recursive: true, force: true });
|
|
ownedTemps.clear();
|
|
}
|
|
}
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
})();
|