mirror of
https://github.com/steel-dev/cli.git
synced 2026-09-14 20:47:34 +08:00
1725 lines
46 KiB
TypeScript
1725 lines
46 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import {
|
|
type Mock,
|
|
vi,
|
|
describe,
|
|
test,
|
|
expect,
|
|
beforeEach,
|
|
afterEach,
|
|
} from 'vitest';
|
|
import {getLocalBrowserRepoPath} from '../../source/utils/dev/local';
|
|
|
|
type BrowserLifecycleModule =
|
|
typeof import('../../source/utils/browser/lifecycle');
|
|
type SessionState = {
|
|
activeSessionId: string | null;
|
|
activeSessionMode: 'cloud' | 'local' | null;
|
|
activeSessionName: string | null;
|
|
namedSessions: {
|
|
cloud: Record<string, string>;
|
|
local: Record<string, string>;
|
|
};
|
|
updatedAt: string | null;
|
|
};
|
|
|
|
function createJsonResponse(status: number, payload: unknown): Response {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: status >= 200 && status < 300 ? 'OK' : 'Error',
|
|
text: async () => JSON.stringify(payload),
|
|
} as Response;
|
|
}
|
|
|
|
function createTempConfigDirectory(): string {
|
|
return fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'steel-browser-lifecycle-test-'),
|
|
);
|
|
}
|
|
|
|
function readSessionState(configDirectory: string): SessionState {
|
|
const statePath = path.join(configDirectory, 'browser-session-state.json');
|
|
const raw = fs.readFileSync(statePath, 'utf-8');
|
|
return JSON.parse(raw) as SessionState;
|
|
}
|
|
|
|
function writeSessionState(configDirectory: string, state: SessionState): void {
|
|
const statePath = path.join(configDirectory, 'browser-session-state.json');
|
|
fs.mkdirSync(configDirectory, {recursive: true});
|
|
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
}
|
|
|
|
async function loadBrowserLifecycle(
|
|
configDirectory: string,
|
|
): Promise<BrowserLifecycleModule> {
|
|
process.env.STEEL_CONFIG_DIR = configDirectory;
|
|
vi.resetModules();
|
|
return import('../../source/utils/browser/lifecycle');
|
|
}
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
let fetchMock: Mock<typeof fetch>;
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn() as Mock<typeof fetch>;
|
|
globalThis.fetch = fetchMock;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
delete process.env.STEEL_CONFIG_DIR;
|
|
});
|
|
|
|
describe('browser lifecycle passthrough bootstrap parsing', () => {
|
|
test('extracts bootstrap options and keeps runtime args', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--session',
|
|
'daily',
|
|
'--stealth',
|
|
'--proxy',
|
|
'http://proxy.local:8080',
|
|
'--wait-for',
|
|
'load',
|
|
]);
|
|
|
|
expect(parsed.options).toEqual({
|
|
local: false,
|
|
apiUrl: null,
|
|
sessionName: 'daily',
|
|
stealth: true,
|
|
proxyUrl: 'http://proxy.local:8080',
|
|
timeoutMs: null,
|
|
headless: false,
|
|
region: null,
|
|
solveCaptcha: false,
|
|
autoConnect: false,
|
|
cdpTarget: null,
|
|
profileName: null,
|
|
updateProfile: false,
|
|
namespace: null,
|
|
credentials: false,
|
|
});
|
|
expect(parsed.passthroughArgv).toEqual([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--wait-for',
|
|
'load',
|
|
]);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('throws for invalid bootstrap flag values', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags(['open', '--session']),
|
|
).toThrow('Missing value for --session.');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags(['open', '--proxy']),
|
|
).toThrow('Missing value for --proxy.');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--session-timeout',
|
|
]),
|
|
).toThrow('Missing value for --session-timeout.');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--session-timeout',
|
|
'nope',
|
|
]),
|
|
).toThrow('Invalid value for --session-timeout');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--session-headless=true',
|
|
]),
|
|
).toThrow('`--session-headless` does not accept a value.');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--session-region',
|
|
]),
|
|
).toThrow('Missing value for --session-region.');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--session-solve-captcha=true',
|
|
]),
|
|
).toThrow('`--session-solve-captcha` does not accept a value.');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags(['open', '--cdp']),
|
|
).toThrow('Missing value for --cdp.');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags(['open', '--api-url']),
|
|
).toThrow('Missing value for --api-url.');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--api-url',
|
|
'not-a-url',
|
|
]),
|
|
).toThrow('Invalid value for --api-url');
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--auto-connect=ws://localhost:9222',
|
|
]),
|
|
).toThrow('`--auto-connect` does not accept a value.');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('parses explicit api-url without forcing local bootstrap mode', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--api-url',
|
|
'https://steel.local.dev/v1/',
|
|
'--session',
|
|
'daily',
|
|
]);
|
|
|
|
expect(parsed.options).toEqual({
|
|
local: false,
|
|
apiUrl: 'https://steel.local.dev/v1',
|
|
sessionName: 'daily',
|
|
stealth: false,
|
|
proxyUrl: null,
|
|
timeoutMs: null,
|
|
headless: false,
|
|
region: null,
|
|
solveCaptcha: false,
|
|
autoConnect: false,
|
|
cdpTarget: null,
|
|
profileName: null,
|
|
updateProfile: false,
|
|
namespace: null,
|
|
credentials: false,
|
|
});
|
|
expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('parses session create config flags for bootstrap', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--session-timeout',
|
|
'60000',
|
|
'--session-headless',
|
|
'--session-region',
|
|
'us-west-2',
|
|
'--session-solve-captcha',
|
|
'--wait-for',
|
|
'load',
|
|
]);
|
|
|
|
expect(parsed.options).toEqual({
|
|
local: false,
|
|
apiUrl: null,
|
|
sessionName: null,
|
|
stealth: false,
|
|
proxyUrl: null,
|
|
timeoutMs: 60000,
|
|
headless: true,
|
|
region: 'us-west-2',
|
|
solveCaptcha: true,
|
|
autoConnect: false,
|
|
cdpTarget: null,
|
|
profileName: null,
|
|
updateProfile: false,
|
|
namespace: null,
|
|
credentials: false,
|
|
});
|
|
expect(parsed.passthroughArgv).toEqual([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--wait-for',
|
|
'load',
|
|
]);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('allows positional args after boolean bootstrap flags', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--session-headless',
|
|
'https://steel.dev',
|
|
'--session-solve-captcha',
|
|
'--wait-for',
|
|
'load',
|
|
]);
|
|
|
|
expect(parsed.options).toEqual({
|
|
local: false,
|
|
apiUrl: null,
|
|
sessionName: null,
|
|
stealth: false,
|
|
proxyUrl: null,
|
|
timeoutMs: null,
|
|
headless: true,
|
|
region: null,
|
|
solveCaptcha: true,
|
|
autoConnect: false,
|
|
cdpTarget: null,
|
|
profileName: null,
|
|
updateProfile: false,
|
|
namespace: null,
|
|
credentials: false,
|
|
});
|
|
expect(parsed.passthroughArgv).toEqual([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--wait-for',
|
|
'load',
|
|
]);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('keeps explicit auto-connect untouched', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const result = await lifecycle.bootstrapBrowserPassthroughArgv(
|
|
['open', 'https://steel.dev', '--auto-connect'],
|
|
{STEEL_API_KEY: 'env-api-key'},
|
|
);
|
|
|
|
expect(result.argv).toEqual([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--auto-connect',
|
|
]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('keeps explicit cdp target untouched', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const result = await lifecycle.bootstrapBrowserPassthroughArgv(
|
|
['open', 'https://steel.dev', '--cdp', 'ws://localhost:9222'],
|
|
{STEEL_API_KEY: 'env-api-key'},
|
|
);
|
|
|
|
expect(result.argv).toEqual([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--cdp',
|
|
'ws://localhost:9222',
|
|
]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('rejects mixed explicit attach flags', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--auto-connect',
|
|
'--cdp',
|
|
'ws://localhost:9222',
|
|
]),
|
|
).toThrow('Cannot combine `--auto-connect` with `--cdp`.');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('skips bootstrap for help output', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const result = await lifecycle.bootstrapBrowserPassthroughArgv(
|
|
['open', '--help'],
|
|
{STEEL_API_KEY: 'env-api-key'},
|
|
);
|
|
|
|
expect(result.argv).toEqual(['open', '--help']);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('browser lifecycle session contract', () => {
|
|
test('creates cloud session and persists active state', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-created',
|
|
status: 'live',
|
|
websocketUrl: 'wss://connect.steel.dev/session-created',
|
|
viewerUrl: 'https://app.steel.dev/sessions/session-created',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const session = await lifecycle.startBrowserSession({
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(session).toMatchObject({
|
|
id: 'session-created',
|
|
mode: 'cloud',
|
|
live: true,
|
|
connectUrl:
|
|
'wss://connect.steel.dev/session-created?apiKey=env-api-key',
|
|
viewerUrl: 'https://app.steel.dev/sessions/session-created',
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Steel-Api-Key': 'env-api-key',
|
|
},
|
|
body: '{}',
|
|
});
|
|
|
|
const state = readSessionState(configDirectory);
|
|
expect(state.activeSessionMode).toBe('cloud');
|
|
expect(state.activeSessionId).toBe('session-created');
|
|
expect(state.activeSessionName).toBeNull();
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('maps session config fields into create payload', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-configured',
|
|
status: 'live',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
await lifecycle.startBrowserSession({
|
|
timeoutMs: 45_000,
|
|
headless: true,
|
|
region: 'us-east-1',
|
|
solveCaptcha: true,
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
timeout: 45000,
|
|
headless: true,
|
|
region: 'us-east-1',
|
|
solveCaptcha: true,
|
|
}),
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('does not hold the state lock while waiting for start-session API', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
const lockPath = path.join(
|
|
configDirectory,
|
|
'browser-session-state.json.lock',
|
|
);
|
|
let resolveFetch: ((value: Response) => void) | null = null;
|
|
|
|
try {
|
|
fetchMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise<Response>(resolve => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const sessionPromise = lifecycle.startBrowserSession({
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
if (fetchMock.mock.calls.length > 0) {
|
|
break;
|
|
}
|
|
await new Promise(resolve => {
|
|
setTimeout(resolve, 5);
|
|
});
|
|
}
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fs.existsSync(lockPath)).toBe(false);
|
|
expect(resolveFetch).not.toBeNull();
|
|
|
|
resolveFetch?.(
|
|
createJsonResponse(201, {
|
|
id: 'session-slow',
|
|
status: 'live',
|
|
websocketUrl: 'wss://connect.steel.dev/session-slow',
|
|
}),
|
|
);
|
|
|
|
const session = await sessionPromise;
|
|
expect(session.id).toBe('session-slow');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('creates local session when explicit api-url is provided', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-local',
|
|
status: 'live',
|
|
websocketUrl: 'ws://localhost:9222/devtools/browser/session-local',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const session = await lifecycle.startBrowserSession({
|
|
apiUrl: 'https://steel.self-hosted.dev/v1/',
|
|
environment: {
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
});
|
|
|
|
expect(session.mode).toBe('local');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://steel.self-hosted.dev/v1/sessions',
|
|
);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('treats explicit cloud api-url as cloud mode', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-cloud-explicit-url',
|
|
status: 'live',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const session = await lifecycle.startBrowserSession({
|
|
apiUrl: 'https://api.steel.dev/v1/',
|
|
environment: {
|
|
STEEL_API_KEY: 'env-api-key',
|
|
},
|
|
});
|
|
|
|
expect(session.mode).toBe('cloud');
|
|
expect(session.connectUrl).toBe(
|
|
'wss://connect.steel.dev?apiKey=env-api-key&sessionId=session-cloud-explicit-url',
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions',
|
|
);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('uses STEEL_BROWSER_API_URL before STEEL_LOCAL_API_URL in local mode', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(createJsonResponse(200, {sessions: []}));
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
await lifecycle.listBrowserSessions({
|
|
local: true,
|
|
environment: {
|
|
STEEL_BROWSER_API_URL: 'https://preferred.local/v1',
|
|
STEEL_LOCAL_API_URL: 'https://legacy.local/v1',
|
|
},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://preferred.local/v1/sessions',
|
|
);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('uses config browser.apiUrl when local env vars are absent', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
const configPath = path.join(configDirectory, 'config.json');
|
|
|
|
try {
|
|
fs.mkdirSync(configDirectory, {recursive: true});
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify(
|
|
{
|
|
browser: {
|
|
apiUrl: 'https://configured.local/v1/',
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf-8',
|
|
);
|
|
|
|
fetchMock.mockResolvedValueOnce(createJsonResponse(200, {sessions: []}));
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
await lifecycle.listBrowserSessions({
|
|
local: true,
|
|
environment: {
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://configured.local/v1/sessions',
|
|
);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('reattaches a named live session instead of creating a new one', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-daily',
|
|
status: 'live',
|
|
websocketUrl: 'wss://connect.steel.dev/session-daily',
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
createJsonResponse(200, {
|
|
id: 'session-daily',
|
|
status: 'live',
|
|
websocketUrl: 'wss://connect.steel.dev/session-daily',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
const firstSession = await lifecycle.startBrowserSession({
|
|
sessionName: 'daily',
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
const secondSession = await lifecycle.startBrowserSession({
|
|
sessionName: 'daily',
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(firstSession.id).toBe('session-daily');
|
|
expect(secondSession.id).toBe('session-daily');
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('POST');
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions/session-daily',
|
|
);
|
|
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe('GET');
|
|
|
|
const state = readSessionState(configDirectory);
|
|
expect(state.namedSessions.cloud).toEqual({
|
|
daily: 'session-daily',
|
|
});
|
|
expect(state.activeSessionId).toBe('session-daily');
|
|
expect(state.activeSessionName).toBe('daily');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('injects cdp URL with create-on-first-action bootstrap', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-bootstrap',
|
|
status: 'live',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const passthroughArgv = await lifecycle.bootstrapBrowserPassthroughArgv(
|
|
[
|
|
'open',
|
|
'https://steel.dev',
|
|
'--session',
|
|
'daily',
|
|
'--stealth',
|
|
'--proxy',
|
|
'http://proxy.local:8080',
|
|
],
|
|
{
|
|
STEEL_API_KEY: 'env-api-key',
|
|
},
|
|
);
|
|
|
|
expect(passthroughArgv.argv).toEqual([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--cdp',
|
|
'wss://connect.steel.dev?apiKey=env-api-key&sessionId=session-bootstrap',
|
|
]);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
proxyUrl: 'http://proxy.local:8080',
|
|
stealthConfig: {
|
|
humanizeInteractions: true,
|
|
autoCaptchaSolving: true,
|
|
},
|
|
solveCaptcha: true,
|
|
}),
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('injects cloud cdp URL when explicit cloud api-url is provided', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-bootstrap-cloud-url',
|
|
status: 'live',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const passthroughArgv = await lifecycle.bootstrapBrowserPassthroughArgv(
|
|
[
|
|
'open',
|
|
'https://steel.dev',
|
|
'--api-url',
|
|
'https://api.steel.dev/v1',
|
|
'--session',
|
|
'daily',
|
|
],
|
|
{
|
|
STEEL_API_KEY: 'env-api-key',
|
|
},
|
|
);
|
|
|
|
expect(passthroughArgv.argv).toEqual([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--cdp',
|
|
'wss://connect.steel.dev?apiKey=env-api-key&sessionId=session-bootstrap-cloud-url',
|
|
]);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions',
|
|
);
|
|
|
|
const state = readSessionState(configDirectory);
|
|
expect(state.activeSessionMode).toBe('cloud');
|
|
expect(state.activeSessionId).toBe('session-bootstrap-cloud-url');
|
|
expect(state.activeSessionName).toBe('daily');
|
|
expect(state.namedSessions.cloud).toEqual({
|
|
daily: 'session-bootstrap-cloud-url',
|
|
});
|
|
expect(state.namedSessions.local).toEqual({});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('injects cdp URL and maps session config bootstrap flags', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-bootstrap-config',
|
|
status: 'live',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const passthroughArgv = await lifecycle.bootstrapBrowserPassthroughArgv(
|
|
[
|
|
'open',
|
|
'https://steel.dev',
|
|
'--session-timeout',
|
|
'120000',
|
|
'--session-headless',
|
|
'--session-region',
|
|
'us-west-2',
|
|
'--session-solve-captcha',
|
|
],
|
|
{
|
|
STEEL_API_KEY: 'env-api-key',
|
|
},
|
|
);
|
|
|
|
expect(passthroughArgv.argv).toEqual([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--cdp',
|
|
'wss://connect.steel.dev?apiKey=env-api-key&sessionId=session-bootstrap-config',
|
|
]);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
timeout: 120000,
|
|
headless: true,
|
|
region: 'us-west-2',
|
|
solveCaptcha: true,
|
|
}),
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('fails bootstrap when mapped named session is dead', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: null,
|
|
activeSessionMode: null,
|
|
activeSessionName: null,
|
|
namedSessions: {
|
|
cloud: {
|
|
daily: 'session-dead',
|
|
},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(200, {
|
|
id: 'session-dead',
|
|
status: 'terminated',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
await expect(
|
|
lifecycle.bootstrapBrowserPassthroughArgv(
|
|
['open', 'https://steel.dev', '--session', 'daily'],
|
|
{
|
|
STEEL_API_KEY: 'env-api-key',
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'SESSION_NOT_FOUND',
|
|
message: expect.stringContaining(
|
|
'Run `steel browser start --session daily`',
|
|
),
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions/session-dead',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('GET');
|
|
|
|
const state = readSessionState(configDirectory);
|
|
expect(state.namedSessions.cloud).toEqual({
|
|
daily: 'session-dead',
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('propagates API failures while checking mapped named sessions', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: null,
|
|
activeSessionMode: null,
|
|
activeSessionName: null,
|
|
namedSessions: {
|
|
cloud: {
|
|
daily: 'session-unavailable',
|
|
},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(503, {
|
|
message: 'Temporary unavailable',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
await expect(
|
|
lifecycle.bootstrapBrowserPassthroughArgv(
|
|
['open', 'https://steel.dev', '--session', 'daily'],
|
|
{
|
|
STEEL_API_KEY: 'env-api-key',
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'API_ERROR',
|
|
message: expect.stringContaining('(503)'),
|
|
});
|
|
|
|
const state = readSessionState(configDirectory);
|
|
expect(state.namedSessions.cloud).toEqual({
|
|
daily: 'session-unavailable',
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('fails bootstrap when active session is dead', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: 'session-dead',
|
|
activeSessionMode: 'cloud',
|
|
activeSessionName: null,
|
|
namedSessions: {
|
|
cloud: {},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(200, {
|
|
id: 'session-dead',
|
|
status: 'terminated',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
await expect(
|
|
lifecycle.bootstrapBrowserPassthroughArgv(
|
|
['open', 'https://steel.dev'],
|
|
{
|
|
STEEL_API_KEY: 'env-api-key',
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'SESSION_NOT_FOUND',
|
|
message: expect.stringContaining('Run `steel browser start`'),
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions/session-dead',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('GET');
|
|
|
|
const state = readSessionState(configDirectory);
|
|
expect(state.activeSessionMode).toBe('cloud');
|
|
expect(state.activeSessionId).toBe('session-dead');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('returns missing-auth error before API calls when no auth is present', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
await expect(
|
|
lifecycle.startBrowserSession({
|
|
environment: {
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'MISSING_AUTH',
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('shows install guidance for localhost local mode when runtime is missing', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'));
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
await expect(
|
|
lifecycle.startBrowserSession({
|
|
local: true,
|
|
environment: {
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'API_ERROR',
|
|
message: expect.stringContaining('steel dev install'),
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('shows start guidance for localhost local mode when runtime is installed', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
const repoPath = getLocalBrowserRepoPath(configDirectory);
|
|
|
|
try {
|
|
fs.mkdirSync(repoPath, {recursive: true});
|
|
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'));
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
await expect(
|
|
lifecycle.startBrowserSession({
|
|
local: true,
|
|
environment: {
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'API_ERROR',
|
|
message: expect.stringContaining('steel dev start'),
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('stops all live sessions and clears active cloud state', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: 'session-a',
|
|
activeSessionMode: 'cloud',
|
|
activeSessionName: 'daily',
|
|
namedSessions: {
|
|
cloud: {
|
|
daily: 'session-a',
|
|
old: 'session-z',
|
|
},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
createJsonResponse(200, {
|
|
sessions: [
|
|
{id: 'session-a', status: 'live'},
|
|
{id: 'session-b', status: 'terminated'},
|
|
{id: 'session-c', status: 'active'},
|
|
],
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(createJsonResponse(200, {ok: true}))
|
|
.mockResolvedValueOnce(createJsonResponse(200, {ok: true}));
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const result = await lifecycle.stopBrowserSession({
|
|
all: true,
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
mode: 'cloud',
|
|
all: true,
|
|
stoppedSessionIds: ['session-a', 'session-c'],
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions/session-a/release',
|
|
);
|
|
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions/session-c/release',
|
|
);
|
|
|
|
const state = readSessionState(configDirectory);
|
|
expect(state.activeSessionMode).toBeNull();
|
|
expect(state.activeSessionId).toBeNull();
|
|
expect(state.namedSessions.cloud).toEqual({
|
|
old: 'session-z',
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('stops a named mapped session when --session is provided', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: 'session-active',
|
|
activeSessionMode: 'cloud',
|
|
activeSessionName: 'active',
|
|
namedSessions: {
|
|
cloud: {
|
|
active: 'session-active',
|
|
daily: 'session-daily',
|
|
},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce(createJsonResponse(200, {ok: true}));
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const result = await lifecycle.stopBrowserSession({
|
|
sessionName: 'daily',
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
mode: 'cloud',
|
|
all: false,
|
|
stoppedSessionIds: ['session-daily'],
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions/session-daily/release',
|
|
);
|
|
|
|
const state = readSessionState(configDirectory);
|
|
expect(state.activeSessionMode).toBe('cloud');
|
|
expect(state.activeSessionId).toBe('session-active');
|
|
expect(state.namedSessions.cloud).toEqual({
|
|
active: 'session-active',
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('returns fallback cloud live URL for the active session', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: 'session-live',
|
|
activeSessionMode: 'cloud',
|
|
activeSessionName: null,
|
|
namedSessions: {
|
|
cloud: {},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(200, {
|
|
id: 'session-live',
|
|
status: 'live',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const liveUrl = await lifecycle.getActiveBrowserLiveUrl({
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(liveUrl).toBe('https://app.steel.dev/sessions/session-live');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('returns live URL for a named session when requested', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: null,
|
|
activeSessionMode: null,
|
|
activeSessionName: null,
|
|
namedSessions: {
|
|
cloud: {
|
|
daily: 'session-live-named',
|
|
},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(200, {
|
|
id: 'session-live-named',
|
|
status: 'live',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const liveUrl = await lifecycle.getActiveBrowserLiveUrl({
|
|
sessionName: 'daily',
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(liveUrl).toBe('https://app.steel.dev/sessions/session-live-named');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions/session-live-named',
|
|
);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('manually solves captchas for the active session', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: 'session-captcha',
|
|
activeSessionMode: 'cloud',
|
|
activeSessionName: null,
|
|
namedSessions: {
|
|
cloud: {},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(200, {
|
|
success: true,
|
|
message: 'CAPTCHA solve requested',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const result = await lifecycle.solveBrowserSessionCaptcha({
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
mode: 'cloud',
|
|
sessionId: 'session-captcha',
|
|
success: true,
|
|
message: 'CAPTCHA solve requested',
|
|
raw: {
|
|
success: true,
|
|
message: 'CAPTCHA solve requested',
|
|
},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'https://api.steel.dev/v1/sessions/session-captcha/captchas/solve',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: 'POST',
|
|
body: '{}',
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('manually solves a targeted captcha for a named session', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
writeSessionState(configDirectory, {
|
|
activeSessionId: null,
|
|
activeSessionMode: null,
|
|
activeSessionName: null,
|
|
namedSessions: {
|
|
cloud: {
|
|
daily: 'session-daily',
|
|
},
|
|
local: {},
|
|
},
|
|
updatedAt: null,
|
|
});
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(200, {
|
|
success: true,
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
await lifecycle.solveBrowserSessionCaptcha({
|
|
sessionName: 'daily',
|
|
pageId: 'page-123',
|
|
taskId: 'task-789',
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
pageId: 'page-123',
|
|
taskId: 'task-789',
|
|
}),
|
|
});
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('throws when no target session is available for captcha solving', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
await expect(
|
|
lifecycle.solveBrowserSessionCaptcha({
|
|
environment: {STEEL_API_KEY: 'env-api-key'},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'SESSION_NOT_FOUND',
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('browser lifecycle --profile flag parsing', () => {
|
|
test('parses --profile flag into profileName', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--profile',
|
|
'myapp',
|
|
]);
|
|
|
|
expect(parsed.options.profileName).toBe('myapp');
|
|
expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('parses --profile= (equals form) into profileName', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--profile=myapp',
|
|
]);
|
|
|
|
expect(parsed.options.profileName).toBe('myapp');
|
|
expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('throws when --profile has no value', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags(['open', '--profile']),
|
|
).toThrow('Missing value for --profile.');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('throws when --profile value contains a path separator', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
|
|
expect(() =>
|
|
lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--profile',
|
|
'/tmp/my-profile',
|
|
]),
|
|
).toThrow('Invalid profile name');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('parses --update-profile flag', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'https://steel.dev',
|
|
'--profile',
|
|
'myapp',
|
|
'--update-profile',
|
|
]);
|
|
|
|
expect(parsed.options.profileName).toBe('myapp');
|
|
expect(parsed.options.updateProfile).toBe(true);
|
|
expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('updateProfile defaults to false', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'--profile',
|
|
'myapp',
|
|
]);
|
|
|
|
expect(parsed.options.updateProfile).toBe(false);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('profileName defaults to null when STEEL_PROFILE is not set', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
delete process.env['STEEL_PROFILE'];
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([
|
|
'open',
|
|
'https://steel.dev',
|
|
]);
|
|
|
|
expect(parsed.options.profileName).toBeNull();
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('browser lifecycle session profile contract', () => {
|
|
test('creates new session with persistProfile when no profile file exists', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-new-profile',
|
|
status: 'live',
|
|
profileId: 'returned-profile-uuid',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const session = await lifecycle.startBrowserSession({
|
|
profileName: 'myapp',
|
|
updateProfile: true,
|
|
environment: {
|
|
STEEL_API_KEY: 'env-api-key',
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
});
|
|
|
|
expect(session.id).toBe('session-new-profile');
|
|
|
|
// Should pass persistProfile: true but no profileId
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const body = JSON.parse(
|
|
fetchMock.mock.calls[0]?.[1]?.body as string,
|
|
) as Record<string, unknown>;
|
|
expect(body['persistProfile']).toBe(true);
|
|
expect(body['profileId']).toBeUndefined();
|
|
|
|
// Should write the returned profileId to profiles/myapp.json
|
|
const profileFile = path.join(configDirectory, 'profiles', 'myapp.json');
|
|
expect(fs.existsSync(profileFile)).toBe(true);
|
|
const written = JSON.parse(fs.readFileSync(profileFile, 'utf-8')) as {
|
|
profileId: string;
|
|
};
|
|
expect(written.profileId).toBe('returned-profile-uuid');
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('reuses stored profileId from profile file on subsequent start', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
// Pre-seed profiles/myapp.json with an existing profileId
|
|
const profilesDir = path.join(configDirectory, 'profiles');
|
|
fs.mkdirSync(profilesDir, {recursive: true});
|
|
fs.writeFileSync(
|
|
path.join(profilesDir, 'myapp.json'),
|
|
JSON.stringify({profileId: 'existing-profile-uuid'}),
|
|
'utf-8',
|
|
);
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-with-profile',
|
|
status: 'live',
|
|
profileId: 'existing-profile-uuid',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const session = await lifecycle.startBrowserSession({
|
|
profileName: 'myapp',
|
|
updateProfile: true,
|
|
environment: {
|
|
STEEL_API_KEY: 'env-api-key',
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
});
|
|
|
|
expect(session.id).toBe('session-with-profile');
|
|
|
|
const body = JSON.parse(
|
|
fetchMock.mock.calls[0]?.[1]?.body as string,
|
|
) as Record<string, unknown>;
|
|
expect(body['profileId']).toBe('existing-profile-uuid');
|
|
expect(body['persistProfile']).toBe(true);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('recovers from expired profileId: warns and retries without profileId', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
try {
|
|
const profilesDir = path.join(configDirectory, 'profiles');
|
|
fs.mkdirSync(profilesDir, {recursive: true});
|
|
fs.writeFileSync(
|
|
path.join(profilesDir, 'myapp.json'),
|
|
JSON.stringify({profileId: 'expired-profile-uuid'}),
|
|
'utf-8',
|
|
);
|
|
|
|
// First call: 404 for expired profileId
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
createJsonResponse(404, {
|
|
message: "Profile with ID 'expired-profile-uuid' not found",
|
|
}),
|
|
)
|
|
// Second call: success with new profileId
|
|
.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-recovered',
|
|
status: 'live',
|
|
profileId: 'new-profile-uuid',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const session = await lifecycle.startBrowserSession({
|
|
profileName: 'myapp',
|
|
updateProfile: true,
|
|
environment: {
|
|
STEEL_API_KEY: 'env-api-key',
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
});
|
|
|
|
expect(session.id).toBe('session-recovered');
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Profile not found'),
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const retryBody = JSON.parse(
|
|
fetchMock.mock.calls[1]?.[1]?.body as string,
|
|
) as Record<string, unknown>;
|
|
expect(retryBody['profileId']).toBeUndefined();
|
|
expect(retryBody['persistProfile']).toBe(true);
|
|
|
|
// New profileId should be written to profiles/myapp.json
|
|
const written = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(configDirectory, 'profiles', 'myapp.json'),
|
|
'utf-8',
|
|
),
|
|
) as {profileId: string};
|
|
expect(written.profileId).toBe('new-profile-uuid');
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('does not write profile file when API does not return profileId', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-no-profile-returned',
|
|
status: 'live',
|
|
// no profileId field
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
await lifecycle.startBrowserSession({
|
|
profileName: 'myapp',
|
|
environment: {
|
|
STEEL_API_KEY: 'env-api-key',
|
|
STEEL_CONFIG_DIR: configDirectory,
|
|
},
|
|
});
|
|
|
|
expect(
|
|
fs.existsSync(path.join(configDirectory, 'profiles', 'myapp.json')),
|
|
).toBe(false);
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('bootstrap passthrough passes profileName from --profile flag', async () => {
|
|
const configDirectory = createTempConfigDirectory();
|
|
|
|
try {
|
|
fetchMock.mockResolvedValueOnce(
|
|
createJsonResponse(201, {
|
|
id: 'session-bootstrap-profile',
|
|
status: 'live',
|
|
profileId: 'bootstrap-profile-uuid',
|
|
}),
|
|
);
|
|
|
|
const lifecycle = await loadBrowserLifecycle(configDirectory);
|
|
const result = await lifecycle.bootstrapBrowserPassthroughArgv(
|
|
['open', 'https://steel.dev', '--profile', 'myapp'],
|
|
{STEEL_API_KEY: 'env-api-key', STEEL_CONFIG_DIR: configDirectory},
|
|
);
|
|
|
|
expect(result.argv[0]).toBe('open');
|
|
expect(result.argv).toContain('--cdp');
|
|
|
|
const body = JSON.parse(
|
|
fetchMock.mock.calls[0]?.[1]?.body as string,
|
|
) as Record<string, unknown>;
|
|
// --profile without --update-profile should not persist
|
|
expect(body['persistProfile']).toBeFalsy();
|
|
} finally {
|
|
fs.rmSync(configDirectory, {recursive: true, force: true});
|
|
}
|
|
});
|
|
});
|