mirror of
https://github.com/steel-dev/cli.git
synced 2026-09-14 20:47:34 +08:00
408 lines
10 KiB
TypeScript
408 lines
10 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import {spawnSync} from 'node:child_process';
|
|
import {fileURLToPath} from 'node:url';
|
|
import {test} from 'vitest';
|
|
|
|
export type CommandResult = {
|
|
status: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
output: string;
|
|
};
|
|
|
|
const DEFAULT_COMMAND_TIMEOUT_MS = 90_000;
|
|
const SLOWEST_STEP_COUNT = 5;
|
|
const DEFAULT_TRANSIENT_BROWSER_ERRORS = [
|
|
'daemon may be busy or unresponsive',
|
|
'Target page, context or browser has been closed',
|
|
'net::ERR_ABORTED',
|
|
'Failed to connect: No such file or directory (os error 2)',
|
|
];
|
|
const ANSI_ESCAPE_PATTERN = new RegExp(
|
|
`${String.fromCharCode(27)}\\[[0-9;]*m`,
|
|
'g',
|
|
);
|
|
|
|
type RunBrowserCommandOptions = {
|
|
environment: NodeJS.ProcessEnv;
|
|
projectRoot: string;
|
|
timeoutMs?: number;
|
|
attempts?: number;
|
|
transientErrorPatterns?: string[];
|
|
};
|
|
|
|
export type CloudHarness = {
|
|
apiKey: string | null;
|
|
cloudTest: typeof test;
|
|
projectRoot: string;
|
|
distEntrypoint: string;
|
|
ensureBuilt: () => void;
|
|
createEnvironment: (
|
|
configDirectory: string,
|
|
overrides?: NodeJS.ProcessEnv,
|
|
) => NodeJS.ProcessEnv;
|
|
};
|
|
|
|
type CloudSessionOptions = {
|
|
configDirectoryPrefix: string;
|
|
sessionNamePrefix: string;
|
|
environmentOverrides?: NodeJS.ProcessEnv;
|
|
commandTimeoutMs?: number;
|
|
commandAttempts?: number;
|
|
transientErrorPatterns?: string[];
|
|
cleanupStopBehavior?: 'assert' | 'warn' | 'ignore';
|
|
};
|
|
|
|
export type CloudSessionContext = {
|
|
projectRoot: string;
|
|
configDirectory: string;
|
|
sessionName: string;
|
|
environment: NodeJS.ProcessEnv;
|
|
runCommand: (arguments_: string[]) => CommandResult;
|
|
runStep: (stepName: string, arguments_: string[]) => CommandResult;
|
|
runFailStep: (stepName: string, arguments_: string[]) => CommandResult;
|
|
startSession: (stepName?: string) => CommandResult;
|
|
stopSession: (stepName?: string) => CommandResult;
|
|
};
|
|
|
|
export type LegacyRunBrowserCommand = (
|
|
arguments_: string[],
|
|
environment: NodeJS.ProcessEnv,
|
|
projectRoot: string,
|
|
) => CommandResult;
|
|
|
|
type StepTiming = {
|
|
stepName: string;
|
|
durationMs: number;
|
|
status: number;
|
|
};
|
|
|
|
function isStepProfilingEnabled(): boolean {
|
|
const value =
|
|
process.env.STEEL_INTEGRATION_STEP_PROFILE?.trim().toLowerCase();
|
|
return value === '1' || value === 'true' || value === 'yes';
|
|
}
|
|
|
|
export function runBrowserCommand(
|
|
arguments_: string[],
|
|
options: RunBrowserCommandOptions,
|
|
): CommandResult {
|
|
const attempts = options.attempts || 1;
|
|
let latestResult: CommandResult = {
|
|
status: 1,
|
|
stdout: '',
|
|
stderr: '',
|
|
output: '',
|
|
};
|
|
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
['dist/steel.js', 'browser', ...arguments_],
|
|
{
|
|
cwd: options.projectRoot,
|
|
env: options.environment,
|
|
encoding: 'utf-8',
|
|
timeout: options.timeoutMs || DEFAULT_COMMAND_TIMEOUT_MS,
|
|
killSignal: 'SIGKILL',
|
|
},
|
|
);
|
|
|
|
const stdout = result.stdout || '';
|
|
const stderrParts = [result.stderr || ''];
|
|
|
|
if (result.error) {
|
|
stderrParts.push(`spawn error: ${result.error.message}`);
|
|
}
|
|
|
|
if (result.signal) {
|
|
stderrParts.push(`terminated by signal: ${result.signal}`);
|
|
}
|
|
|
|
const stderr = stderrParts.filter(Boolean).join('\n');
|
|
const output = [stdout, stderr].filter(Boolean).join('\n');
|
|
|
|
latestResult = {
|
|
status: result.status ?? 1,
|
|
stdout,
|
|
stderr,
|
|
output,
|
|
};
|
|
|
|
if (latestResult.status === 0) {
|
|
return latestResult;
|
|
}
|
|
|
|
const transientErrorPatterns =
|
|
options.transientErrorPatterns || DEFAULT_TRANSIENT_BROWSER_ERRORS;
|
|
const hasTransientError = transientErrorPatterns.some(pattern =>
|
|
latestResult.output.includes(pattern),
|
|
);
|
|
if (!hasTransientError) {
|
|
return latestResult;
|
|
}
|
|
}
|
|
|
|
return latestResult;
|
|
}
|
|
|
|
function formatStepFailure(header: string, result: CommandResult): string {
|
|
return [
|
|
header,
|
|
result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : null,
|
|
result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
export function assertSuccessfulStep(
|
|
stepName: string,
|
|
result: CommandResult,
|
|
): void {
|
|
if (result.status === 0) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
formatStepFailure(
|
|
`${stepName} failed with exit code ${result.status}.`,
|
|
result,
|
|
),
|
|
);
|
|
}
|
|
|
|
export function assertFailedStep(
|
|
stepName: string,
|
|
result: CommandResult,
|
|
): void {
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
formatStepFailure(
|
|
`${stepName} unexpectedly succeeded with exit code 0.`,
|
|
result,
|
|
),
|
|
);
|
|
}
|
|
|
|
export function stripAnsi(value: string): string {
|
|
return value.replace(ANSI_ESCAPE_PATTERN, '');
|
|
}
|
|
|
|
export function extractSessionId(commandOutput: string): string {
|
|
const normalizedOutput = stripAnsi(commandOutput);
|
|
const sessionIdMatch = normalizedOutput.match(/(?:^|\n)id:\s*([^\s]+)/);
|
|
if (!sessionIdMatch?.[1]) {
|
|
throw new Error(
|
|
[
|
|
'Could not parse session id from browser start output.',
|
|
normalizedOutput.trim() ? normalizedOutput.trim() : '(empty output)',
|
|
].join('\n'),
|
|
);
|
|
}
|
|
|
|
return sessionIdMatch[1];
|
|
}
|
|
|
|
export function extractRefForText(
|
|
snapshotOutput: string,
|
|
targetText: string,
|
|
): string {
|
|
const targetLower = targetText.toLowerCase();
|
|
|
|
for (const line of snapshotOutput.split('\n')) {
|
|
if (!line.toLowerCase().includes(targetLower)) {
|
|
continue;
|
|
}
|
|
|
|
const refMatch = line.match(/ref=(e\d+)/i);
|
|
if (refMatch?.[1]) {
|
|
return `@${refMatch[1]}`;
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Could not find a ref for "${targetText}" in snapshot output.\n${snapshotOutput}`,
|
|
);
|
|
}
|
|
|
|
export function createLegacyRunBrowserCommand(
|
|
runCommand: (arguments_: string[]) => CommandResult,
|
|
): LegacyRunBrowserCommand {
|
|
return (arguments_, environment, projectRoot) => {
|
|
void environment;
|
|
void projectRoot;
|
|
return runCommand(arguments_);
|
|
};
|
|
}
|
|
|
|
export function createCloudHarness(importMetaUrl: string): CloudHarness {
|
|
const testDirectory = path.dirname(fileURLToPath(importMetaUrl));
|
|
const derivedProjectRoot = path.resolve(testDirectory, '../..');
|
|
const cwdProjectRoot = process.cwd();
|
|
const derivedDistEntrypoint = path.join(derivedProjectRoot, 'dist/steel.js');
|
|
const cwdDistEntrypoint = path.join(cwdProjectRoot, 'dist/steel.js');
|
|
const projectRoot = fs.existsSync(derivedDistEntrypoint)
|
|
? derivedProjectRoot
|
|
: cwdProjectRoot;
|
|
const distEntrypoint = path.join(projectRoot, 'dist/steel.js');
|
|
const apiKey = process.env.STEEL_API_KEY?.trim() || null;
|
|
|
|
return {
|
|
apiKey,
|
|
cloudTest: apiKey ? test : test.skip,
|
|
projectRoot,
|
|
distEntrypoint,
|
|
ensureBuilt() {
|
|
if (!fs.existsSync(distEntrypoint)) {
|
|
throw new Error(
|
|
[
|
|
'dist/steel.js is missing. Run `npm run build` first.',
|
|
`resolved project root: ${projectRoot}`,
|
|
`checked paths: ${derivedDistEntrypoint}, ${cwdDistEntrypoint}`,
|
|
].join('\n'),
|
|
);
|
|
}
|
|
},
|
|
createEnvironment(configDirectory, overrides = {}) {
|
|
return {
|
|
...process.env,
|
|
STEEL_API_KEY: apiKey || '',
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
STEEL_CLI_SKIP_UPDATE_CHECK: 'true',
|
|
FORCE_COLOR: '0',
|
|
NODE_NO_WARNINGS: '1',
|
|
...overrides,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function withCloudSession(
|
|
harness: CloudHarness,
|
|
options: CloudSessionOptions,
|
|
scenario: (context: CloudSessionContext) => Promise<void> | void,
|
|
): Promise<void> {
|
|
harness.ensureBuilt();
|
|
if (!harness.apiKey) {
|
|
throw new Error('Missing STEEL_API_KEY for cloud integration test.');
|
|
}
|
|
|
|
const configDirectory = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), options.configDirectoryPrefix),
|
|
);
|
|
const sessionName = `${options.sessionNamePrefix}-${Date.now()}`;
|
|
const environment = harness.createEnvironment(
|
|
configDirectory,
|
|
options.environmentOverrides,
|
|
);
|
|
const stepProfilingEnabled = isStepProfilingEnabled();
|
|
const stepTimings: StepTiming[] = [];
|
|
let sessionStarted = false;
|
|
|
|
const runCommandWithStepName = (
|
|
stepName: string,
|
|
arguments_: string[],
|
|
): CommandResult => {
|
|
const startedAt = Date.now();
|
|
const result = runBrowserCommand(arguments_, {
|
|
environment,
|
|
projectRoot: harness.projectRoot,
|
|
timeoutMs: options.commandTimeoutMs,
|
|
attempts: options.commandAttempts,
|
|
transientErrorPatterns: options.transientErrorPatterns,
|
|
});
|
|
|
|
if (stepProfilingEnabled) {
|
|
stepTimings.push({
|
|
stepName,
|
|
durationMs: Date.now() - startedAt,
|
|
status: result.status,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const runCommand = (arguments_: string[]): CommandResult => {
|
|
return runCommandWithStepName(
|
|
`browser ${arguments_.join(' ')}`,
|
|
arguments_,
|
|
);
|
|
};
|
|
|
|
const runStep = (stepName: string, arguments_: string[]): CommandResult => {
|
|
const result = runCommandWithStepName(stepName, arguments_);
|
|
assertSuccessfulStep(stepName, result);
|
|
return result;
|
|
};
|
|
|
|
const runFailStep = (
|
|
stepName: string,
|
|
arguments_: string[],
|
|
): CommandResult => {
|
|
const result = runCommandWithStepName(stepName, arguments_);
|
|
assertFailedStep(stepName, result);
|
|
return result;
|
|
};
|
|
|
|
const startSession = (stepName = 'browser start'): CommandResult => {
|
|
const result = runStep(stepName, ['start', '--session', sessionName]);
|
|
sessionStarted = true;
|
|
return result;
|
|
};
|
|
|
|
const stopSession = (stepName = 'browser stop'): CommandResult => {
|
|
return runStep(stepName, ['stop']);
|
|
};
|
|
|
|
try {
|
|
await scenario({
|
|
projectRoot: harness.projectRoot,
|
|
configDirectory,
|
|
sessionName,
|
|
environment,
|
|
runCommand,
|
|
runStep,
|
|
runFailStep,
|
|
startSession,
|
|
stopSession,
|
|
});
|
|
} finally {
|
|
if (sessionStarted) {
|
|
const stopResult = runCommand(['stop']);
|
|
const cleanupStopBehavior = options.cleanupStopBehavior || 'assert';
|
|
|
|
if (cleanupStopBehavior === 'assert') {
|
|
assertSuccessfulStep('browser stop', stopResult);
|
|
} else if (cleanupStopBehavior === 'warn' && stopResult.status !== 0) {
|
|
console.warn(
|
|
`Cleanup warning: browser stop failed with status ${stopResult.status}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (stepProfilingEnabled && stepTimings.length > 0) {
|
|
const slowestSteps = [...stepTimings]
|
|
.sort((left, right) => right.durationMs - left.durationMs)
|
|
.slice(0, SLOWEST_STEP_COUNT);
|
|
|
|
console.log(
|
|
`[integration-step-profile] session=${sessionName} slowest ${slowestSteps.length} step(s):`,
|
|
);
|
|
for (const step of slowestSteps) {
|
|
console.log(
|
|
`- ${step.durationMs}ms | status=${step.status} | ${step.stepName}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
}
|