Files
msd-core/tests/workspace.test.cjs
Tom Boucher 3fac6e629f test(#3145): bound the installer/runtime cluster onto the process seam (#3176)
* test(#3145): bound the installer/runtime cluster onto the process seam

Migrates 156 unbounded sync spawn sites across 47 files. Allowlist 120 to 73.

Timeouts are sized from evidence already in the tree rather than a house
default, because this wave spawns installers rather than git plumbing and an
undersized bound does not catch a hang -- it manufactures CI flake, which is
worse, since a flake gets re-run instead of investigated. install.test.cjs
records a real spawnSync ETIMEDOUT at a 60000ms cap on a loaded bench while
another lane passed the same commit in 12.7s, so full installs are bound at
120000ms against that recorded incident.

Also adds an auditable escape to the guard's timeout ceiling. The 600000ms
cap was set in #3143 from partial evidence, but fragment-single-edit-
propagation carries a documented, load-tested 900000ms bound on a run that
chains a full build plus eight generators -- the guard would have rejected a
correct timeout the moment that file left the allowlist. A value above the
ceiling is now permitted only with an inline allow-spawn-timeout-ceiling
marker carrying a non-empty reason. It raises the ceiling; it never waives
the requirement for a bound, which is asserted directly.

install-shared.cjs keeps its hand-rolled assert rather than routing through
throwIfFailed: its message embeds both streams, and throwIfFailed carries
only a trimmed stderr. The message now also names the outcome, so a bounded
timeout reads as such across its 38 importers instead of as
expected null to equal 0.

* test(#3145): extract class-norm timeouts and correct the build-hooks sizing

A pre-PR review found 52 copies of four class-norm timeout constants across
this wave. These are not per-suite fixture bindings -- they are shared facts
about how long a class of subprocess takes, derived from a recorded bench
incident. That norm already moved once (60000 to 120000 after a real
ETIMEDOUT), and 52 copies would have drifted the next time it moved.

Extracts tests/helpers/timeouts.cjs, where each norm is justified once, and
converts the copies. A site that genuinely differs -- a real tsc compile, or
regen:derived -- keeps its own local constant with its own justification.

Also corrects a misclassification: scripts/build-hooks.js was sized as a
build at 120000 in twelve places and 60000 in another, but it compiles and
bundles nothing. Its own header says no bundling needed; it copies pre-built
files and syntax-checks them with vm. Three different values bounded one
script; now there is one.

* test(#3145): fix red CI — lint self-match and a Windows chunk overrun

Two failures on PR 3176.

lint-allow-test-rule-refs read a RuleTester fixture as a real exemption. The
fixture exists to prove an unrelated marker does NOT suppress the rule, so it
carries that marker's literal text as test data. Split via concatenation, the
same idiom no-unbounded-spawn-allowlist.test.cjs already uses for its own
self-match problem. The explanatory comment needed the same treatment.

The Windows shard 3/3 chunk was killed at its 600000ms budget. Output stopped
seven minutes before the kill, so this was an overrun rather than a slow
chunk: regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface runs
regen:derived bounded at 900000ms, which is larger than the whole chunk
budget, so the chunk killer always fires first and it can never complete
there. Both the test and that bound predate this change; modifying the file
pulled it into the Windows targeted set and exposed it. Skipped on Windows
with the reason recorded; the Linux lanes cover it. The 900000 bound and its
ceiling marker are unchanged -- they are correct.

* test(#3145): refresh the stale test-timings cost table

The Windows shard was killed at its 600000ms per-chunk budget. run-tests.cjs
packs chunks by measured duration from tests/test-timings.json, and an
unknown file falls back to the table's median weight -- advisory by design,
but it silently underweights exactly the files that matter.

Four of the failing chunk's 22 files were absent from the table, including
the two heaviest: fragment-single-edit-propagation.install.test.cjs at 230s
(it runs regen:derived) and agent-fragments-emission.install.test.cjs at 79s.
Both were weighted as average, so the chunk's total weight read 53.68 against
a budget of 60 and the packer produced a single chunk.

Regenerated from a passing full-suite run, per the remedy the script itself
documents. 700 to 770 entries, 70 added, 0 dropped -- verified, since
gen-test-timings.cjs replaces the table wholesale rather than merging.

Proven against the real packer: the same 22 files now weigh 103.91 and split
into two chunks. No logic, budget, or timeout was changed; raising a budget
to make a red gate pass is not a fix.

---------

Co-authored-by: sim <sim@local>
2026-08-07 15:18:18 -04:00

535 lines
21 KiB
JavaScript

/**
* GSD Workspace Tests
*
* Tests for /gsd-new-workspace, /gsd-list-workspaces, /gsd-remove-workspace
* init functions and integration with gsd-tools routing.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs');
const { detectChildRepos } = require('../gsd-core/bin/lib/init.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
// ─── detectChildRepos ────────────────────────────────────────────────────────
describe('detectChildRepos', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-ws-test-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('detects child git repos', () => {
// Create two child git repos
const repo1 = path.join(tmpDir, 'repo-a');
const repo2 = path.join(tmpDir, 'repo-b');
fs.mkdirSync(repo1);
fs.mkdirSync(repo2);
gitOrThrow(['init'], { cwd: repo1, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['init'], { cwd: repo2, timeoutMs: GIT_TIMEOUT_MS });
const repos = detectChildRepos(tmpDir);
assert.strictEqual(repos.length, 2);
const names = repos.map(r => r.name).sort();
assert.deepStrictEqual(names, ['repo-a', 'repo-b']);
});
test('skips non-git directories', () => {
const gitRepo = path.join(tmpDir, 'real-repo');
const notRepo = path.join(tmpDir, 'just-a-dir');
fs.mkdirSync(gitRepo);
fs.mkdirSync(notRepo);
gitOrThrow(['init'], { cwd: gitRepo, timeoutMs: GIT_TIMEOUT_MS });
const repos = detectChildRepos(tmpDir);
assert.strictEqual(repos.length, 1);
assert.strictEqual(repos[0].name, 'real-repo');
});
test('skips hidden directories', () => {
const hiddenRepo = path.join(tmpDir, '.hidden-repo');
fs.mkdirSync(hiddenRepo);
gitOrThrow(['init'], { cwd: hiddenRepo, timeoutMs: GIT_TIMEOUT_MS });
const repos = detectChildRepos(tmpDir);
assert.strictEqual(repos.length, 0);
});
test('skips files', () => {
fs.writeFileSync(path.join(tmpDir, 'some-file.txt'), 'hello');
const repos = detectChildRepos(tmpDir);
assert.strictEqual(repos.length, 0);
});
test('returns empty array for non-existent directory', () => {
const repos = detectChildRepos(path.join(tmpDir, 'does-not-exist'));
assert.strictEqual(repos.length, 0);
});
});
// ─── cmdInitNewWorkspace via gsd-tools ──────────────────────────────────────
describe('init new-workspace', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-ws-test-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns expected JSON fields', () => {
const result = runGsdTools('init new-workspace', tmpDir);
assert.ok(result.success, `init failed: ${result.error}`);
const data = JSON.parse(result.output);
assert.ok('default_workspace_base' in data);
assert.ok('child_repos' in data);
assert.ok('child_repo_count' in data);
assert.ok('worktree_available' in data);
assert.ok('is_git_repo' in data);
assert.ok('cwd_repo_name' in data);
assert.ok('project_root' in data);
});
test('detects child git repos in cwd', () => {
const repo = path.join(tmpDir, 'my-repo');
fs.mkdirSync(repo);
gitOrThrow(['init'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
const result = runGsdTools('init new-workspace', tmpDir);
const data = JSON.parse(result.output);
assert.strictEqual(data.child_repo_count, 1);
assert.strictEqual(data.child_repos[0].name, 'my-repo');
});
test('reports no git repo when cwd is not a git repo', () => {
const result = runGsdTools('init new-workspace', tmpDir);
const data = JSON.parse(result.output);
assert.strictEqual(data.is_git_repo, false);
});
});
// ─── cmdInitListWorkspaces via gsd-tools ────────────────────────────────────
describe('init list-workspaces', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-ws-test-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns empty list when no workspaces exist', () => {
const result = runGsdTools('init list-workspaces', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init failed: ${result.error}`);
const data = JSON.parse(result.output);
assert.strictEqual(data.workspace_count, 0);
assert.deepStrictEqual(data.workspaces, []);
});
test('finds workspaces with WORKSPACE.md', () => {
const wsBase = path.join(tmpDir, 'gsd-workspaces');
const ws1 = path.join(wsBase, 'feature-a');
fs.mkdirSync(path.join(ws1, '.planning'), { recursive: true });
fs.writeFileSync(path.join(ws1, 'WORKSPACE.md'), [
'# Workspace: feature-a',
'',
'Created: 2026-03-20',
'Strategy: worktree',
'',
'## Member Repos',
'',
'| Repo | Source | Branch | Strategy |',
'|------|--------|--------|----------|',
'| hr-ui | /tmp/hr-ui | workspace/feature-a | worktree |',
].join('\n'));
const result = runGsdTools('init list-workspaces', tmpDir, { HOME: tmpDir });
const data = JSON.parse(result.output);
assert.strictEqual(data.workspace_count, 1);
assert.strictEqual(data.workspaces[0].name, 'feature-a');
assert.strictEqual(data.workspaces[0].strategy, 'worktree');
assert.strictEqual(data.workspaces[0].repo_count, 1);
});
});
// ─── cmdInitRemoveWorkspace via gsd-tools ───────────────────────────────────
describe('init remove-workspace', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-ws-test-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('errors when no name provided', () => {
const result = runGsdTools('init remove-workspace', tmpDir);
assert.strictEqual(result.success, false);
assert.ok(result.error.includes('workspace name required'));
});
test('errors when workspace not found', () => {
const result = runGsdTools('init remove-workspace nonexistent', tmpDir, { HOME: tmpDir });
assert.strictEqual(result.success, false);
assert.ok(result.error.includes('Workspace not found'));
});
test('returns workspace info for existing workspace', () => {
const wsBase = path.join(tmpDir, 'gsd-workspaces');
const ws = path.join(wsBase, 'test-ws');
fs.mkdirSync(ws, { recursive: true });
fs.writeFileSync(path.join(ws, 'WORKSPACE.md'), [
'# Workspace: test-ws',
'',
'Created: 2026-03-20',
'Strategy: clone',
'',
'## Member Repos',
'',
'| Repo | Source | Branch | Strategy |',
'|------|--------|--------|----------|',
'| api | /tmp/api | workspace/test-ws | clone |',
].join('\n'));
const result = runGsdTools('init remove-workspace test-ws', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init failed: ${result.error}`);
const data = JSON.parse(result.output);
assert.strictEqual(data.workspace_name, 'test-ws');
assert.strictEqual(data.strategy, 'clone');
assert.strictEqual(data.has_dirty_repos, false);
});
// #2402: cmdInitRemoveWorkspace didn't route through withProjectRoot, so
// remove-workspace.md never received response_language (nor project_root/
// agents_installed). Mirrors the established `init manager` response_language
// coverage pattern (tests/init-manager.test.cjs).
describe('response_language wiring (#2402)', () => {
function writeWorkspace(base, name) {
const ws = path.join(base, 'gsd-workspaces', name);
fs.mkdirSync(ws, { recursive: true });
fs.writeFileSync(path.join(ws, 'WORKSPACE.md'), [
`# Workspace: ${name}`,
'',
'Created: 2026-03-20',
'Strategy: clone',
'',
'## Member Repos',
'',
'| Repo | Source | Branch | Strategy |',
'|------|--------|--------|----------|',
].join('\n'));
}
test('output includes response_language when configured', () => {
writeWorkspace(tmpDir, 'test-ws');
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ response_language: 'Japanese' })
);
const result = runGsdTools('init remove-workspace test-ws', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init failed: ${result.error}`);
const data = JSON.parse(result.output);
assert.strictEqual(data.response_language, 'Japanese');
});
test('output omits response_language when not configured', () => {
writeWorkspace(tmpDir, 'test-ws');
const result = runGsdTools('init remove-workspace test-ws', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init failed: ${result.error}`);
const data = JSON.parse(result.output);
assert.strictEqual(data.response_language, undefined);
});
});
});
// ─── Integration: worktree creation and removal ─────────────────────────────
describe('workspace worktree integration', () => {
let tmpDir;
let sourceRepo;
beforeEach(() => {
tmpDir = createTempDir('gsd-ws-integ-');
// Create a source git repo with a commit
sourceRepo = path.join(tmpDir, 'source-repo');
fs.mkdirSync(sourceRepo);
gitOrThrow(['init'], { cwd: sourceRepo, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: sourceRepo, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'user.name', 'Test'], { cwd: sourceRepo, timeoutMs: GIT_TIMEOUT_MS });
fs.writeFileSync(path.join(sourceRepo, 'README.md'), '# Test Repo\n');
gitOrThrow(['add', '-A'], { cwd: sourceRepo, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'initial'], { cwd: sourceRepo, timeoutMs: GIT_TIMEOUT_MS });
});
afterEach(() => {
// Clean up worktrees before removing tmp dir
try {
gitOrThrow(['worktree', 'prune'], { cwd: sourceRepo, timeoutMs: GIT_TIMEOUT_MS });
} catch { /* best-effort */ }
cleanup(tmpDir);
});
test('creates workspace with git worktree', () => {
const wsPath = path.join(tmpDir, 'my-workspace');
fs.mkdirSync(wsPath);
fs.mkdirSync(path.join(wsPath, '.planning'));
// Create worktree
gitOrThrow(['worktree', 'add', path.join(wsPath, 'source-repo'), '-b', 'workspace/test'], {
cwd: sourceRepo,
timeoutMs: GIT_TIMEOUT_MS,
});
// Verify worktree was created
assert.ok(fs.existsSync(path.join(wsPath, 'source-repo', 'README.md')));
// Verify it's a worktree (has .git file, not .git directory)
const gitPath = path.join(wsPath, 'source-repo', '.git');
assert.ok(fs.existsSync(gitPath));
const stat = fs.statSync(gitPath);
assert.ok(stat.isFile(), '.git should be a file (worktree link), not a directory');
});
test('creates workspace with git clone', () => {
const wsPath = path.join(tmpDir, 'cloned-workspace');
fs.mkdirSync(wsPath);
// Clone repo
gitOrThrow(['clone', sourceRepo, path.join(wsPath, 'source-repo')], {
timeoutMs: GIT_TIMEOUT_MS,
});
// Verify clone
assert.ok(fs.existsSync(path.join(wsPath, 'source-repo', 'README.md')));
// Verify it's a full clone (has .git directory)
const gitPath = path.join(wsPath, 'source-repo', '.git');
const stat = fs.statSync(gitPath);
assert.ok(stat.isDirectory(), '.git should be a directory (full clone)');
});
test('worktree removal cleans up properly', () => {
const wsPath = path.join(tmpDir, 'removable-ws');
fs.mkdirSync(wsPath);
// Create worktree
gitOrThrow(['worktree', 'add', path.join(wsPath, 'source-repo'), '-b', 'workspace/removable'], {
cwd: sourceRepo,
timeoutMs: GIT_TIMEOUT_MS,
});
assert.ok(fs.existsSync(path.join(wsPath, 'source-repo', 'README.md')));
// Remove worktree
gitOrThrow(['worktree', 'remove', path.join(wsPath, 'source-repo')], {
cwd: sourceRepo,
timeoutMs: GIT_TIMEOUT_MS,
});
// Verify worktree is gone
assert.ok(!fs.existsSync(path.join(wsPath, 'source-repo')));
// Verify worktree list doesn't include it
const worktrees = gitOrThrow(['worktree', 'list'], { cwd: sourceRepo, timeoutMs: GIT_TIMEOUT_MS });
assert.ok(!worktrees.includes('removable-ws'));
});
});
// ─── Command and workflow file existence ────────────────────────────────────
// #2790: new-workspace.md, list-workspaces.md, remove-workspace.md were
// consolidated into a single workspace.md command with --new/--list/--remove flags.
// allow-test-rule: source-text-is-the-product
// workspace.md routing text and workflow content IS the deployed behavioral contract for the agent.
describe('workspace command files', () => {
const baseDir = path.join(__dirname, '..');
/**
* Split frontmatter / body and parse simple YAML-ish key:value pairs.
* Returns { fm: { name, argument-hint, ... }, body }. Avoids raw
* substring matching on the file as a whole.
*/
function parseCommandFile(filePath) {
// Strip UTF-8 BOM if present (some editors inject on save under Windows);
// a BOM byte at offset 0 defeats the ^--- anchor, making fmMatch null.
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\ufeff/, '');
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
assert.ok(fmMatch, `${path.basename(filePath)} must start with a YAML frontmatter block`);
const fm = {};
for (const rawLine of fmMatch[1].split(/\r?\n/)) {
// Explicit \r strip: split('\n') on CRLF content leaves a trailing
// \r on every line, which the value regex pulls into `kv[2]` and trim
// is enough for most values — but be defensive so future keys with
// exact-string compare don't surprise us.
const line = rawLine.replace(/\r$/, '');
const kv = line.match(/^([a-zA-Z_-]+):\s*(.*)$/);
if (!kv) continue;
const key = kv[1];
let val = kv[2].trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
fm[key] = val;
}
return { fm, body: fmMatch[2] };
}
/**
* Extract `@`-include targets from any of the <execution_context*> blocks.
* Each line of the form `@~/.claude/gsd-core/workflows/foo.md` becomes
* a relative target like `workflows/foo.md`. Used to assert workflow
* routing structurally instead of substring-matching prose.
*/
function executionContextIncludes(body) {
const blocks = [...body.matchAll(/<execution_context(?:_extended)?>([\s\S]*?)<\/execution_context(?:_extended)?>/g)]
.map((m) => m[1]);
const targets = [];
for (const blk of blocks) {
for (const line of blk.split(/\r?\n/)) {
const t = line.trim();
if (!t.startsWith('@')) continue;
// Normalize away the home-prefix and the `.claude/gsd-core/` root
// so the test only cares about the workflow path tail.
const rel = t.replace(/^@~?\/?(?:\.claude\/)?(?:gsd-core\/)?/, '');
targets.push(rel);
}
}
return targets;
}
test('consolidated workspace.md command declares correct frontmatter contract (#2790)', () => {
// Structural: parse frontmatter, then split argument-hint into the
// tokenized flag list. Each consolidated flag must appear there.
const { fm } = parseCommandFile(path.join(baseDir, 'commands/gsd/workspace.md'));
assert.equal(fm.name, 'gsd:workspace', `workspace.md frontmatter name must be "gsd:workspace"; got "${fm.name}"`);
assert.ok(fm['argument-hint'], 'workspace.md frontmatter must declare argument-hint');
// argument-hint can include multiple bracketed segments and free tokens,
// e.g. "[--new | --list | --remove] [name]". Pull every `--flag` token
// out of any bracketed segment so the test asserts on a parsed flag set,
// not the punctuation around it.
const bracketed = [...fm['argument-hint'].matchAll(/\[([^\]]*)\]/g)].map((m) => m[1]);
const flagList = bracketed
.flatMap((seg) => seg.split('|').map((s) => s.trim().split(/\s+/)[0]))
.filter((tok) => tok.startsWith('--'));
for (const flag of ['--new', '--list', '--remove']) {
assert.ok(
flagList.includes(flag),
`workspace.md argument-hint must declare ${flag}; got: ${JSON.stringify(flagList)}`
);
}
});
test('workspace.md @-includes the new-workspace workflow', () => {
const { body } = parseCommandFile(path.join(baseDir, 'commands/gsd/workspace.md'));
const targets = executionContextIncludes(body);
assert.ok(
targets.some((t) => /(^|\/)workflows\/new-workspace\.md$/.test(t)),
`workspace.md execution_context must @-include workflows/new-workspace.md; got: ${JSON.stringify(targets)}`
);
});
test('workspace.md @-includes the list-workspaces workflow', () => {
const { body } = parseCommandFile(path.join(baseDir, 'commands/gsd/workspace.md'));
const targets = executionContextIncludes(body);
assert.ok(
targets.some((t) => /(^|\/)workflows\/list-workspaces\.md$/.test(t)),
`workspace.md execution_context must @-include workflows/list-workspaces.md; got: ${JSON.stringify(targets)}`
);
});
test('workspace.md @-includes the remove-workspace workflow', () => {
const { body } = parseCommandFile(path.join(baseDir, 'commands/gsd/workspace.md'));
const targets = executionContextIncludes(body);
assert.ok(
targets.some((t) => /(^|\/)workflows\/remove-workspace\.md$/.test(t)),
`workspace.md execution_context must @-include workflows/remove-workspace.md; got: ${JSON.stringify(targets)}`
);
});
test('new-workspace workflow exists', () => {
const content = fs.readFileSync(path.join(baseDir, 'gsd-core/workflows/new-workspace.md'), 'utf8');
assert.ok(
content.includes('init new-workspace') || content.includes('init.new-workspace'),
'expected init new-workspace (CJS) or gsd-sdk query init.new-workspace'
);
assert.ok(content.includes('WORKSPACE.md'));
assert.ok(content.includes('git worktree add'));
assert.ok(content.includes('git clone'));
});
test('list-workspaces workflow exists', () => {
const content = fs.readFileSync(path.join(baseDir, 'gsd-core/workflows/list-workspaces.md'), 'utf8');
assert.ok(
content.includes('init list-workspaces') || content.includes('init.list-workspaces'),
'expected init list-workspaces or gsd-sdk query init.list-workspaces'
);
});
test('remove-workspace workflow exists', () => {
const content = fs.readFileSync(path.join(baseDir, 'gsd-core/workflows/remove-workspace.md'), 'utf8');
assert.ok(
content.includes('init remove-workspace') || content.includes('init.remove-workspace'),
'expected init remove-workspace or gsd-sdk query init.remove-workspace'
);
assert.ok(content.includes('git worktree remove'));
});
});
// ─── Routing in gsd-tools ───────────────────────────────────────────────────
describe('workspace routing in gsd-tools', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
// Behavioral routing tests: verify each command is recognized by the router
// (does not return "Unknown init workflow: ..."). The exact command output is
// covered by the functional tests above; these guard against routing deletions.
test('init new-workspace is routed correctly', () => {
const result = runGsdTools('init new-workspace test-ws', tmpDir);
const stderr = result.error || '';
assert.ok(
!stderr.includes('Unknown init workflow'),
`init new-workspace must be a recognized command; got: ${stderr}`
);
});
test('init list-workspaces is routed correctly', () => {
const result = runGsdTools('init list-workspaces', tmpDir);
assert.ok(result.success, `init list-workspaces should succeed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(Array.isArray(parsed.workspaces), 'list-workspaces must return a workspaces array');
});
test('init remove-workspace is routed correctly', () => {
const result = runGsdTools('init remove-workspace nonexistent-ws', tmpDir);
const stderr = result.error || '';
assert.ok(
!stderr.includes('Unknown init workflow'),
`init remove-workspace must be a recognized command; got: ${stderr}`
);
});
});