From b2ee612a27f34e8602fe65bd9414822ae5d5e53f Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Nov 2026 12:30:35 +0300 Subject: [PATCH] feat: a bare patchright-cli shows the open sessions and what to run next The no-argument run printed the whole manual. It now prints the home view: the live sessions of this workspace with their mode, profile, tab count, current page and lifetime, then the next commands (or how to open a browser when none is). --json gives the same as data; --help is still the manual. It starts nothing, asks each daemon for tab-list with a 1.5 s budget, deletes no stale session file and does not touch the idle timer. Idea from AhaiMk01/patchright-cli's home view. --- CHANGELOG.md | 1 + README.md | 4 +- skills/patchright-cli/SKILL.md | 6 + src/commands/home.ts | 219 ++++++++++++++++++++++++++++ src/program.ts | 13 +- tests/integration/isolation.spec.ts | 21 +++ tests/unit/home.spec.ts | 103 +++++++++++++ 7 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 src/commands/home.ts create mode 100644 tests/unit/home.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0727692..e5b8c6c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project are documented here. The format follows [Kee - Headless sessions keep Chrome's scrollbars: Playwright's `--hide-scrollbars` made them 0 px wide where Chrome on Windows draws 15. ### Added +- A bare `patchright-cli` prints the home view instead of the manual: the live sessions of this workspace (mode, profile, tab count, current page, lifetime) and the commands to run next, or how to open a browser when none is; `--json` gives the same as data and `--help` is still the manual. It starts nothing, gives each daemon 1.5 s to answer and does not count as activity for the idle timeout. - Named profiles: `open --profile=` keeps an identity under the state root (`named-profiles/`) that any session in any workspace opens by name; a value with a path separator is still a directory. `open` refuses a profile another live session holds and names it, instead of Chrome failing on the locked profile. `profile-list` shows each profile's size, identity and holder; `profile-delete ` removes one and is refused while it is held. - `url`, `title`, `text [target]`, `wait ` / `wait --url=`, `wait-for --state=...` and `scroll-to `: the reads and waits agents otherwise write as `eval`/`run-code` every time. Each is a fixed Playwright snippet sent through `run-code`, so element reads stay in the isolated world, and prints only the value. - `cookie-import ` takes what people actually have: a cookie-editor browser extension export (Cookie-Editor, EditThisCookie), a Netscape `cookies.txt`, a Playwright storage state or cookie array; `cookie-export [file]` writes JSON or `--format=netscape`, owner-only. Both take `--domain`. Cookie values never appear in the output. diff --git a/README.md b/README.md index 5ca0063b..0540635d 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ Warnings go to stderr as `[patchright-cli] LeakWarning(): ...`, or into `wa A session nobody comes back to closes itself. The daemon is upstream code and keeps its browser until something sends `close`; an agent that opens a session, finishes and exits never does, and every leaked session keeps a Chrome running at full rate (microsoft/playwright-cli#460). So `open` starts a small watchdog next to the daemon: inside an agent harness (Codex, Claude Code, Copilot, Cursor, Gemini, Aider are recognised from their environment markers) the browser closes after 30 minutes without a command, and as soon as the agent process exits when the harness exposes its pid (`CLAUDE_PID`, or `PATCHRIGHT_CLI_OWNER_PID` for any harness). `--idle-timeout=2h` (or `idleTimeout` in the config) changes the limit, `--idle-timeout=0` and `--no-owner-pid` keep the browser open, `--owner-pid=` ties it to another process. Outside a harness nothing changes unless asked. The persistent profile survives, so the next `open` is still logged in; `list` shows each session's lifetime and `.watchdog.log` in the state directory says why a browser went away. `kill-all` also stops watchdogs. +A bare `patchright-cli` prints the home view instead of the manual: the live sessions of this workspace (mode, profile, tab count, current page, lifetime) and the next commands, or how to open a browser when none is. It starts nothing, gives each daemon 1.5 s to answer and does not count as activity for the idle timeout; `--json` returns the same as data, `--help` is the manual. + `-s=` (or `PATCHRIGHT_CLI_SESSION`) selects a session: its own daemon, Chrome window, persistent profile, identity and proxy. `identity` shows what a profile presents; `delete-data` discards it; `--isolated` opens a throwaway profile. Details: [skills/patchright-cli/references/identity.md](skills/patchright-cli/references/identity.md). `open` states the launch facts once, on its first line: browser and build, mode, profile, and the user agent when it is not the browser's own (`### Browser \`x\` opened with pid N: chrome 152, headless, profile ud-x-chrome, user agent Chrome/152 (headed name of this build)`). The executable path appears only for a non-standard browser (the bundled fallback, a configured `executablePath`); `--json` carries all of it as `launch`. Notes on `open` are one line of facts each, and no later command repeats them; paths are on demand in `list`, `identity`, `doctor` and `env`. @@ -144,7 +146,7 @@ When the default state root is read-only, point it at the workspace: `PATCHRIGHT Core: `open attach close detach goto type click dblclick fill drag drop hover select upload check uncheck snapshot find eval dialog-accept dialog-dismiss resize run-code delete-data` · Navigation: `go-back go-forward reload` · Keyboard: `press keydown keyup` · Mouse: `mousemove mousedown mouseup mousewheel` · Save as: `screenshot pdf` · Tabs: `tab-list tab-new tab-close tab-select` · Storage: `state-load state-save cookie-* localstorage-* sessionstorage-*` · Network: `requests request request-headers request-body response-headers response-body route route-list unroute network-state-set` · Diagnostics: `console tracing-start tracing-stop video-* show generate-locator highlight config-print` · Stealth and environment: `env batch wait-for-user identity selftest doctor` · Sessions: `list close-all kill-all install install-browser`. -`patchright-cli --help` and `patchright-cli --help` are always current; the skill at [skills/patchright-cli/SKILL.md](skills/patchright-cli/SKILL.md) documents everything for agents. +`patchright-cli` alone shows what is open; `patchright-cli --help` and `patchright-cli --help` are always current; the skill at [skills/patchright-cli/SKILL.md](skills/patchright-cli/SKILL.md) documents everything for agents. ## Configuration diff --git a/skills/patchright-cli/SKILL.md b/skills/patchright-cli/SKILL.md index 0df1bf54..eb75754f 100644 --- a/skills/patchright-cli/SKILL.md +++ b/skills/patchright-cli/SKILL.md @@ -43,8 +43,12 @@ Once per session, before the first browser command: ```bash patchright-cli env +# what is already open in this workspace: sessions, their current page, what to run next +patchright-cli ``` +A bare `patchright-cli` is the home view, not the manual (that is `--help`). It lists the live sessions of this workspace with their mode, profile, tab count, current page and lifetime, then the next commands; `--json` gives the same as data. It starts nothing and does not keep an idle session alive. When the browser you need is already open there, continue it with its `-s=` instead of opening another one. + It prints the shell it detected, the console encoding, whether a display exists, the host locale and timezone, the agent host and sandbox markers, whether the state root is writable, and the quoting rules that apply here (`--json` for a structured version, `--probe` also checks network access). Follow those rules for every later command; the CLI never changes the console settings itself. If a `find` or `eval` unexpectedly reports nothing, or a `[msys-path-conversion]` warning appears, the shell mangled the argument: fix the invocation, do not conclude the page lacks the content. Sandboxes, permission modes that confirm every command (`batch` runs a whole flow as one approval), a read-only home, and the security rules for profiles, credentials and `run-code` are in [references/environments.md](references/environments.md); read it when `env` reports a sandbox or a limit. A denied permission or a sandbox limit is the user's decision: report it and ask, do not work around it. @@ -330,6 +334,8 @@ patchright-cli doctor ### Sessions and setup ```bash +# home view: live sessions of this workspace and the next commands +patchright-cli patchright-cli list patchright-cli close-all patchright-cli kill-all diff --git a/src/commands/home.ts b/src/commands/home.ts new file mode 100644 index 00000000..1dee4f8c --- /dev/null +++ b/src/commands/home.ts @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2026 b1zya (https://github.com/b1zya/patchright-cli). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// The home view: what a bare `patchright-cli` prints instead of the manual (that is `--help`). +// An agent arriving in a workspace learns in a few lines which browsers are already open here, +// what each one shows and what to run next, so it continues a session instead of opening a +// second one. It is read-only and quick: it starts nothing, deletes no stale session file, +// does not count as activity for a session's idle timer, and gives each daemon a short budget. + +import path from 'path'; + +import { readState } from '../humanize/state'; +import { describeLifetime } from '../lifetime'; +import { namedProfilesDir } from '../paths'; +import { clientKey } from '../registry'; +import { Session } from '../session'; + +import type { ClientInfo, Registry, SessionFile } from '../registry'; + +export type HomeTab = { index: number, title: string, url: string }; + +export type HomeSession = { + name: string; + // open: answered `tab-list`; busy: did not answer in time; incompatible: started by a newer + // client than this one, which refuses to drive it (`open` restarts it). + status: 'open' | 'busy' | 'incompatible'; + headed?: boolean; + attached: boolean; + browser?: string; + // The named profile (`open --profile=`) the session runs on, if any. + profile?: string; + tabs?: number; + current?: HomeTab; + lifetime?: string; +}; + +export type HomeHint = { command: string, why: string }; + +export type HomeData = { + version: string; + core: string; + workspace: string | null; + skill?: string; + sessions: HomeSession[]; + next: HomeHint[]; +}; + +// One daemon gets this long to answer `tab-list`; all of them are asked at once. +export const homeProbeTimeoutMs = 1500; + +// `- 1: (current) [Title](https://example.com/)`, as the daemon's raw tab-list prints it. +const tabLine = /^- (\d+): (\(current\) )?\[(.*)\]\((.*)\)$/; + +export function parseTabList(text: string): { tabs: number, current?: HomeTab } { + let tabs = 0; + let current: HomeTab | undefined; + for (const line of text.split(/\r?\n/)) { + const match = line.match(tabLine); + if (!match) + continue; + tabs++; + if (match[2]) + current = { index: Number(match[1]), title: match[3], url: match[4] }; + } + return { tabs, current }; +} + +function profileOf(userDataDir: string | undefined): string | undefined { + if (!userDataDir) + return undefined; + const relative = path.relative(namedProfilesDir(), userDataDir); + return relative && !relative.startsWith('..') && !path.isAbsolute(relative) && !relative.includes(path.sep) ? relative : undefined; +} + +function withTimeout(promise: Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise(resolve => { timer = setTimeout(() => resolve(undefined), ms); }); + return Promise.race([promise.catch(() => undefined), timeout]).finally(() => clearTimeout(timer)); +} + +export type HomeProbe = (entry: SessionFile) => Promise<{ alive: boolean, tabList?: string }>; + +// Connects to the daemon and asks for its tabs. A daemon that does not accept the connection +// is gone (its session file is stale); one that accepts but does not answer in time is busy. +export function daemonProbe(clientInfo: ClientInfo, timeoutMs = homeProbeTimeoutMs): HomeProbe { + return async entry => { + const session = new Session(entry); + if (!await withTimeout(session.canConnect(), timeoutMs)) + return { alive: false }; + if (!session.isCompatible(clientInfo)) + return { alive: true }; + const result = await withTimeout(session.run(clientInfo, { _: ['tab-list'] }, { raw: true }), timeoutMs); + return { alive: true, tabList: result && !result.isError ? result.text : undefined }; + }; +} + +export async function collectHomeSessions(registry: Registry, clientInfo: ClientInfo, probe: HomeProbe): Promise { + const entries = registry.entryMap().get(clientKey(clientInfo)) ?? []; + const sessions = await Promise.all(entries.map(async (entry): Promise => { + const { alive, tabList } = await probe(entry); + if (!alive) + return undefined; + const config = entry.config; + const compatible = new Session(entry).isCompatible(clientInfo); + const lifetime = readState(entry.daemonDir, config.name).lifetime; + const session: HomeSession = { + name: config.name, + status: !compatible ? 'incompatible' : tabList === undefined ? 'busy' : 'open', + headed: config.browser ? !config.browser.launchOptions.headless : undefined, + attached: !!config.attached, + browser: config.browser?.launchOptions.channel ?? config.browser?.browserName, + profile: profileOf(config.browser?.userDataDir), + lifetime: lifetime ? describeLifetime(lifetime) : undefined, + }; + if (tabList !== undefined) + Object.assign(session, parseTabList(tabList)); + return session; + })); + return sessions.filter((s): s is HomeSession => !!s).sort((a, b) => a.name === 'default' ? -1 : b.name === 'default' ? 1 : a.name.localeCompare(b.name)); +} + +function sessionFlag(name: string): string { + return name === 'default' ? '' : ` -s=${name}`; +} + +export function homeHints(sessions: HomeSession[]): HomeHint[] { + const hints: HomeHint[] = []; + const first = sessions.find(s => s.status === 'open'); + if (first) { + hints.push({ command: `patchright-cli${sessionFlag(first.name)} snapshot`, why: 'element refs of the current page' }); + if (sessions.length > 1) + hints.push({ command: 'patchright-cli -s= ', why: 'act in another session' }); + } else if (!sessions.length) { + hints.push({ command: 'patchright-cli open ', why: 'headless Chrome with this workspace\'s identity; --headed for a window' }); + hints.push({ command: 'patchright-cli env', why: 'shell quoting, display and rules for this machine' }); + } + const incompatible = sessions.find(s => s.status === 'incompatible'); + if (incompatible) + hints.push({ command: `patchright-cli${sessionFlag(incompatible.name)} open`, why: `restart '${incompatible.name}', started by a newer patchright-cli` }); + hints.push({ command: 'patchright-cli list --all', why: 'sessions in every workspace' }); + hints.push({ command: 'patchright-cli --help', why: 'all commands; --help for one' }); + return hints; +} + +const maxUrl = 80; + +function shorten(text: string, max: number): string { + // ASCII on purpose: a legacy Windows console code page has no ellipsis. + return text.length > max ? text.slice(0, max - 3) + '...' : text; +} + +function describeSession(session: HomeSession): string[] { + const parts: string[] = []; + if (session.attached) + parts.push(`attached ${session.browser ?? 'browser'}`); + else + parts.push(`${session.headed ? 'headed' : 'headless'} ${session.browser ?? 'chrome'}`); + if (session.profile) + parts.push(`profile ${session.profile}`); + if (session.status === 'incompatible') { + parts.push('started by a newer patchright-cli; open restarts it'); + } else if (session.status === 'busy') { + parts.push(`busy (no answer within ${homeProbeTimeoutMs / 1000} s)`); + } else { + parts.push(`${session.tabs} tab${session.tabs === 1 ? '' : 's'}`); + if (session.current) { + const title = session.current.title ? ` "${shorten(session.current.title, 60)}"` : ''; + parts.push(`current${title} ${shorten(session.current.url, maxUrl)}`); + } + } + const lines = [`- ${session.name}: ${parts.join(', ')}`]; + if (session.lifetime) + lines.push(` ${session.lifetime}`); + return lines; +} + +export function renderHome(data: HomeData): string { + const lines = [`patchright-cli ${data.version} (patchright-core ${data.core}) - anti-detection browser automation for AI agents`]; + if (data.skill) + lines.push(`Agent skill: ${data.skill}`); + lines.push(''); + if (data.sessions.length) { + lines.push(`Browsers open in this workspace (${data.sessions.length}):`); + for (const session of data.sessions) + lines.push(...describeSession(session)); + } else { + lines.push('No browser open in this workspace.'); + } + lines.push('', 'Next:'); + const width = Math.max(...data.next.map(h => h.command.length)); + for (const hint of data.next) + lines.push(` ${hint.command.padEnd(width)} ${hint.why}`); + return lines.join('\n'); +} + +export async function collectHome(options: { registry: Registry, clientInfo: ClientInfo, version: string, core: string, skill?: string, probe?: HomeProbe }): Promise { + const sessions = await collectHomeSessions(options.registry, options.clientInfo, options.probe ?? daemonProbe(options.clientInfo)); + return { + version: options.version, + core: options.core, + workspace: options.clientInfo.workspaceDir ?? null, + skill: options.skill, + sessions, + next: homeHints(sessions), + }; +} diff --git a/src/program.ts b/src/program.ts index 0a1806f0..e6320eb7 100644 --- a/src/program.ts +++ b/src/program.ts @@ -34,6 +34,7 @@ import { Session } from './session'; import { coreBundle, corePackageJSON, dashboardAppPath, serverRegistry, utilsBundle } from './core'; import { loadUserConfig } from './config/load'; import { applyCoreEnvInProcess, coreProcessEnv } from './daemonEnv'; +import { collectHome, renderHome } from './commands/home'; import { assertProfileFree } from './commands/profiles'; import { loadHelp } from './help'; import { killAllDaemons } from './killAll'; @@ -125,13 +126,21 @@ export async function program(options?: { embedderVersion?: string }) { } const command = commandName && help.commands[commandName]; + const agentSkill = process.env.CLAUDECODE || process.env.COPILOT_CLI ? path.relative(process.cwd(), path.join(bundledSkillDir(), 'SKILL.md')) : undefined; + // A bare run is the home view (src/commands/home.ts): live sessions and what to run next. + if (!commandName && !args.help && !args.h) { + const home = await collectHome({ registry: await Registry.load(), clientInfo, version: options?.embedderVersion ?? clientInfo.version, core: corePackageJSON.version, skill: agentSkill }); + output.toolResult(output.json ? JSON.stringify(home, null, 2) : renderHome(home)); + // eslint-disable-next-line no-restricted-syntax + processExit(0); + } if (args.help || args.h || !commandName) { if (command) { output.help(command.help); } else { const lines = [`patchright-cli - anti-detection browser automation for AI agents (${corePackageJSON.name} ${corePackageJSON.version})`]; - if (process.env.CLAUDECODE || process.env.COPILOT_CLI) - lines.push(`Agent skill: ${path.relative(process.cwd(), path.join(bundledSkillDir(), 'SKILL.md'))}`); + if (agentSkill) + lines.push(`Agent skill: ${agentSkill}`); lines.push(help.global); output.help(lines.join('\n\n')); } diff --git a/tests/integration/isolation.spec.ts b/tests/integration/isolation.spec.ts index 215ed25d..a4d6c11a 100644 --- a/tests/integration/isolation.spec.ts +++ b/tests/integration/isolation.spec.ts @@ -77,6 +77,27 @@ test('help and version are branded', async ({}) => { expect(version).toEqual({ version: expect.any(String), core: { name: 'patchright-core', version: expect.any(String) } }); }); +test('a bare run shows the live sessions of this workspace and what to run next', async ({}) => { + const empty = await runCli([]); + expect(empty.exitCode).toBe(0); + expect(empty.output).toContain('No browser open in this workspace.'); + expect(empty.output).toContain('patchright-cli open '); + expect(empty.output).not.toContain('Usage:'); + + expect(await runCli(['-s=work', 'open', 'data:text/html,Home testhi'])).toEqual(expect.objectContaining({ exitCode: 0 })); + const home = await runCli([]); + expect(home.output).toContain('Browsers open in this workspace (1):'); + expect(home.output).toMatch(/- work: headless chrome, 1 tab, current "Home test" data:text\/html/); + expect(home.output).toContain('patchright-cli -s=work snapshot'); + + const data = parseJson(await runCli(['--json'])); + expect(data.sessions).toEqual([expect.objectContaining({ name: 'work', status: 'open', tabs: 1, current: expect.objectContaining({ title: 'Home test' }) })]); + + // The manual is still one flag away. + expect((await runCli(['--help'])).output).toContain('Usage:'); + expect(await runCli(['-s=work', 'close'])).toEqual(expect.objectContaining({ exitCode: 0 })); +}); + test('env reports the shell, encoding, display and rules without a browser', async ({}) => { const result = await runCli(['env', '--json'], { MSYSTEM: 'MINGW64', CI: '1' }); expect(result.exitCode, result.error).toBe(0); diff --git a/tests/unit/home.spec.ts b/tests/unit/home.spec.ts new file mode 100644 index 00000000..cd3d95d0 --- /dev/null +++ b/tests/unit/home.spec.ts @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2026 b1zya (https://github.com/b1zya/patchright-cli). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import { test, expect } from 'patchright/test'; + +import { collectHome, homeHints, parseTabList, renderHome } from '../../src/commands/home'; +import { namedProfilesDir } from '../../src/paths'; + +import type { HomeProbe } from '../../src/commands/home'; +import type { ClientInfo, Registry, SessionFile } from '../../src/registry'; + +const clientInfo = { version: '1.64.3', workspaceDirHash: 'h', daemonProfilesDir: '/d', workspaceDir: '/ws', homeDir: '/home' } as ClientInfo; + +function entry(name: string, options: { version?: string, headless?: boolean, userDataDir?: string, attached?: boolean } = {}): SessionFile { + return { + file: '', + daemonDir: path.join('/nonexistent', name), + config: { + name, + version: options.version ?? '1.64.3', + attached: options.attached, + cli: {}, + browser: { browserName: 'chromium', launchOptions: { channel: 'chrome', headless: options.headless ?? true }, userDataDir: options.userDataDir }, + }, + } as unknown as SessionFile; +} + +function registryOf(byWorkspace: Record): Registry { + return { entryMap: () => new Map(Object.entries(byWorkspace)) } as unknown as Registry; +} + +test('parses the daemon\'s raw tab-list, current tab included', () => { + expect(parseTabList('- 0: [Example Domain](https://example.com/)\n- 1: (current) [A [nested] title](about:blank)')).toEqual({ + tabs: 2, + current: { index: 1, title: 'A [nested] title', url: 'about:blank' }, + }); + expect(parseTabList('### Result\r\n- 0: (current) [](data:text/html,x)\r\n')).toEqual({ tabs: 1, current: { index: 0, title: '', url: 'data:text/html,x' } }); + expect(parseTabList('')).toEqual({ tabs: 0, current: undefined }); +}); + +test('lists only live sessions of this workspace, default first, and says why one could not be read', async () => { + const registry = registryOf({ + '/ws': [ + entry('zeta', { headless: false, userDataDir: path.join(namedProfilesDir(), 'work') }), + entry('gone'), + entry('default'), + entry('stuck'), + entry('old', { version: '99.0.0' }), + ], + '/elsewhere': [entry('foreign')], + }); + const asked: string[] = []; + const probe: HomeProbe = async e => { + asked.push(e.config.name); + if (e.config.name === 'gone') + return { alive: false }; + if (e.config.name === 'stuck' || e.config.name === 'old') + return { alive: true }; + return { alive: true, tabList: `- 0: (current) [${e.config.name}](https://${e.config.name}.test/)` }; + }; + const home = await collectHome({ registry, clientInfo, version: '0.3.0', core: '1.64.3', probe }); + + expect(asked.sort()).toEqual(['default', 'gone', 'old', 'stuck', 'zeta']); + expect(home.sessions.map(s => [s.name, s.status])).toEqual([['default', 'open'], ['old', 'incompatible'], ['stuck', 'busy'], ['zeta', 'open']]); + expect(home.sessions.find(s => s.name === 'zeta')).toEqual(expect.objectContaining({ headed: true, profile: 'work', tabs: 1, current: { index: 0, title: 'zeta', url: 'https://zeta.test/' } })); + expect(home.workspace).toBe('/ws'); + + const text = renderHome(home); + expect(text).toContain('Browsers open in this workspace (4):'); + expect(text).toContain('- default: headless chrome, 1 tab, current "default" https://default.test/'); + expect(text).toContain('- zeta: headed chrome, profile work, 1 tab'); + expect(text).toContain('- old: headless chrome, started by a newer patchright-cli; open restarts it'); + expect(text).toContain('- stuck: headless chrome, busy (no answer within 1.5 s)'); + expect(text).toContain('patchright-cli -s=old open'); + expect(text).not.toContain('foreign'); +}); + +test('with nothing open the next step is to open a browser', async () => { + const home = await collectHome({ registry: registryOf({}), clientInfo, version: '0.3.0', core: '1.64.3', probe: async () => ({ alive: false }) }); + expect(home.sessions).toEqual([]); + expect(home.next.map(h => h.command)).toEqual(['patchright-cli open ', 'patchright-cli env', 'patchright-cli list --all', 'patchright-cli --help']); + expect(renderHome(home)).toContain('No browser open in this workspace.'); +}); + +test('the snapshot hint names the session it applies to', () => { + const open = { name: 'work', status: 'open' as const, attached: false }; + expect(homeHints([open])[0].command).toBe('patchright-cli -s=work snapshot'); + expect(homeHints([{ ...open, name: 'default' }, open])[0].command).toBe('patchright-cli snapshot'); +});