Skip to content

Commit 3443ad4

Browse files
committed
test(cli): isolate state dir per invocation in conformance harness
1 parent 098e875 commit 3443ad4

3 files changed

Lines changed: 82 additions & 62 deletions

File tree

apps/cli/src/__tests__/conformance/harness.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,11 @@ export class ConformanceHarness {
164164
stateDir: string,
165165
stdinBytes?: Uint8Array,
166166
): Promise<{ result: RunResult; envelope: CommandEnvelope }> {
167-
const previousStateDir = process.env.SUPERDOC_CLI_STATE_DIR;
168-
process.env.SUPERDOC_CLI_STATE_DIR = stateDir;
169-
170167
let stdout = '';
171168
let stderr = '';
172-
try {
173-
const code = await run(args, {
169+
const code = await run(
170+
args,
171+
{
174172
stdout(message: string) {
175173
stdout += message;
176174
},
@@ -180,17 +178,12 @@ export class ConformanceHarness {
180178
async readStdinBytes() {
181179
return stdinBytes ?? new Uint8Array();
182180
},
183-
});
184-
185-
const result: RunResult = { code, stdout, stderr };
186-
return { result, envelope: parseEnvelope(result) };
187-
} finally {
188-
if (previousStateDir == null) {
189-
delete process.env.SUPERDOC_CLI_STATE_DIR;
190-
} else {
191-
process.env.SUPERDOC_CLI_STATE_DIR = previousStateDir;
192-
}
193-
}
181+
},
182+
{ stateDir },
183+
);
184+
185+
const result: RunResult = { code, stdout, stderr };
186+
return { result, envelope: parseEnvelope(result) };
194187
}
195188

196189
async firstTextRange(docPath: string, stateDir: string, pattern = 'Wilde'): Promise<TextRangeAddress> {

apps/cli/src/index.ts

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { MANUAL_COMMAND_ALLOWLIST, type ManualCommandKey } from './lib/manual-co
1919
import { validateOperationResponseData } from './lib/operation-args';
2020
import { runInstall } from './commands/install';
2121
import { runUninstall } from './commands/uninstall';
22+
import { withStateDirOverride } from './lib/context';
2223
import {
2324
CLI_COMMAND_SPECS,
2425
CLI_COMMAND_KEYS,
@@ -60,6 +61,7 @@ export type InvokeCommandOptions = {
6061
ioOverrides?: Partial<CliIO>;
6162
executionMode?: ExecutionMode;
6263
collabSessionPool?: CommandContext['collabSessionPool'];
64+
stateDir?: string;
6365
};
6466

6567
const MANUAL_COMMANDS = {
@@ -262,13 +264,16 @@ async function executeParsedInvocation(
262264
export async function invokeCommand(argv: string[], options: InvokeCommandOptions = {}): Promise<InvokeCommandResult> {
263265
const io = mergeIo(options.ioOverrides);
264266
const startedAt = io.now();
265-
const parsed = parseInvocation(argv);
266-
const output = await executeParsedInvocation(
267-
parsed,
268-
io,
269-
options.executionMode ?? 'oneshot',
270-
options.collabSessionPool,
271-
);
267+
const { parsed, output } = await withStateDirOverride(options.stateDir, async () => {
268+
const parsedInvocation = parseInvocation(argv);
269+
const commandOutput = await executeParsedInvocation(
270+
parsedInvocation,
271+
io,
272+
options.executionMode ?? 'oneshot',
273+
options.collabSessionPool,
274+
);
275+
return { parsed: parsedInvocation, output: commandOutput };
276+
});
272277

273278
return {
274279
globals: parsed.globals,
@@ -289,60 +294,67 @@ async function runHostCommand(tokens: string[], io: CliIO): Promise<number> {
289294
*
290295
* @param argv - Raw process arguments (after stripping the binary path)
291296
* @param ioOverrides - Optional overrides for stdout, stderr, stdin, and clock
297+
* @param options - Optional runtime overrides such as test-scoped state directory
292298
* @returns Process exit code (0 on success, non-zero on error)
293299
*/
294-
export async function run(argv: string[], ioOverrides?: Partial<CliIO>): Promise<number> {
300+
export async function run(
301+
argv: string[],
302+
ioOverrides?: Partial<CliIO>,
303+
options: Pick<InvokeCommandOptions, 'stateDir'> = {},
304+
): Promise<number> {
295305
const io = mergeIo(ioOverrides);
296306
const startedAt = io.now();
297307
let outputMode: OutputMode = 'json';
298308

299-
try {
300-
const parsed = parseInvocation(argv);
301-
outputMode = parsed.globals.output;
309+
return withStateDirOverride(options.stateDir, async () => {
310+
try {
311+
const parsed = parseInvocation(argv);
312+
outputMode = parsed.globals.output;
302313

303-
if (parsed.rest[0] === 'host') {
304-
const hostTokens = parsed.rest.slice(1);
305-
if (parsed.globals.help) hostTokens.push('--help');
306-
return await runHostCommand(hostTokens, io);
307-
}
314+
if (parsed.rest[0] === 'host') {
315+
const hostTokens = parsed.rest.slice(1);
316+
if (parsed.globals.help) hostTokens.push('--help');
317+
return await runHostCommand(hostTokens, io);
318+
}
308319

309-
if (parsed.rest[0] === 'install' && !parsed.globals.help) {
310-
return await runInstall(parsed.rest.slice(1), io);
311-
}
320+
if (parsed.rest[0] === 'install' && !parsed.globals.help) {
321+
return await runInstall(parsed.rest.slice(1), io);
322+
}
312323

313-
if (parsed.rest[0] === 'uninstall' && !parsed.globals.help) {
314-
return await runUninstall(parsed.rest.slice(1), io);
315-
}
324+
if (parsed.rest[0] === 'uninstall' && !parsed.globals.help) {
325+
return await runUninstall(parsed.rest.slice(1), io);
326+
}
316327

317-
if (parsed.rest[0] === 'call' && outputMode !== 'json') {
318-
throw new CliError('INVALID_ARGUMENT', 'call: only --output json is supported.');
319-
}
328+
if (parsed.rest[0] === 'call' && outputMode !== 'json') {
329+
throw new CliError('INVALID_ARGUMENT', 'call: only --output json is supported.');
330+
}
320331

321-
if (!parsed.globals.help) {
322-
const legacyCompat = await tryRunLegacyCompatCommand(argv, parsed.rest, io);
323-
if (legacyCompat.handled) {
324-
return legacyCompat.exitCode;
332+
if (!parsed.globals.help) {
333+
const legacyCompat = await tryRunLegacyCompatCommand(argv, parsed.rest, io);
334+
if (legacyCompat.handled) {
335+
return legacyCompat.exitCode;
336+
}
337+
}
338+
339+
const output = await executeParsedInvocation(parsed, io, 'oneshot');
340+
if (output.helpText) {
341+
io.stdout(output.helpText);
342+
return 0;
343+
}
344+
if (!output.execution) {
345+
throw new CliError('COMMAND_FAILED', 'Command produced no execution result and no help text.');
325346
}
326-
}
327347

328-
const output = await executeParsedInvocation(parsed, io, 'oneshot');
329-
if (output.helpText) {
330-
io.stdout(output.helpText);
348+
const elapsedMs = io.now() - startedAt;
349+
writeSuccess(io, outputMode, output.execution, elapsedMs);
331350
return 0;
351+
} catch (error) {
352+
const cliError = toCliError(error);
353+
const elapsedMs = io.now() - startedAt;
354+
writeFailure(io, outputMode, cliError, elapsedMs);
355+
return cliError.exitCode;
332356
}
333-
if (!output.execution) {
334-
throw new CliError('COMMAND_FAILED', 'Command produced no execution result and no help text.');
335-
}
336-
337-
const elapsedMs = io.now() - startedAt;
338-
writeSuccess(io, outputMode, output.execution, elapsedMs);
339-
return 0;
340-
} catch (error) {
341-
const cliError = toCliError(error);
342-
const elapsedMs = io.now() - startedAt;
343-
writeFailure(io, outputMode, cliError, elapsedMs);
344-
return cliError.exitCode;
345-
}
357+
});
346358
}
347359

348360
if (import.meta.main) {

apps/cli/src/lib/context.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks';
12
import type { Dirent } from 'node:fs';
23
import { copyFile, mkdir, open, readdir, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
34
import { createHash } from 'node:crypto';
@@ -13,6 +14,7 @@ const CONTEXT_VERSION = 'v1';
1314
const ACTIVE_SESSION_FILENAME = 'active-session';
1415
const DEFAULT_LOCK_TIMEOUT_MS = 5_000;
1516
const LOCK_RETRY_INTERVAL_MS = 50;
17+
const STATE_DIR_OVERRIDE_STORAGE = new AsyncLocalStorage<string>();
1618

1719
export type SourceSnapshot = {
1820
mtimeMs: number;
@@ -74,6 +76,11 @@ type LockMetadata = {
7476
};
7577

7678
function getStateRoot(): string {
79+
const scopedOverride = STATE_DIR_OVERRIDE_STORAGE.getStore();
80+
if (scopedOverride && scopedOverride.length > 0) {
81+
return scopedOverride;
82+
}
83+
7784
const override = process.env.SUPERDOC_CLI_STATE_DIR;
7885
if (override && override.length > 0) {
7986
return resolve(override);
@@ -82,6 +89,14 @@ function getStateRoot(): string {
8289
return join(homedir(), '.superdoc-cli', 'state', CONTEXT_VERSION);
8390
}
8491

92+
export async function withStateDirOverride<T>(stateDir: string | undefined, operation: () => Promise<T>): Promise<T> {
93+
if (stateDir == null || stateDir.length === 0) {
94+
return operation();
95+
}
96+
97+
return STATE_DIR_OVERRIDE_STORAGE.run(resolve(stateDir), operation);
98+
}
99+
85100
export function getContextPaths(contextId: string): ContextPaths {
86101
const normalizedContextId = validateSessionId(contextId, 'session id');
87102
const stateRoot = getStateRoot();

0 commit comments

Comments
 (0)