mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
wave browse-20c099776dd6: merge #3773 (11c7b92a5e)
This commit is contained in:
commit
ffba842714
46 files changed
+1968
-89
No files matched your search
+30
-2
@@ -619,7 +619,7 @@ Storage stays intact by default. With explicit approval on a Chromium target, `-
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `tabs` | List open tabs |
|
||||
| `tabs` | List open tabs, with each owned tab's owner (a pair-agent client or `session:<name>`) |
|
||||
| `tab <id>` | Switch to tab |
|
||||
| `newtab [url] [--json]` | Open new tab; `--json` returns `{tabId, url}` for programmatic use |
|
||||
| `closetab [id]` | Close tab |
|
||||
@@ -634,6 +634,27 @@ in a long script, `status`, `tabs`, `tab <id>`, `newtab`, `closetab`,
|
||||
not responding. A command that needs the stuck tab times out after 30s and
|
||||
names it, so you can switch away or close it without `--force-restart`.
|
||||
|
||||
**Sessions keep parallel agents on their own tabs (#1961).** A call from a
|
||||
known agent harness (Claude Code, Codex and others) gets a session named after
|
||||
that agent process; its first command opens the session's own tab and later
|
||||
commands stay there without moving the shared active tab. Name one with
|
||||
`--session <name>` or `BROWSE_SESSION=<name>`; `BROWSE_SESSION=` turns it off.
|
||||
`--tab-id` and `BROWSE_TAB` win over a session and no longer move the active
|
||||
tab either. Sessions isolate tabs, not cookies or storage, and only the local
|
||||
root token can name one.
|
||||
|
||||
### Phone emulation + touch
|
||||
|
||||
This is Chromium emulating a phone on one tab, not Safari; a real device still goes through `/ios-qa`.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `device [<name> [--landscape] \| off \| --list]` | Emulate a phone on the current tab only (size, pixel ratio, touch, `pointer: coarse`, user agent) from Playwright's devices table; other tabs stay desktop. `off` restores; `viewport` and `responsive` refuse while a device is active |
|
||||
| `tap <@ref\|sel\|x y> [--hold <ms>]` | Touch and release at an element's center or at x y. Needs `device` first |
|
||||
| `swipe <@ref\|sel\|x y> <up\|down\|left\|right> [--distance <px>] [--ms <n>]` | One-finger swipe (default 300px over 300ms). Needs `device` first |
|
||||
| `pinch <@ref\|sel\|x y> <scale>` | Two-finger pinch; scale above 1 zooms in, below 1 zooms out. Needs `device` first |
|
||||
| `touch [down\|move <finger 0-9> <x> <y> \| up <finger> \| cancel]` | Hold, move and lift numbered fingers for multi-finger flows; no action lists held fingers. Needs `device` first |
|
||||
|
||||
### Extraction
|
||||
|
||||
| Command | Description |
|
||||
@@ -1732,6 +1753,9 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a.
|
||||
| `BROWSE_HEADLESS_SKIP` | 0 | Skip Chromium launch entirely (test harness only) |
|
||||
| `BROWSE_TUNNEL` | 0 | Activate the dual-listener tunnel architecture (requires `NGROK_AUTHTOKEN`) |
|
||||
| `BROWSE_TUNNEL_LOCAL_ONLY` | 0 | Test-only — bind both listeners locally without ngrok |
|
||||
| `BROWSE_SESSION` | unset (agent harness calls get `agent-<pid>`) | CLI side: this caller's session name; empty or `off` turns sessions off |
|
||||
| `BROWSE_SESSION_IDLE_MINUTES` | 120 | Daemon side: close (headless) or release (headed) a session's tabs after this many idle minutes; 0 keeps them |
|
||||
| `BROWSE_MAX_SESSIONS` | 16 | Daemon side: most sessions at once; the least recently used idle one is closed to make room |
|
||||
| `CHROMIUM_PROFILE` | unset | Explicit headed Chromium profile directory (used by gbrowser's gbd per-workspace); honored by headed launch and profile-lock cleanup, not used by headless sessions |
|
||||
| `GSTACK_DISABLE_GPU` | unset | On macOS, headless Chromium starts with a GPU-taming flag set (it stops runaway GPU-process spin) that also disables WebGL: `getContext('webgl')`/`'webgl2'` return `null`, so WebGL pages (three.js, Mapbox GL, sigma.js) show their fallback. Set `GSTACK_DISABLE_GPU=off` to enable WebGL (`off` is the only recognized value). |
|
||||
| `GSTACK_BROWSE_MAX_HTML_BYTES` | 52428800 (50MB) | `load-html` size cap |
|
||||
@@ -1776,7 +1800,11 @@ browse/
|
||||
│ ├── cdp-inspector.ts # $B inspect — persistent CDP session per page
|
||||
│ ├── activity.ts # ActivityEntry, CircularBuffer, SSE subscribers, privacy filtering
|
||||
│ ├── buffers.ts # Console/network/dialog circular buffers (O(1) ring)
|
||||
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope)
|
||||
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope, device profile, held fingers)
|
||||
│ ├── session-name.ts # CLI: which session a call belongs to (--session, BROWSE_SESSION, agent harness pid)
|
||||
│ ├── session-tabs.ts # Daemon: per-session and per-tab-id request scopes, idle sweep, session cap
|
||||
│ ├── device-emulation.ts # $B device: per-tab phone emulation over the tab's own CDP session
|
||||
│ ├── touch-commands.ts # $B tap/swipe/pinch/touch via Input.dispatchTouchEvent
|
||||
│ ├── token-registry.ts # Mint/validate/revoke for root + setup keys + scoped tokens
|
||||
│ ├── sse-session-cookie.ts # 30-min HttpOnly cookie for /activity/stream + /inspector/events
|
||||
│ ├── pty-session-cookie.ts # Separate scope: live Claude PTY auth
|
||||
|
||||
@@ -95,7 +95,7 @@ The 73 open feature requests (75 with #3061 and #3122, which the themes below al
|
||||
- **Ship, review and safety (9 issues)** — `/review` shadowing the native command (#422), a silent-deploy-failures checklist section (#2089), Conductor TODO proposals and a /ship block on unresolved ones (#2090), a light-ship mode (#2188), auditing an issue before filing it (#2642), maintainer-gated CI versus failures in landing-report (#2766), Flutter pubspec versions with build metadata (#2833), a deterministic fact-consistency check for document skills (#2887), a dual-engine /careful guard (#2888). **Priority:** P3.
|
||||
- **Planning reviews (8 issues)** — marketplace-aware skill recommendations (#419), founder-signal reasoning in the YC recommendation (#513), grounded verification of paths, math and flags in spec reviews (#973), a substrate-neutral existing-capability check (#1685), `/autoplan --tier` (#1955), autoplan workspace safeguards (#1959), splitting one-way architecture forks from two-way defects (#2490), an evidence ledger for "is demand real?" (#2350). **Priority:** P3.
|
||||
- **State root and project identity (6 issues)** — repo-local portable context artifacts (#1161), design-doc archive and prune (#1536), umbrella directories over several repos (#1923), reclaiming per-repo `.gstack` state (#2627), nested repos declaring themselves projects (#2649), references and disposition on decision events (#2678). **Priority:** P3.
|
||||
- **Browser and mobile QA (6 issues)** — a remote browser via `BROWSER_WS_ENDPOINT` (#974), per-session tab isolation (#1961), an opt-in User-Agent suffix and default headers (#2459), skipping the Aside probe on Linux and Windows (#2864), activity-ring copies on caught-up reads (#2973), touch input and mobile Safari (#3058). **Priority:** P3.
|
||||
- **Browser and mobile QA (5 issues)** — a remote browser via `BROWSER_WS_ENDPOINT` (#974), an opt-in User-Agent suffix and default headers (#2459), skipping the Aside probe on Linux and Windows (#2864), activity-ring copies on caught-up reads (#2973), and mobile Safari, the half of #3058 that Chromium touch emulation (GSTA-227) did not cover. Per-session tabs (#1961) shipped in GSTA-227. **Priority:** P3.
|
||||
- **Prompt cost and skill shape (4 issues)** — a response-header template (#1376), caching preamble output within a session (#1572), the Fable 5 prompting-guide audit (#1994), per-skill benefit in `gstack-context-bill` (#2889). **Priority:** P3.
|
||||
- **Customization and localization (3 issues)** — locale-aware skills (#481), a tutorial on new customization dimensions (#489), a `SKILL.local.md` overlay (#1759). **Priority:** P3.
|
||||
- **Retro (2 issues)** — a team-scoped multi-repo retro (#402), ADR and issue awareness in the narrative (#2284). **Priority:** P3.
|
||||
|
||||
@@ -233,7 +233,6 @@ gstack_result_anchor() {
|
||||
BUN_RESOLUTION_DRIFT) printf '%s' 'bun-resolution-drift' ;;
|
||||
BUN_INSTALL_VERSION_INVALID) printf '%s' 'bun-install-version-invalid' ;;
|
||||
INSTALL_UNVERIFIED) printf '%s' 'install-unverified' ;;
|
||||
BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;;
|
||||
BUN_VERSION_UNREADABLE) printf '%s' 'bun-version-unreadable' ;;
|
||||
BASH_UNAVAILABLE) printf '%s' 'bash-unavailable' ;;
|
||||
IOS_LAUNCH_ENV_INVALID) printf '%s' 'ios-launch-env-invalid' ;;
|
||||
@@ -381,6 +380,14 @@ gstack_result_anchor() {
|
||||
CEO_ONLY_CONFLICT) printf '%s' 'ceo-only-conflict' ;;
|
||||
CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;;
|
||||
CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;;
|
||||
BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;;
|
||||
BROWSE_SESSION_INVALID) printf '%s' 'browse-session-invalid' ;;
|
||||
BROWSE_SESSION_ROOT_ONLY) printf '%s' 'browse-session-root-only' ;;
|
||||
BROWSE_SESSION_TAB_RESET) printf '%s' 'browse-session-tab-reset' ;;
|
||||
BROWSE_SESSION_LIMIT) printf '%s' 'browse-session-limit' ;;
|
||||
BROWSE_DEVICE_UNKNOWN) printf '%s' 'browse-device-unknown' ;;
|
||||
BROWSE_DEVICE_CDP_FAILED) printf '%s' 'browse-device-cdp-failed' ;;
|
||||
BROWSE_TOUCH_NO_DEVICE) printf '%s' 'browse-touch-no-device' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -318,6 +318,17 @@ console.log("ASIDE_DIR=" + pwd); await closeTab(pg); console.log("GSTACK_STEP_OK
|
||||
'
|
||||
```
|
||||
|
||||
**Touch (verified in Aside 1.26; add the Responsive override for phone size):**
|
||||
|
||||
```bash
|
||||
aside repl '
|
||||
const pg = await openTab("<url>"), t = (m, p) => pg._sendToTarget(m, p);
|
||||
await t("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
||||
for (const [type, touchPoints] of [["touchStart", [{ x: <tap-x>, y: <tap-y> }]], ["touchEnd", []]]) await t("Input.dispatchTouchEvent", { type, touchPoints });
|
||||
await closeTab(pg); console.log("GSTACK_STEP_OK");
|
||||
'
|
||||
```
|
||||
|
||||
**Links and their status (same-origin; on a LOCAL target each link is HEAD-checked, on a real site the user's cookies would ride every request so links are listed as `LINK ?` unfetched — consent to LOOK is not consent to hit every URL):**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -95,15 +95,20 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||||
| `cookie-import <json>` | Import cookies from JSON file |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
||||
| `device [<name> [--landscape] | off | --list]` | Emulate a phone on the current tab only: size, pixel ratio, touch support (pointer: coarse) and user agent from Playwright's devices table (names match case-insensitively). Other tabs stay desktop. No name prints the active profile or "none"; --list prints every name; off (or reset, auto) restores. viewport and responsive refuse while a device is active. |
|
||||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
||||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||||
| `fill <sel> <val>` | Fill input |
|
||||
| `header <name>:<value>` | Set custom request header (colon-separated, sensitive values auto-redacted) |
|
||||
| `hover <sel>` | Hover element |
|
||||
| `pinch <@ref|selector|x y> <scale>` | Two-finger pinch around an element's center or x y: scale above 1 spreads the fingers (zoom in), below 1 brings them together. Needs $B device first. |
|
||||
| `press <key>` | Press a Playwright keyboard key against the focused element. Names are case-sensitive: Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown. Modifiers combine with +: Shift+Enter, Control+A, Meta+K. Single printable chars (a, A, 1) work too. Full key list: https://playwright.dev/docs/api/class-keyboard#keyboard-press |
|
||||
| `scroll [sel|@ref]` | With a selector, smooth-scrolls the element into view. Without a selector, jumps to page bottom. No --by/--to amount option; for pixel-precise scrolling use `js window.scrollTo(0, N)`. |
|
||||
| `select <sel> <val>` | Select dropdown option by value, label, or visible text |
|
||||
| `style <sel> <prop> <value> | style --undo [N]` | Modify CSS property on element (with undo support) |
|
||||
| `swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]` | One-finger swipe from an element's center or x y; the finger moves in the given direction (default 300px over 300ms, 10 moves). Needs $B device first. |
|
||||
| `tap <@ref|selector|x y> [--hold <ms>]` | Touch and release at an element's center (scrolled into view) or at x y. Needs $B device first. |
|
||||
| `touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]` | Hold, move and lift numbered fingers for multi-finger flows (hold a joystick with finger 0, tap fire with finger 1). Lifting one finger leaves the others down. No action lists held fingers; cancel releases all. Navigation and device off cancel held fingers. Needs $B device first. |
|
||||
| `type [--selector <sel>] [--] <text>` | Send keystrokes to the focused element, or to <sel> with --selector (focuses it first). Text that starts with -- goes after a -- separator. |
|
||||
| `upload <sel> <file> [file2...]` | Upload file(s) |
|
||||
| `useragent <string>` | Set user agent |
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface BrowseClientOptions {
|
||||
token?: string;
|
||||
/** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */
|
||||
tabId?: number;
|
||||
/** Session whose own tab runs every command (root token only; tabId wins when both are set). Default: none. */
|
||||
session?: string;
|
||||
/** Per-request timeout in milliseconds. Default: 30_000. */
|
||||
timeoutMs?: number;
|
||||
/** Override state-file path. Default: BROWSE_STATE_FILE env or <git-root>/.gstack/browse.json. */
|
||||
@@ -133,6 +135,7 @@ export class BrowseClient {
|
||||
readonly port: number;
|
||||
readonly token: string;
|
||||
readonly tabId?: number;
|
||||
readonly session?: string;
|
||||
readonly timeoutMs: number;
|
||||
|
||||
constructor(opts: BrowseClientOptions = {}) {
|
||||
@@ -140,6 +143,7 @@ export class BrowseClient {
|
||||
this.port = auth.port;
|
||||
this.token = auth.token;
|
||||
this.tabId = opts.tabId ?? parseIntegerEnvValue(process.env.BROWSE_TAB);
|
||||
this.session = opts.session;
|
||||
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
||||
}
|
||||
|
||||
@@ -150,7 +154,7 @@ export class BrowseClient {
|
||||
const body = JSON.stringify({
|
||||
command: cmd,
|
||||
args,
|
||||
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}),
|
||||
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : this.session ? { session: this.session } : {}),
|
||||
});
|
||||
|
||||
let resp: Response;
|
||||
|
||||
+108
-25
@@ -16,18 +16,20 @@
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||
import { emitActivity } from './activity';
|
||||
import { validateNavigationUrl, blockedNavigationReason } from './url-validation';
|
||||
import { TabSession, type RefEntry } from './tab-session';
|
||||
import { TabSession, type DeviceProfile, type RefEntry } from './tab-session';
|
||||
import { resolveChromiumProfile, cleanSingletonLocks, resolveConfig } from './config';
|
||||
import { ensureProjectProfile } from './chromium-profiles';
|
||||
import { launchWithXProtectHeal } from './xprotect-heal';
|
||||
import { DIRECT_PROXY_ARG, forcesDirect } from './proxy-config';
|
||||
import { readPidStartTime, shouldSpawnXvfb, spawnFreeXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
import { applyDevice } from './device-emulation';
|
||||
import { resultLine } from '../../lib/result-codes';
|
||||
import { sanitizeEcho } from '../../lib/sanitize-echo';
|
||||
import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot';
|
||||
@@ -352,9 +354,16 @@ export interface BrowserState {
|
||||
* In-memory only.
|
||||
*/
|
||||
owner?: string;
|
||||
/** `$B device` profile, re-applied before the restored tab's first request. */
|
||||
device?: DeviceProfile;
|
||||
/** The tab's id before the save, so scopes on it follow it to its restored id. In-memory only. */
|
||||
tabId?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The tab a scoped request runs on; owner set for session scopes (#1961). */
|
||||
export interface TabScope { tabId: number; owner?: string; picked?: number }
|
||||
|
||||
export class BrowserManager {
|
||||
private browser: Browser | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
@@ -366,6 +375,14 @@ export class BrowserManager {
|
||||
private pages: Map<number, Page> = new Map();
|
||||
private tabSessions: Map<number, TabSession> = new Map();
|
||||
private activeTabId: number = 0;
|
||||
/**
|
||||
* The tab one request runs on (#1961). A request that names a tab or a
|
||||
* session runs inside this scope, so every getActiveSession() under it, after
|
||||
* any await, resolves to that tab and never moves activeTabId for other
|
||||
* callers. A scope with an owner follows that owner's tab across a context
|
||||
* rebuild, which gives every tab a new id.
|
||||
*/
|
||||
private readonly tabScope = new AsyncLocalStorage<TabScope>();
|
||||
private nextTabId: number = 1;
|
||||
private extraHeaders: Record<string, string> = {};
|
||||
private customUserAgent: string | null = null;
|
||||
@@ -395,6 +412,8 @@ export class BrowserManager {
|
||||
// ─── Tab Ownership (multi-agent isolation) ──────────────
|
||||
// Maps tabId → clientId. Unowned tabs (not in this map) are root-only for writes.
|
||||
private tabOwnership: Map<number, string> = new Map();
|
||||
/** Old tab id → its id after a context rebuild, so every scope on it follows it. */
|
||||
private restoredTabIds = new Map<number, number>();
|
||||
/** Headless launch with BROWSE_EXTENSIONS_DIR: a persistent context (#2281). */
|
||||
private extensionContext = false;
|
||||
/** Pending and recorded navigation-guard verdicts per page (D2). */
|
||||
@@ -1200,19 +1219,29 @@ export class BrowserManager {
|
||||
normalizedUrl = await validateNavigationUrl(url);
|
||||
}
|
||||
|
||||
const activeBefore = this.activeTabId;
|
||||
const page = await this.context.newPage();
|
||||
const id = this.nextTabId++;
|
||||
this.pages.set(id, page);
|
||||
this.tabSessions.set(id, new TabSession(page));
|
||||
this.activeTabId = id;
|
||||
let id = [...this.pages].find(([, p]) => p === page)?.[0];
|
||||
const adopted = id !== undefined;
|
||||
if (id === undefined) {
|
||||
id = this.nextTabId++;
|
||||
this.pages.set(id, page);
|
||||
this.tabSessions.set(id, new TabSession(page));
|
||||
}
|
||||
const scope = this.tabScope.getStore();
|
||||
if (scope) {
|
||||
scope.tabId = id;
|
||||
this.activeTabId = activeBefore;
|
||||
} else this.activeTabId = id;
|
||||
|
||||
// Record tab ownership for multi-agent isolation
|
||||
if (clientId) {
|
||||
this.tabOwnership.set(id, clientId);
|
||||
const owner = clientId ?? scope?.owner;
|
||||
if (owner) {
|
||||
this.tabOwnership.set(id, owner);
|
||||
}
|
||||
|
||||
// Wire up console/network/dialog capture
|
||||
this.wirePageEvents(page);
|
||||
if (!adopted) this.wirePageEvents(page);
|
||||
|
||||
if (normalizedUrl) {
|
||||
await page.goto(normalizedUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
@@ -1222,9 +1251,10 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
async closeTab(id?: number): Promise<void> {
|
||||
const tabId = id ?? this.activeTabId;
|
||||
const tabId = id ?? this.getActiveTabId();
|
||||
const page = this.pages.get(tabId);
|
||||
if (!page) throw new Error(`Tab ${tabId} not found`);
|
||||
const scope = this.tabScope.getStore();
|
||||
|
||||
// Capture BEFORE close(): the page 'close' event handler wired in
|
||||
// wirePageEvents() can fire while page.close() is awaited. It removes
|
||||
@@ -1234,15 +1264,19 @@ export class BrowserManager {
|
||||
// close() resolves varies across Playwright/Chromium versions and
|
||||
// machines, and losing the race means the last-tab auto-create below
|
||||
// never runs, leaving the manager with zero tabs.
|
||||
const wasActive = tabId === this.activeTabId;
|
||||
const wasActive = tabId === this.getActiveTabId();
|
||||
|
||||
await page.close();
|
||||
this.pages.delete(tabId);
|
||||
this.tabSessions.delete(tabId);
|
||||
this.tabOwnership.delete(tabId);
|
||||
|
||||
// Switch to another tab if we closed the active one
|
||||
if (wasActive) {
|
||||
// Switch to another tab if we closed the active one. A scoped request moves
|
||||
// only its own scope, to another tab of the same owner when there is one.
|
||||
if (wasActive && scope && this.pages.size > 0) {
|
||||
const ownedTabs = scope.owner ? this.tabsOwnedBy(scope.owner) : [];
|
||||
if (ownedTabs.length > 0) scope.tabId = ownedTabs[ownedTabs.length - 1]!;
|
||||
} else if (wasActive) {
|
||||
const remaining = [...this.pages.keys()];
|
||||
if (remaining.length === 0) {
|
||||
// No tabs left — create a new blank one
|
||||
@@ -1257,7 +1291,9 @@ export class BrowserManager {
|
||||
|
||||
switchTab(id: number, opts?: { bringToFront?: boolean }): void {
|
||||
if (!this.tabSessions.has(id)) throw new Error(`Tab ${id} not found`);
|
||||
this.activeTabId = id;
|
||||
const scope = this.tabScope.getStore();
|
||||
if (scope) scope.tabId = scope.picked = id;
|
||||
else this.activeTabId = id;
|
||||
// Only bring to front when explicitly requested (user-initiated tab switch).
|
||||
// Internal tab pinning (BROWSE_TAB) should NOT steal focus.
|
||||
if (opts?.bringToFront !== false) {
|
||||
@@ -1311,7 +1347,40 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
getActiveTabId(): number {
|
||||
return this.activeTabId;
|
||||
const scope = this.tabScope.getStore();
|
||||
return scope ? this.resolveScopeTab(scope) : this.activeTabId;
|
||||
}
|
||||
|
||||
/** The scope's tab; an owned scope whose tab is gone (context rebuild) or neither owned nor picked with `tab <id>` moves to its owner's newest tab. */
|
||||
resolveScopeTab(scope: TabScope): number {
|
||||
for (let hops = 0; hops < 64 && !this.pages.has(scope.tabId) && this.restoredTabIds.has(scope.tabId); hops++) {
|
||||
const moved = this.restoredTabIds.get(scope.tabId)!;
|
||||
if (scope.picked === scope.tabId) scope.picked = moved;
|
||||
scope.tabId = moved;
|
||||
}
|
||||
const kept = this.tabOwnership.get(scope.tabId) === scope.owner || scope.picked === scope.tabId;
|
||||
if (scope.owner && !(this.pages.has(scope.tabId) && kept)) {
|
||||
const ownedTabs = this.tabsOwnedBy(scope.owner);
|
||||
if (ownedTabs.length > 0) scope.tabId = ownedTabs[ownedTabs.length - 1]!;
|
||||
}
|
||||
return scope.tabId;
|
||||
}
|
||||
|
||||
tabUrl(tabId: number): string | null {
|
||||
return this.pages.get(tabId)?.url() ?? null;
|
||||
}
|
||||
|
||||
/** Run fn with every active-tab lookup bound to scope.tabId (see tabScope). */
|
||||
runInTab<T>(scope: TabScope, fn: () => Promise<T>): Promise<T> {
|
||||
return this.tabScope.run(scope, fn);
|
||||
}
|
||||
|
||||
hasTab(tabId: number): boolean {
|
||||
return this.pages.has(tabId);
|
||||
}
|
||||
|
||||
tabsOwnedBy(owner: string): number[] {
|
||||
return [...this.tabOwnership].filter(([id, o]) => o === owner && this.pages.has(id)).map(([id]) => id).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
getTabCount(): number {
|
||||
@@ -1388,7 +1457,8 @@ export class BrowserManager {
|
||||
* TAB_PROBE_TIMEOUT_MS: a tab whose renderer is stuck comes back with
|
||||
* `busy: true` and an empty title instead of hanging the list (#3247).
|
||||
*/
|
||||
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean; busy: boolean }>> {
|
||||
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean; busy: boolean; owner: string | null }>> {
|
||||
const activeId = this.getActiveTabId();
|
||||
return Promise.all([...this.pages].map(async ([id, page]) => {
|
||||
const title = await withinTabBudget(
|
||||
this.sharedTabRead(page, 'title', () => page.title().catch(() => '')), TAB_PROBE_TIMEOUT_MS);
|
||||
@@ -1396,8 +1466,9 @@ export class BrowserManager {
|
||||
id,
|
||||
url: page.url(),
|
||||
title: title ?? '',
|
||||
active: id === this.activeTabId,
|
||||
active: id === activeId,
|
||||
busy: title === undefined,
|
||||
owner: this.tabOwnership.get(id) ?? null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
@@ -1413,7 +1484,7 @@ export class BrowserManager {
|
||||
* true when a new tab had to be opened.
|
||||
*/
|
||||
async ensureActiveTab(): Promise<boolean> {
|
||||
if (this.tabSessions.has(this.activeTabId)) return false;
|
||||
if (this.tabSessions.has(this.getActiveTabId())) return false;
|
||||
const remaining = [...this.tabSessions.keys()];
|
||||
if (remaining.length > 0) {
|
||||
this.activeTabId = remaining[remaining.length - 1]!;
|
||||
@@ -1447,7 +1518,7 @@ export class BrowserManager {
|
||||
|
||||
/** Get the TabSession for the active tab. */
|
||||
getActiveSession(): TabSession {
|
||||
const session = this.tabSessions.get(this.activeTabId);
|
||||
const session = this.tabSessions.get(this.getActiveTabId());
|
||||
if (!session) throw new Error('No active page. Use "browse goto <url>" first.');
|
||||
return session;
|
||||
}
|
||||
@@ -1736,13 +1807,20 @@ export class BrowserManager {
|
||||
}
|
||||
if (!this.context) throw new Error('Browser not launched');
|
||||
|
||||
const tabId = this.activeTabId;
|
||||
const tabId = this.getActiveTabId();
|
||||
const pinned = this.getPage();
|
||||
const loaded = this.getActiveSession().getLoadedHtml();
|
||||
const url = pinned.url();
|
||||
const activeBefore = this.activeTabId;
|
||||
const page = await this.context.newPage();
|
||||
const listenerId = [...this.pages].find(([, p]) => p === page)?.[0];
|
||||
if (listenerId !== undefined) {
|
||||
this.pages.delete(listenerId);
|
||||
this.tabSessions.delete(listenerId);
|
||||
}
|
||||
this.activeTabId = activeBefore;
|
||||
const session = new TabSession(page);
|
||||
this.wirePageEvents(page);
|
||||
if (listenerId === undefined) this.wirePageEvents(page);
|
||||
try {
|
||||
if (loaded) await session.setTabContent(loaded.html, { waitUntil: loaded.waitUntil });
|
||||
else if (url !== 'about:blank') await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
@@ -1831,6 +1909,8 @@ export class BrowserManager {
|
||||
loadedHtml: loaded?.html,
|
||||
loadedHtmlWaitUntil: loaded?.waitUntil,
|
||||
owner,
|
||||
device: session?.getDevice() ?? undefined,
|
||||
tabId: id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1887,12 +1967,16 @@ export class BrowserManager {
|
||||
const newSession = new TabSession(page);
|
||||
this.tabSessions.set(id, newSession);
|
||||
this.wirePageEvents(page);
|
||||
if (saved.tabId !== undefined) this.restoredTabIds.set(saved.tabId, id);
|
||||
|
||||
// Restore tab ownership for the new ID — preserves scoped-agent isolation
|
||||
// across context recreation (viewport --scale, user-agent change, handoff).
|
||||
if (saved.owner) {
|
||||
this.tabOwnership.set(id, saved.owner);
|
||||
}
|
||||
if (saved.device) {
|
||||
await applyDevice(newSession, saved.device).catch((err: any) => console.warn(`[browse] Failed to re-apply device for tab ${id}: ${err.message}`));
|
||||
}
|
||||
|
||||
if (saved.loadedHtml) {
|
||||
// Replay load-html content via setTabContent — this rehydrates
|
||||
@@ -1938,11 +2022,10 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
// If no pages were saved, create a blank one
|
||||
if (this.pages.size === 0) {
|
||||
await this.newTab();
|
||||
} else {
|
||||
this.activeTabId = activeId ?? [...this.pages.keys()][0];
|
||||
}
|
||||
if (this.pages.size === 0) await this.newTab();
|
||||
this.activeTabId = activeId ?? [...this.pages.keys()][0];
|
||||
const scope = this.tabScope.getStore();
|
||||
if (scope && !this.pages.has(this.resolveScopeTab(scope))) scope.tabId = scope.picked = this.activeTabId;
|
||||
|
||||
// Clear refs — pages are new, locators are stale
|
||||
this.clearRefs();
|
||||
|
||||
+12
-3
@@ -23,6 +23,7 @@ import { profileOwner, ensureProjectProfile, runProfilesCommand } from './chromi
|
||||
// Zero side effects on import (documented invariant in token-registry.ts) —
|
||||
// safe to pull the shared pairing default into the CLI.
|
||||
import { DEFAULT_PAIR_SCOPES } from './token-registry';
|
||||
import { chooseSession, extractSession, systemProcLookup } from './session-name';
|
||||
|
||||
const config = resolveConfig();
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
@@ -1088,14 +1089,16 @@ export async function commandTimeoutReport(state: { pid: number; port: number }
|
||||
|
||||
// ─── Command Dispatch ──────────────────────────────────────────
|
||||
export async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
|
||||
// Precedence: CLI --tab-id flag > BROWSE_TAB env var.
|
||||
// Precedence: CLI --tab-id flag > BROWSE_TAB env var > --session > BROWSE_SESSION > agent harness.
|
||||
// make-pdf always passes --tab-id; human users typically rely on BROWSE_TAB
|
||||
// or the active tab.
|
||||
const extracted = extractTabId(args);
|
||||
args = extracted.args;
|
||||
const sessionFlag = extractSession(extracted.args);
|
||||
const envTab = process.env.BROWSE_TAB;
|
||||
const tabId = extracted.tabId ?? (envTab ? parseInt(envTab, 10) : undefined);
|
||||
const body = JSON.stringify({ command, args, ...(tabId !== undefined && !isNaN(tabId) ? { tabId } : {}) });
|
||||
const hasTab = tabId !== undefined && !isNaN(tabId);
|
||||
const { session, auto } = chooseSession({ hasTab, flag: sessionFlag.session, env: process.env, parentPid: process.ppid, lookup: systemProcLookup() });
|
||||
const body = JSON.stringify({ command, args: sessionFlag.args, ...(hasTab ? { tabId } : {}), ...(session ? { session, ...(auto ? { sessionAuto: true } : {}) } : {}) });
|
||||
const timeoutMs = command === 'cookie-import-browser' ? 90_000 : 30_000;
|
||||
|
||||
try {
|
||||
@@ -1856,6 +1859,10 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
||||
is <prop> <sel> (visible|hidden|enabled|disabled|checked|editable|focused)
|
||||
Visual: screenshot [--viewport] [--clip x,y,w,h] [@ref|sel] [path]
|
||||
pdf [path] | responsive [prefix]
|
||||
Touch: device ["<phone>" [--landscape] | off | --list] (this tab only)
|
||||
tap <@ref|sel|x y> [--hold <ms>] | pinch <@ref|sel|x y> <scale>
|
||||
swipe <@ref|sel|x y> <up|down|left|right> [--distance <px>] [--ms <n>]
|
||||
touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]
|
||||
Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]
|
||||
-D/--diff: diff against previous snapshot
|
||||
-a/--annotate: annotated screenshot with ref labels
|
||||
@@ -1863,6 +1870,8 @@ Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]
|
||||
Compare: diff <url1> <url2>
|
||||
Multi-step: chain (reads JSON from stdin)
|
||||
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
||||
--session <name> | BROWSE_SESSION=<name>: this caller's own tab
|
||||
(agents get one per agent process; BROWSE_SESSION= turns it off)
|
||||
Server: status | cookie <n>=<v> | header <n>:<v>
|
||||
useragent <str> | stop | restart
|
||||
profiles [list] | profiles prune [--days N] (per-project headed profiles)
|
||||
|
||||
@@ -27,6 +27,7 @@ export const WRITE_COMMANDS = new Set([
|
||||
'upload', 'dialog-accept', 'dialog-dismiss',
|
||||
'style', 'cleanup', 'prettyscreenshot',
|
||||
'download', 'scrape', 'archive',
|
||||
'device', 'tap', 'swipe', 'pinch', 'touch',
|
||||
]);
|
||||
|
||||
export const META_COMMANDS = new Set([
|
||||
@@ -135,6 +136,12 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
|
||||
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
|
||||
'dialog-dismiss': { category: 'Interaction', description: 'Auto-dismiss next dialog' },
|
||||
// Phone emulation and touch (this tab only; gstack's own Chromium)
|
||||
'device': { category: 'Interaction', description: 'Emulate a phone on the current tab only: size, pixel ratio, touch support (pointer: coarse) and user agent from Playwright\'s devices table (names match case-insensitively). Other tabs stay desktop. No name prints the active profile or "none"; --list prints every name; off (or reset, auto) restores. viewport and responsive refuse while a device is active.', usage: 'device [<name> [--landscape] | off | --list]' },
|
||||
'tap': { category: 'Interaction', description: 'Touch and release at an element\'s center (scrolled into view) or at x y. Needs $B device first.', usage: 'tap <@ref|selector|x y> [--hold <ms>]' },
|
||||
'swipe': { category: 'Interaction', description: 'One-finger swipe from an element\'s center or x y; the finger moves in the given direction (default 300px over 300ms, 10 moves). Needs $B device first.', usage: 'swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]' },
|
||||
'pinch': { category: 'Interaction', description: 'Two-finger pinch around an element\'s center or x y: scale above 1 spreads the fingers (zoom in), below 1 brings them together. Needs $B device first.', usage: 'pinch <@ref|selector|x y> <scale>' },
|
||||
'touch': { category: 'Interaction', description: 'Hold, move and lift numbered fingers for multi-finger flows (hold a joystick with finger 0, tap fire with finger 1). Lifting one finger leaves the others down. No action lists held fingers; cancel releases all. Navigation and device off cancel held fingers. Needs $B device first.', usage: 'touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]' },
|
||||
// Data extraction
|
||||
'download': { category: 'Extraction', description: 'Download URL or media element to disk using browser cookies. Default path: <temp>/browse-download-<timestamp>.<ext>. --base64 returns a data:<mime>;base64,... string instead of writing, capped at 10MB. Use --navigate for URLs that trigger browser downloads (CDN redirects, Content-Disposition, anti-bot protected sites).', usage: 'download <url|@ref> [path] [--base64] [--navigate]' },
|
||||
'scrape': { category: 'Extraction', description: 'Bulk download all media from page to --dir (default: <temp>/browse-scrape-<timestamp>). Writes files plus manifest.json with source URL, size, type, success/failure counts. --limit defaults to 50 and caps at 200.', usage: 'scrape <images|videos|media> [--selector sel] [--dir path] [--limit N]' },
|
||||
@@ -231,7 +238,7 @@ export const NEW_IN_VERSION: Record<string, string> = {
|
||||
* Levenshtein distance (dynamic programming).
|
||||
* O(a.length * b.length) — fast for command name sizes (<20 chars).
|
||||
*/
|
||||
function levenshtein(a: string, b: string): number {
|
||||
export function levenshtein(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Phone emulation per tab (`$B device`, #3058). Sizes and user agents come
|
||||
* from Playwright's own `devices` table. Everything is applied through the
|
||||
* tab's own CDP session, never through context options or recreateContext(),
|
||||
* so one agent's phone never resizes another agent's tab.
|
||||
*
|
||||
* device "iPhone 15" ─▶ Emulation.setDeviceMetricsOverride (mobile, screen, orientation)
|
||||
* ─▶ Emulation.setTouchEmulationEnabled (maxTouchPoints 5)
|
||||
* ─▶ Emulation.setUserAgentOverride (UA, platform, UA-CH metadata)
|
||||
* device off ─▶ the three clears, then Playwright's viewport re-applied
|
||||
*
|
||||
* The profile lives on TabSession; BrowserManager.saveState()/restoreState()
|
||||
* carry it across context recreation and re-apply it before the first request.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { devices } from 'playwright';
|
||||
import type { Page } from 'playwright';
|
||||
import type { DeviceProfile, TabSession } from './tab-session';
|
||||
import { getOrCreateCdpSession } from './cdp-bridge';
|
||||
import { levenshtein } from './commands';
|
||||
import { resultLine } from '../../lib/result-codes';
|
||||
|
||||
const DEVICE_NAMES = Object.keys(devices);
|
||||
const cdpSessions: WeakMap<Page, any> = new WeakMap();
|
||||
|
||||
/** The tab's long-lived CDP session; emulation overrides live as long as it does. */
|
||||
export function tabCdpSession(page: Page): Promise<any> {
|
||||
return getOrCreateCdpSession(page, cdpSessions);
|
||||
}
|
||||
|
||||
export function describeDevice(profile: DeviceProfile | null): string {
|
||||
if (!profile) return 'none';
|
||||
return profile.landscape ? `${profile.name} (landscape)` : profile.name;
|
||||
}
|
||||
|
||||
/** Usage error for commands that would fight an active phone profile. */
|
||||
export function refuseWhileDevice(session: TabSession, command: string): void {
|
||||
const profile = session.getDevice();
|
||||
if (!profile) return;
|
||||
throw new Error(
|
||||
`Usage: ${command} cannot change the size of a tab that emulates ${describeDevice(profile)}; the phone profile sets its size.\n` +
|
||||
'fix: $B device off',
|
||||
);
|
||||
}
|
||||
|
||||
function findDevice(name: string): string | null {
|
||||
const lower = name.toLowerCase();
|
||||
return DEVICE_NAMES.find((n) => n.toLowerCase() === lower) ?? null;
|
||||
}
|
||||
|
||||
function nearestDevices(name: string): string[] {
|
||||
const lower = name.toLowerCase();
|
||||
return DEVICE_NAMES
|
||||
.filter((n) => !n.endsWith(' landscape'))
|
||||
.map((n) => ({ n, d: levenshtein(lower, n.toLowerCase()) }))
|
||||
.sort((a, b) => a.d - b.d || a.n.localeCompare(b.n))
|
||||
.slice(0, 3)
|
||||
.map((x) => x.n);
|
||||
}
|
||||
|
||||
function resolveProfile(profile: DeviceProfile) {
|
||||
const name = findDevice(profile.name);
|
||||
if (!name) {
|
||||
throw new Error(resultLine('browse', 'BROWSE_DEVICE_UNKNOWN',
|
||||
`"${profile.name}"; nearest: ${nearestDevices(profile.name).map((n) => `"${n}"`).join(', ')}`));
|
||||
}
|
||||
const landscapeName = profile.landscape ? findDevice(`${name} landscape`) : null;
|
||||
const d = devices[landscapeName ?? name]!;
|
||||
const landscape = profile.landscape || name.endsWith(' landscape');
|
||||
const swap = profile.landscape && !landscapeName;
|
||||
const viewport = swap ? { width: d.viewport.height, height: d.viewport.width } : d.viewport;
|
||||
const screen = (d as { screen?: { width: number; height: number } }).screen ?? d.viewport;
|
||||
return {
|
||||
profile: { name, landscape: profile.landscape },
|
||||
viewport,
|
||||
screen: swap ? { width: screen.height, height: screen.width } : screen,
|
||||
deviceScaleFactor: d.deviceScaleFactor,
|
||||
isMobile: d.isMobile,
|
||||
hasTouch: d.hasTouch,
|
||||
userAgent: d.userAgent,
|
||||
landscape,
|
||||
};
|
||||
}
|
||||
|
||||
function userAgentMetadata(ua: string, mobile: boolean) {
|
||||
const android = ua.match(/Android (\d+(?:\.\d+)*)/);
|
||||
const ios = ua.match(/(?:iPhone|CPU) OS (\d+(?:_\d+)*)/);
|
||||
const chrome = ua.match(/Chrome\/(\d+)\.([\d.]+)/);
|
||||
const platform = android ? 'Android' : ios ? 'iOS' : /Mac OS X/.test(ua) ? 'macOS' : /Windows/.test(ua) ? 'Windows' : 'Linux';
|
||||
return {
|
||||
brands: chrome ? [{ brand: 'Chromium', version: chrome[1]! }, { brand: 'Not.A/Brand', version: '99' }] : [],
|
||||
fullVersion: chrome ? `${chrome[1]}.${chrome[2]}` : '',
|
||||
platform,
|
||||
platformVersion: android ? android[1]! : ios ? ios[1]!.replace(/_/g, '.') : '',
|
||||
architecture: android || ios ? 'arm' : 'x86',
|
||||
model: android ? (ua.match(/Android [\d.]+; ([^;)]+)/)?.[1] ?? '') : '',
|
||||
mobile,
|
||||
};
|
||||
}
|
||||
|
||||
function navigatorPlatform(ua: string): string {
|
||||
if (/iPhone/.test(ua)) return 'iPhone';
|
||||
if (/iPad/.test(ua)) return 'iPad';
|
||||
if (/Android/.test(ua)) return 'Linux armv81';
|
||||
if (/Mac OS X/.test(ua)) return 'MacIntel';
|
||||
return /Windows/.test(ua) ? 'Win32' : 'Linux x86_64';
|
||||
}
|
||||
|
||||
async function sendAll(page: Page, steps: Array<[string, Record<string, unknown>]>, partly: string): Promise<void> {
|
||||
const cdp = await tabCdpSession(page);
|
||||
let done = 0;
|
||||
for (const [method, params] of steps) {
|
||||
try {
|
||||
await cdp.send(method, params);
|
||||
done++;
|
||||
} catch (err: any) {
|
||||
const failure = new Error(resultLine('browse', 'BROWSE_DEVICE_CDP_FAILED',
|
||||
`${method}: ${err?.message ?? err}; ${done === 0 ? 'the tab is unchanged' : partly}`));
|
||||
throw Object.assign(failure, { partial: done > 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate, then emulate the phone on this tab. Throws before touching the browser on an unknown name. */
|
||||
export async function applyDevice(session: TabSession, requested: DeviceProfile): Promise<string> {
|
||||
const r = resolveProfile(requested);
|
||||
const page = session.getPage();
|
||||
try {
|
||||
await sendAll(page, [
|
||||
['Emulation.setDeviceMetricsOverride', {
|
||||
width: r.viewport.width,
|
||||
height: r.viewport.height,
|
||||
deviceScaleFactor: r.deviceScaleFactor,
|
||||
mobile: r.isMobile,
|
||||
screenWidth: r.screen.width,
|
||||
screenHeight: r.screen.height,
|
||||
screenOrientation: r.landscape ? { type: 'landscapePrimary', angle: 90 } : { type: 'portraitPrimary', angle: 0 },
|
||||
}],
|
||||
['Emulation.setTouchEmulationEnabled', { enabled: r.hasTouch, maxTouchPoints: 5 }],
|
||||
['Emulation.setUserAgentOverride', {
|
||||
userAgent: r.userAgent,
|
||||
platform: navigatorPlatform(r.userAgent),
|
||||
userAgentMetadata: userAgentMetadata(r.userAgent, r.isMobile),
|
||||
}],
|
||||
], 'the tab is partly emulated');
|
||||
} catch (err: any) {
|
||||
if (err.partial) session.setDevice(r.profile);
|
||||
throw err;
|
||||
}
|
||||
session.setDevice(r.profile);
|
||||
return `Emulating ${describeDevice(r.profile)} on this tab: ${r.viewport.width}x${r.viewport.height} @${r.deviceScaleFactor}x, ` +
|
||||
`${r.hasTouch ? 'touch on' : 'no touch'}, ${r.isMobile ? 'mobile' : 'desktop'} user agent. Reload to rerun page scripts; $B device off restores.`;
|
||||
}
|
||||
|
||||
/** Drop held fingers and clear the three overrides; Playwright's own viewport comes back. */
|
||||
export async function clearDevice(session: TabSession): Promise<string> {
|
||||
const page = session.getPage();
|
||||
const had = session.getDevice();
|
||||
session.dropFingers();
|
||||
await sendAll(page, [
|
||||
['Emulation.clearDeviceMetricsOverride', {}],
|
||||
['Emulation.setTouchEmulationEnabled', { enabled: false }],
|
||||
['Emulation.setUserAgentOverride', { userAgent: '' }],
|
||||
], 'the tab is partly restored');
|
||||
session.setDevice(null);
|
||||
const viewport = page.viewportSize();
|
||||
if (viewport) await page.setViewportSize(viewport);
|
||||
return had ? `Device emulation off (was ${describeDevice(had)}); desktop size, mouse input and user agent restored` : 'No device was active on this tab';
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright re-sends its own metrics after a full-page capture, which puts the
|
||||
* tab back at desktop size. Chromium skips a repeated identical override from
|
||||
* the same session, so clear ours first, then apply the phone again.
|
||||
*/
|
||||
export async function reassertDevice(session: TabSession): Promise<void> {
|
||||
const profile = session.getDevice();
|
||||
if (!profile) return;
|
||||
await sendAll(session.getPage(), [['Emulation.clearDeviceMetricsOverride', {}]], 'the tab is partly emulated');
|
||||
await applyDevice(session, profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Screenshot that keeps a device tab at phone size. Playwright's own capture
|
||||
* clips to its desktop viewport and then re-sends that viewport, so a device
|
||||
* tab's viewport and full-page captures go through the tab's CDP session
|
||||
* (at the phone's pixel ratio); clip captures go through Playwright. Both
|
||||
* Playwright's capture and a full-page one drop parts of the emulation, so the
|
||||
* phone is put back afterwards.
|
||||
*/
|
||||
export async function screenshotTab(session: TabSession, opts: { path?: string; fullPage?: boolean; clip?: { x: number; y: number; width: number; height: number } }): Promise<Buffer> {
|
||||
const page = session.getPage();
|
||||
if (!session.getDevice()) return page.screenshot(opts);
|
||||
if (opts.clip) {
|
||||
const clipped = await page.screenshot(opts);
|
||||
await reassertDevice(session);
|
||||
return clipped;
|
||||
}
|
||||
const cdp = await tabCdpSession(page);
|
||||
const params: Record<string, unknown> = { format: 'png' };
|
||||
if (opts.fullPage) {
|
||||
const { cssContentSize } = await cdp.send('Page.getLayoutMetrics');
|
||||
params.captureBeyondViewport = true;
|
||||
params.clip = { x: 0, y: 0, width: Math.ceil(cssContentSize.width), height: Math.ceil(cssContentSize.height), scale: 1 };
|
||||
}
|
||||
const { data } = await cdp.send('Page.captureScreenshot', params);
|
||||
if (opts.fullPage) await reassertDevice(session);
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
if (opts.path) fs.writeFileSync(opts.path, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function handleDeviceCommand(args: string[], session: TabSession): Promise<string> {
|
||||
const landscape = args.includes('--landscape');
|
||||
const unknownFlag = args.find((a) => a.startsWith('--') && a !== '--landscape' && a !== '--list');
|
||||
if (unknownFlag) throw new Error(`Usage: unknown device flag ${unknownFlag}.\nfix: $B device <name> [--landscape] | $B device off | $B device --list`);
|
||||
if (args.includes('--list')) return DEVICE_NAMES.join('\n');
|
||||
const name = args.filter((a) => a !== '--landscape').join(' ').trim();
|
||||
if (!name) return describeDevice(session.getDevice());
|
||||
if (['off', 'reset', 'auto'].includes(name.toLowerCase())) return clearDevice(session);
|
||||
const result = await applyDevice(session, { name, landscape });
|
||||
session.dropFingers();
|
||||
return result;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { reassertDevice, refuseWhileDevice, screenshotTab } from './device-emulation';
|
||||
import { resolveConfig } from './config';
|
||||
import { filterSessionCookies } from './session-persist';
|
||||
import type { Frame } from 'playwright';
|
||||
@@ -265,7 +266,7 @@ export async function handleMetaCommand(
|
||||
case 'tabs': {
|
||||
const tabs = await bm.getTabListWithTitles();
|
||||
return tabs.map(t =>
|
||||
`${t.active ? '→ ' : ' '}[${t.id}] ${t.busy ? '(not responding)' : t.title || '(untitled)'} — ${t.url}`
|
||||
`${t.active ? '→ ' : ' '}[${t.id}] ${t.busy ? '(not responding)' : t.title || '(untitled)'} — ${t.url}${t.owner ? ` (${t.owner})` : ''}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
@@ -511,10 +512,11 @@ export async function handleMetaCommand(
|
||||
const resolved = await bm.resolveRef(targetSelector);
|
||||
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
||||
buffer = await locator.screenshot({ timeout: 5000 });
|
||||
await reassertDevice(bm.getActiveSession());
|
||||
} else if (clipRect) {
|
||||
buffer = await page.screenshot({ clip: clipRect });
|
||||
buffer = await screenshotTab(bm.getActiveSession(), { clip: clipRect });
|
||||
} else {
|
||||
buffer = await page.screenshot({ fullPage: !viewportOnly });
|
||||
buffer = await screenshotTab(bm.getActiveSession(), { fullPage: !viewportOnly });
|
||||
// Guard the most common API-bricking case (fullPage). Element /
|
||||
// clip captures usually stay within the cap; we still guard the
|
||||
// path-mode below for fullPage writes.
|
||||
@@ -530,15 +532,16 @@ export async function handleMetaCommand(
|
||||
const resolved = await bm.resolveRef(targetSelector);
|
||||
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
||||
await locator.screenshot({ path: outputPath, timeout: 5000 });
|
||||
await reassertDevice(bm.getActiveSession());
|
||||
return `Screenshot saved (element): ${outputPath}`;
|
||||
}
|
||||
|
||||
if (clipRect) {
|
||||
await page.screenshot({ path: outputPath, clip: clipRect });
|
||||
await screenshotTab(bm.getActiveSession(), { path: outputPath, clip: clipRect });
|
||||
return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`;
|
||||
}
|
||||
|
||||
await page.screenshot({ path: outputPath, fullPage: !viewportOnly });
|
||||
await screenshotTab(bm.getActiveSession(), { path: outputPath, fullPage: !viewportOnly });
|
||||
if (!viewportOnly) await guardScreenshotPath(outputPath);
|
||||
return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`;
|
||||
}
|
||||
@@ -574,6 +577,7 @@ export async function handleMetaCommand(
|
||||
}
|
||||
|
||||
case 'responsive': {
|
||||
refuseWhileDevice(bm.getActiveSession(), 'responsive');
|
||||
const page = bm.getPage();
|
||||
const prefix = args[0] || `${TEMP_DIR}/browse-responsive`;
|
||||
validateOutputPath(prefix);
|
||||
@@ -921,12 +925,13 @@ export async function handleMetaCommand(
|
||||
|
||||
if (action === 'save') {
|
||||
const state = await bm.saveState();
|
||||
const activeId = bm.getActiveTabId();
|
||||
// V1: cookies + URLs only (not localStorage — breaks on load-before-navigate)
|
||||
const saveData = {
|
||||
version: 1,
|
||||
savedAt: new Date().toISOString(),
|
||||
cookies: state.cookies,
|
||||
pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
|
||||
pages: state.pages.map(p => ({ url: p.url, isActive: p.tabId === activeId })),
|
||||
};
|
||||
writeSecureFile(statePath, JSON.stringify(saveData, null, 2));
|
||||
return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠️ Cookies stored in plaintext. Delete when no longer needed.`;
|
||||
|
||||
@@ -86,7 +86,7 @@ export const commandRoutes: RouteEntry[] = [
|
||||
continue;
|
||||
}
|
||||
const cr = await ctx.commands.handleInternal(
|
||||
{ command: cmd.command, args: cmd.args, tabId: cmd.tabId },
|
||||
{ command: cmd.command, args: cmd.args, tabId: cmd.tabId, session: cmd.session ?? body.session },
|
||||
tokenInfo,
|
||||
{ skipRateCheck: true, skipActivity: true },
|
||||
);
|
||||
|
||||
+7
-29
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { BrowserManager, markDaemonProcess } from './browser-manager';
|
||||
import { runInRequestTab } from './session-tabs';
|
||||
import { handleReadCommand, hasOutArg } from './read-commands';
|
||||
import { handleWriteCommand } from './write-commands';
|
||||
import { handleMetaCommand } from './meta-commands';
|
||||
@@ -968,11 +969,11 @@ interface CommandResult {
|
||||
* chainDepth: recursion guard — reject nested chains (depth > 0 means inside a chain)
|
||||
*/
|
||||
async function handleCommandInternalImpl(
|
||||
body: { command: string; args?: string[]; tabId?: number },
|
||||
body: { command: string; args?: string[] },
|
||||
tokenInfo?: TokenInfo | null,
|
||||
opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number },
|
||||
): Promise<CommandResult> {
|
||||
const { args = [], tabId } = body;
|
||||
const { args = [] } = body;
|
||||
const rawCommand = body.command;
|
||||
|
||||
if (!rawCommand) {
|
||||
@@ -1049,17 +1050,7 @@ async function handleCommandInternalImpl(
|
||||
if (!opts?.skipRateCheck && tokenInfo.token) recordCommand(tokenInfo.token);
|
||||
}
|
||||
|
||||
// Pin to a specific tab if requested (set by BROWSE_TAB env var, e.g. per-tab agent contexts).
|
||||
// This prevents parallel agents from interfering with each other's tab context.
|
||||
// Safe because Bun's event loop is single-threaded — no concurrent handleCommand.
|
||||
let savedTabId: number | null = null;
|
||||
if (tabId !== undefined && tabId !== null) {
|
||||
savedTabId = browserManager.getActiveTabId();
|
||||
// bringToFront: false — internal tab pinning must NOT steal window focus
|
||||
try { browserManager.switchTab(tabId, { bringToFront: false }); } catch (err: any) {
|
||||
console.warn('[browse] Failed to pin tab', tabId, ':', err.message);
|
||||
}
|
||||
}
|
||||
// Pin to a specific tab: runInRequestTab (session-tabs.ts) already bound this request's tab (#1961).
|
||||
|
||||
// ─── Tab ownership check (own-only tokens / pair-agent isolation) ──
|
||||
//
|
||||
@@ -1070,7 +1061,7 @@ async function handleCommandInternalImpl(
|
||||
//
|
||||
// Skip for `newtab` — it creates a tab rather than accessing one.
|
||||
if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') {
|
||||
const targetTab = tabId ?? browserManager.getActiveTabId();
|
||||
const targetTab = browserManager.getActiveTabId();
|
||||
if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: isWriteInvocation(command, args), ownOnly: true })) {
|
||||
return {
|
||||
status: 403, json: true,
|
||||
@@ -1277,21 +1268,8 @@ async function handleCommandInternalImpl(
|
||||
});
|
||||
|
||||
browserManager.resetFailures();
|
||||
// Restore original active tab if we pinned to a specific one
|
||||
if (savedTabId !== null) {
|
||||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
|
||||
console.warn('[browse] Failed to restore tab after command:', restoreErr.message);
|
||||
}
|
||||
}
|
||||
return { status: 200, result: tabWarning ? `${tabWarning}\n${result}` : result };
|
||||
} catch (err: any) {
|
||||
// Restore original active tab even on error
|
||||
if (savedTabId !== null) {
|
||||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
|
||||
console.warn('[browse] Failed to restore tab after error:', restoreErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Activity: emit command_end (error) — skipped for chain subcommands
|
||||
const errorDuration = Date.now() - startTime;
|
||||
if (!opts?.skipActivity) {
|
||||
@@ -1337,11 +1315,11 @@ async function handleCommandInternalImpl(
|
||||
* Do not bypass this by calling handleCommandInternalImpl directly.
|
||||
*/
|
||||
async function handleCommandInternal(
|
||||
body: { command: string; args?: string[]; tabId?: number },
|
||||
body: { command: string; args?: string[]; tabId?: number; session?: string },
|
||||
tokenInfo?: TokenInfo | null,
|
||||
opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number },
|
||||
): Promise<CommandResult> {
|
||||
const cr = await handleCommandInternalImpl(body, tokenInfo, opts);
|
||||
const cr = await runInRequestTab(browserManager, body, tokenInfo, () => handleCommandInternalImpl(body, tokenInfo, opts));
|
||||
return { ...cr, result: stripLoneSurrogates(cr.result) };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Which session a CLI call belongs to (#1961), so parallel agents on one
|
||||
* daemon each get their own tab. Precedence: an explicit tab (--tab-id,
|
||||
* BROWSE_TAB) wins and sends no session; then --session <name>; then
|
||||
* BROWSE_SESSION (empty or "off" turns sessions off); then the agent harness
|
||||
* the call runs under. The harness is the first ancestor that is not a shell,
|
||||
* the same rule as bin/gstack-harness-pid.sh: every tool call an agent makes
|
||||
* starts a fresh shell under the same agent process, so its pid names the
|
||||
* agent across calls without any exported variable surviving between them.
|
||||
* Only known agent harnesses get an automatic session; a human terminal and a
|
||||
* harness shared by every session on the machine (capy-server) keep today's
|
||||
* shared active tab.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export interface ProcInfo { ppid: number; comm: string }
|
||||
export type ProcLookup = (pid: number) => ProcInfo | null;
|
||||
export interface SessionChoice { session?: string; auto?: boolean }
|
||||
|
||||
const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'mksh', 'ash', 'fish', 'busybox']);
|
||||
const AGENT_HARNESSES = new Set(['claude', 'codex', 'cursor-agent', 'gemini', 'opencode', 'amp', 'droid']);
|
||||
const SHARED_HARNESSES = new Set(['capy-server']);
|
||||
|
||||
/** Strip `--session <name>` from args, the way extractTabId strips --tab-id. */
|
||||
export function extractSession(args: string[]): { session: string | undefined; args: string[] } {
|
||||
const stripped: string[] = [];
|
||||
let session: string | undefined;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--session') session = args[++i] ?? session;
|
||||
else stripped.push(args[i]!);
|
||||
}
|
||||
return { session, args: stripped };
|
||||
}
|
||||
|
||||
/** The first non-shell ancestor of `pid` (at most 8 hops), or null when the walk fails. */
|
||||
export function harnessOf(pid: number, lookup: ProcLookup): { pid: number; comm: string } | null {
|
||||
let current = pid;
|
||||
for (let hops = 0; hops < 8; hops++) {
|
||||
const info = lookup(current);
|
||||
if (!info) return null;
|
||||
if (!SHELLS.has(info.comm)) return { pid: current, comm: info.comm };
|
||||
if (info.ppid <= 1) return null;
|
||||
current = info.ppid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function autoSessionName(env: NodeJS.ProcessEnv, parentPid: number, lookup: ProcLookup): string | undefined {
|
||||
const harness = harnessOf(parentPid, lookup);
|
||||
if (!harness || SHARED_HARNESSES.has(harness.comm)) return undefined;
|
||||
if (!AGENT_HARNESSES.has(harness.comm) && env.CLAUDECODE !== '1') return undefined;
|
||||
return `agent-${harness.pid}`;
|
||||
}
|
||||
|
||||
export function chooseSession(opts: {
|
||||
hasTab: boolean; flag: string | undefined; env: NodeJS.ProcessEnv; parentPid: number; lookup: ProcLookup;
|
||||
}): SessionChoice {
|
||||
if (opts.hasTab) return {};
|
||||
if (opts.flag !== undefined) return opts.flag === '' || opts.flag === 'off' ? {} : { session: opts.flag };
|
||||
const fromEnv = opts.env.BROWSE_SESSION;
|
||||
if (fromEnv !== undefined) return fromEnv === '' || fromEnv === 'off' ? {} : { session: fromEnv };
|
||||
const auto = autoSessionName(opts.env, opts.parentPid, opts.lookup);
|
||||
return auto ? { session: auto, auto: true } : {};
|
||||
}
|
||||
|
||||
function basename(comm: string): string {
|
||||
const name = comm.trim().split('/').pop() ?? '';
|
||||
return name.startsWith('-') ? name.slice(1) : name;
|
||||
}
|
||||
|
||||
/** Linux reads /proc; macOS takes one `ps` table; anything else has no automatic session. */
|
||||
export function systemProcLookup(): ProcLookup {
|
||||
if (process.platform === 'linux') {
|
||||
return (pid) => {
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||
const open = stat.indexOf('(');
|
||||
const close = stat.lastIndexOf(')');
|
||||
const ppid = Number(stat.slice(close + 2).split(' ')[1]);
|
||||
return Number.isFinite(ppid) ? { ppid, comm: basename(stat.slice(open + 1, close)) } : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
}
|
||||
if (process.platform !== 'darwin') return () => null;
|
||||
let table: Map<number, ProcInfo> | null = null;
|
||||
return (pid) => {
|
||||
if (!table) {
|
||||
table = new Map();
|
||||
const ps = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', timeout: 2000, windowsHide: true });
|
||||
for (const line of (ps.stdout ?? '').split('\n')) {
|
||||
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
|
||||
if (m) table.set(Number(m[1]), { ppid: Number(m[2]), comm: basename(m[3]!) });
|
||||
}
|
||||
}
|
||||
return table.get(pid) ?? null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Per-session tabs for agents sharing one browse daemon (#1961).
|
||||
*
|
||||
* A request that names a session runs inside BrowserManager.runInTab with a
|
||||
* scope owned by `session:<name>`: the session's first command opens its own
|
||||
* tab, and every later command, chain subcommand included, resolves to that
|
||||
* tab without moving the daemon's global active tab. Sessions isolate tab
|
||||
* routing only; cookies and storage stay shared by every tab in the browser.
|
||||
* Only the root token may name a session, so a paired or tunnel agent cannot
|
||||
* open tabs through it (a session the CLI derived on its own is ignored for
|
||||
* them instead). A request that names a tab id runs in the same kind of
|
||||
* scope with no owner, which replaces the old switch-then-restore pin.
|
||||
*/
|
||||
import type { BrowserManager, TabScope } from './browser-manager';
|
||||
import type { TokenInfo } from './token-registry';
|
||||
import { resultLine } from '../../lib/result-codes';
|
||||
|
||||
interface Result { status: number; result: string; json?: boolean; headers?: Record<string, string> }
|
||||
|
||||
interface SessionRecord extends TabScope {
|
||||
owner: string;
|
||||
lastUsed: number;
|
||||
inFlight: number;
|
||||
lastUrl: string;
|
||||
opening: Promise<void> | null;
|
||||
}
|
||||
|
||||
const SESSION_NAME = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
const REOPENING_COMMANDS = new Set(['goto', 'newtab']);
|
||||
const DAEMON_COMMANDS = new Set(['status', 'stop', 'restart']);
|
||||
const DEFAULT_IDLE_MINUTES = 120;
|
||||
const DEFAULT_MAX_SESSIONS = 16;
|
||||
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
const MAX_DROPPED = 256;
|
||||
/** Sessions the idle sweep or the cap dropped, with their last URL, so a later read reports the reset instead of a blank tab. */
|
||||
const dropped = new Map<string, string>();
|
||||
|
||||
function numberFromEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === '') return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function refuse(status: number, line: string): Result {
|
||||
return { status, json: true, result: JSON.stringify({ error: line }) };
|
||||
}
|
||||
|
||||
/** Close (headless) or release (headed) every tab of sessions idle past BROWSE_SESSION_IDLE_MINUTES (0 = never). */
|
||||
async function sweepIdle(bm: BrowserManager, now: number, keep: string): Promise<void> {
|
||||
const idleMinutes = numberFromEnv('BROWSE_SESSION_IDLE_MINUTES', DEFAULT_IDLE_MINUTES);
|
||||
if (idleMinutes === 0) return;
|
||||
for (const [name, record] of sessions) {
|
||||
if (name === keep || record.inFlight > 0 || now - record.lastUsed < idleMinutes * 60_000) continue;
|
||||
await dropSession(bm, name, record);
|
||||
}
|
||||
}
|
||||
|
||||
async function dropSession(bm: BrowserManager, name: string, record: SessionRecord): Promise<void> {
|
||||
sessions.delete(name);
|
||||
dropped.delete(name);
|
||||
dropped.set(name, record.lastUrl);
|
||||
if (dropped.size > MAX_DROPPED) dropped.delete(dropped.keys().next().value!);
|
||||
if (bm.getConnectionMode() === 'headed') {
|
||||
bm.releaseClientTabs(record.owner);
|
||||
return;
|
||||
}
|
||||
for (const id of bm.tabsOwnedBy(record.owner)) await bm.closeTab(id).catch(() => {});
|
||||
}
|
||||
|
||||
/** Make room for session `name` by dropping the least recently used idle session. */
|
||||
async function makeRoom(bm: BrowserManager, name: string): Promise<boolean> {
|
||||
const others = [...sessions].filter(([n]) => n !== name);
|
||||
if (others.length < numberFromEnv('BROWSE_MAX_SESSIONS', DEFAULT_MAX_SESSIONS)) return true;
|
||||
const idle = others.filter(([, r]) => r.inFlight === 0 && !r.opening).sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
|
||||
if (!idle) return false;
|
||||
await dropSession(bm, idle[0], idle[1]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Open the session's tab once; concurrent first commands share the same promise. */
|
||||
function openTab(bm: BrowserManager, name: string, record: SessionRecord): Promise<void> {
|
||||
record.opening ??= (async () => {
|
||||
if (!(await makeRoom(bm, name))) throw new Error('limit');
|
||||
await bm.runInTab(record, () => bm.newTab(undefined, record.owner));
|
||||
})().finally(() => { record.opening = null; });
|
||||
return record.opening;
|
||||
}
|
||||
|
||||
async function runInSession<R extends Result>(bm: BrowserManager, name: string, command: string, quiet: boolean, fn: () => Promise<R>): Promise<Result> {
|
||||
const now = Date.now();
|
||||
await sweepIdle(bm, now, name);
|
||||
let record = sessions.get(name);
|
||||
let notice = '';
|
||||
if (!record && dropped.has(name)) {
|
||||
if (!REOPENING_COMMANDS.has(command)) {
|
||||
const last = dropped.get(name) || 'about:blank';
|
||||
return refuse(409, resultLine('browse', 'BROWSE_SESSION_TAB_RESET', `session ${name} was closed after going idle or to make room; it was on ${last}`));
|
||||
}
|
||||
dropped.delete(name);
|
||||
}
|
||||
try {
|
||||
if (!record) {
|
||||
record = { tabId: 0, owner: `session:${name}`, lastUsed: now, inFlight: 0, lastUrl: '', opening: null };
|
||||
sessions.set(name, record);
|
||||
await openTab(bm, name, record);
|
||||
notice = `[browse] session ${name} → tab ${record.tabId}`;
|
||||
} else if (record.opening) {
|
||||
await record.opening;
|
||||
} else if (!bm.hasTab(bm.resolveScopeTab(record))) {
|
||||
if (!REOPENING_COMMANDS.has(command)) {
|
||||
const last = record.lastUrl || 'about:blank';
|
||||
return refuse(409, resultLine('browse', 'BROWSE_SESSION_TAB_RESET', `session ${name}'s tab ${record.tabId} was closed; it was on ${last}`));
|
||||
}
|
||||
await openTab(bm, name, record);
|
||||
notice = `[browse] session ${name}: its tab was closed, so this runs on new tab ${record.tabId}`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (record && !bm.hasTab(bm.resolveScopeTab(record))) sessions.delete(name);
|
||||
if (err?.message !== 'limit') throw err;
|
||||
return refuse(429, resultLine('browse', 'BROWSE_SESSION_LIMIT', `${sessions.size} sessions are running a command`));
|
||||
}
|
||||
record.inFlight++;
|
||||
record.lastUsed = Date.now();
|
||||
try {
|
||||
const cr = await bm.runInTab(record, fn);
|
||||
return notice && !cr.json && !quiet ? { ...cr, result: `${notice}\n${cr.result}` } : cr;
|
||||
} finally {
|
||||
record.inFlight--;
|
||||
record.lastUsed = Date.now();
|
||||
record.lastUrl = bm.tabUrl(bm.resolveScopeTab(record)) ?? record.lastUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one command on the tab its request names: `tabId` first (BROWSE_TAB,
|
||||
* --tab-id), then `session`. A request naming neither runs in whatever scope
|
||||
* it was called from, so chain subcommands inherit their parent's tab.
|
||||
*/
|
||||
export async function runInRequestTab<R extends Result>(
|
||||
bm: BrowserManager,
|
||||
body: { command: string; args?: string[]; tabId?: number; session?: string; sessionAuto?: boolean },
|
||||
tokenInfo: TokenInfo | null | undefined,
|
||||
fn: () => Promise<R>,
|
||||
): Promise<Result> {
|
||||
if (typeof body.tabId === 'number') {
|
||||
if (bm.hasTab(body.tabId)) return bm.runInTab({ tabId: body.tabId }, fn);
|
||||
console.warn('[browse] Failed to pin tab', body.tabId, ': not found');
|
||||
return fn();
|
||||
}
|
||||
if (body.session === undefined || body.session === null || body.session === '' || DAEMON_COMMANDS.has(body.command)) return fn();
|
||||
if (tokenInfo && tokenInfo.clientId !== 'root') {
|
||||
if (body.sessionAuto) return fn();
|
||||
return refuse(403, resultLine('browse', 'BROWSE_SESSION_ROOT_ONLY', `client ${tokenInfo.clientId}`));
|
||||
}
|
||||
const name = String(body.session);
|
||||
if (!SESSION_NAME.test(name)) {
|
||||
return refuse(400, resultLine('browse', 'BROWSE_SESSION_INVALID', JSON.stringify(name.slice(0, 80))));
|
||||
}
|
||||
return runInSession(bm, name, body.command, Boolean(body.args?.includes('--json')), fn);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { escapeEnvelopeSentinels } from './content-security';
|
||||
import { stripLoneSurrogates } from './sanitize';
|
||||
import { guardScreenshotPath } from './screenshot-size-guard';
|
||||
import { reassertDevice } from './device-emulation';
|
||||
import { SNAPSHOT_FLAGS, type SnapshotOptions } from './snapshot-flags';
|
||||
|
||||
export { SNAPSHOT_FLAGS } from './snapshot-flags';
|
||||
@@ -430,6 +431,7 @@ export async function handleSnapshot(
|
||||
}, boxes);
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
await reassertDevice(session);
|
||||
await guardScreenshotPath(screenshotPath);
|
||||
|
||||
// Always remove overlays
|
||||
@@ -560,6 +562,7 @@ export async function handleSnapshot(
|
||||
}, boxes);
|
||||
|
||||
await page.screenshot({ path: heatmapPath, fullPage: true });
|
||||
await reassertDevice(session);
|
||||
await guardScreenshotPath(heatmapPath);
|
||||
|
||||
// Remove heatmap overlays
|
||||
|
||||
@@ -26,6 +26,12 @@ export interface RefEntry {
|
||||
|
||||
export type SetContentWaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
|
||||
|
||||
/** Phone profile applied by `$B device` (a Playwright `devices` name). */
|
||||
export interface DeviceProfile {
|
||||
name: string;
|
||||
landscape: boolean;
|
||||
}
|
||||
|
||||
export class TabSession {
|
||||
readonly page: Page;
|
||||
|
||||
@@ -63,6 +69,12 @@ export class TabSession {
|
||||
private loadedHtml: string | null = null;
|
||||
private loadedHtmlWaitUntil: SetContentWaitUntil | undefined;
|
||||
|
||||
// ─── Phone emulation and held touch fingers (device-emulation.ts, touch-commands.ts) ─
|
||||
private device: DeviceProfile | null = null;
|
||||
private fingers: Map<number, { x: number; y: number }> = new Map();
|
||||
private fingerCancel: (() => void) | null = null;
|
||||
private gestureChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
@@ -176,6 +188,40 @@ export class TabSession {
|
||||
this.activeFrame = null;
|
||||
this.loadedHtml = null;
|
||||
this.loadedHtmlWaitUntil = undefined;
|
||||
this.dropFingers();
|
||||
}
|
||||
|
||||
// ─── Phone emulation and touch ─────────────────────────────
|
||||
getDevice(): DeviceProfile | null {
|
||||
return this.device;
|
||||
}
|
||||
|
||||
setDevice(profile: DeviceProfile | null): void {
|
||||
this.device = profile;
|
||||
}
|
||||
|
||||
/** Held fingers by finger number; touch-commands.ts mutates this map. */
|
||||
heldFingers(): Map<number, { x: number; y: number }> {
|
||||
return this.fingers;
|
||||
}
|
||||
|
||||
/** How to tell the browser the held fingers are gone (a CDP touchCancel). */
|
||||
setFingerCancel(cancel: () => void): void {
|
||||
this.fingerCancel = cancel;
|
||||
}
|
||||
|
||||
/** Forget held fingers and cancel them in the page. Navigation and device changes call this. */
|
||||
dropFingers(): void {
|
||||
if (this.fingers.size === 0) return;
|
||||
this.fingers.clear();
|
||||
this.fingerCancel?.();
|
||||
}
|
||||
|
||||
/** Run gestures on this tab one at a time. */
|
||||
runGesture<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.gestureChain.then(fn, fn);
|
||||
this.gestureChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
// ─── Loaded HTML (load-html replay) ───────────────────────
|
||||
|
||||
@@ -396,6 +396,7 @@ function spawnClaude(cols: number, rows: number, lifecycle: PtyLifecycle) {
|
||||
BROWSE_STATE_FILE: STATE_FILE,
|
||||
BROWSE_NO_AUTOSTART: '1',
|
||||
BROWSE_HEADED: '1',
|
||||
BROWSE_SESSION: '',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ export const SCOPE_WRITE = new Set([
|
||||
'upload', 'viewport', 'newtab', 'closetab',
|
||||
'dialog-accept', 'dialog-dismiss',
|
||||
'download', 'scrape', 'archive',
|
||||
'device', 'tap', 'swipe', 'pinch', 'touch',
|
||||
]);
|
||||
|
||||
/** Page-level power tools — JS execution, credential access, page mutations */
|
||||
@@ -134,6 +135,9 @@ export function assertValidClientId(clientId: unknown): asserts clientId is stri
|
||||
if (clientId.trim().toLowerCase() === 'root') {
|
||||
throw new ReservedClientIdError("clientId 'root' is reserved");
|
||||
}
|
||||
if (clientId.trim().toLowerCase().startsWith('session:')) {
|
||||
throw new ReservedClientIdError("clientId prefix 'session:' is reserved for agent sessions");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Touch input for phone-emulated tabs (`$B tap|swipe|pinch|touch`, #3058).
|
||||
* Everything goes through Input.dispatchTouchEvent on the tab's own CDP
|
||||
* session (the one device-emulation.ts set up), so it needs `$B device` first.
|
||||
*
|
||||
* held fingers (touch down 0-9) ── TabSession.heldFingers()
|
||||
* gesture fingers (tap/swipe/pinch) use ids 10+, added beside held ones
|
||||
*
|
||||
* Chromium 151 dispatches an event only for the points it lists: touchStart
|
||||
* with the new finger, touchMove with the moved ones, touchEnd with the lifted
|
||||
* one (measured: a touchMove that omits a finger does not release it). Fingers
|
||||
* not listed stay down, which is what multi-finger chords need. Gestures on one
|
||||
* tab run one at a time (TabSession.runGesture).
|
||||
*/
|
||||
|
||||
import type { TabSession } from './tab-session';
|
||||
import { tabCdpSession } from './device-emulation';
|
||||
import { resultLine } from '../../lib/result-codes';
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type Points = Map<number, Point>;
|
||||
|
||||
const GESTURE_ID = 10;
|
||||
const STEPS = 10;
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function usage(problem: string, fix: string): Error {
|
||||
return new Error(`Usage: ${problem}\nfix: ${fix}`);
|
||||
}
|
||||
|
||||
async function dispatch(session: TabSession, type: 'touchStart' | 'touchMove' | 'touchEnd', points: Points): Promise<void> {
|
||||
const cdp = await tabCdpSession(session.getPage());
|
||||
session.setFingerCancel(() => {
|
||||
cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }).catch(() => {});
|
||||
});
|
||||
const touchPoints = [...points].map(([id, p]) => ({ id, x: p.x, y: p.y, radiusX: 1, radiusY: 1, force: 1 }));
|
||||
await cdp.send('Input.dispatchTouchEvent', { type, touchPoints });
|
||||
}
|
||||
|
||||
function parseNumber(raw: string | undefined, what: string, fix: string): number {
|
||||
const n = Number(raw);
|
||||
if (raw === undefined || raw === '' || !Number.isFinite(n)) throw usage(`${what} must be a number, got "${raw ?? ''}".`, fix);
|
||||
return n;
|
||||
}
|
||||
|
||||
function flag(args: string[], name: string, fallback: number, fix: string): number {
|
||||
const i = args.indexOf(name);
|
||||
if (i === -1) return fallback;
|
||||
const n = parseNumber(args[i + 1], name, fix);
|
||||
if (n < 0 || n > 10000) throw usage(`${name} must be between 0 and 10000, got ${n}.`, fix);
|
||||
args.splice(i, 2);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** `<@ref|selector|x y>` → a viewport point; element targets scroll into view first. */
|
||||
async function resolvePoint(session: TabSession, args: string[], fix: string): Promise<Point> {
|
||||
const [a, b] = args;
|
||||
if (!a) throw usage('missing target (@ref, selector, or x y).', fix);
|
||||
if (Number.isFinite(Number(a)) && Number.isFinite(Number(b)) && b !== undefined && b !== '') {
|
||||
args.splice(0, 2);
|
||||
return { x: Number(a), y: Number(b) };
|
||||
}
|
||||
args.splice(0, 1);
|
||||
const resolved = await session.resolveRef(a);
|
||||
const locator = 'locator' in resolved ? resolved.locator : session.getActiveFrameOrPage().locator(resolved.selector).first();
|
||||
await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw usage(`${a} has no visible box to touch.`, `$B snapshot -i, then target a visible element`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
async function viewportClamp(session: TabSession): Promise<(p: Point) => Point> {
|
||||
const [w, h] = await session.getPage().evaluate(() => [innerWidth, innerHeight]);
|
||||
return (p) => ({ x: Math.min(Math.max(p.x, 0), w - 1), y: Math.min(Math.max(p.y, 0), h - 1) });
|
||||
}
|
||||
|
||||
/** Press gesture fingers at `from`, move them to `to` in STEPS moves over `ms`, lift them; held fingers stay down. */
|
||||
async function stroke(session: TabSession, from: Points, to: Points, ms: number): Promise<void> {
|
||||
await dispatch(session, 'touchStart', from);
|
||||
let at: Points = from;
|
||||
for (let i = 1; i <= STEPS; i++) {
|
||||
await sleep(ms / STEPS);
|
||||
at = new Map([...from].map(([id, a]) => {
|
||||
const b = to.get(id)!;
|
||||
return [id, { x: a.x + (b.x - a.x) * i / STEPS, y: a.y + (b.y - a.y) * i / STEPS }];
|
||||
}));
|
||||
await dispatch(session, 'touchMove', at);
|
||||
}
|
||||
await dispatch(session, 'touchEnd', at);
|
||||
}
|
||||
|
||||
const fmt = (p: Point) => `(${Math.round(p.x)}, ${Math.round(p.y)})`;
|
||||
|
||||
async function tap(session: TabSession, args: string[]): Promise<string> {
|
||||
const fix = '$B tap <@ref|selector|x y> [--hold <ms>]';
|
||||
const hold = flag(args, '--hold', 0, fix);
|
||||
const point = await resolvePoint(session, args, fix);
|
||||
const finger: Points = new Map([[GESTURE_ID, point]]);
|
||||
await dispatch(session, 'touchStart', finger);
|
||||
if (hold) await sleep(hold);
|
||||
await dispatch(session, 'touchEnd', finger);
|
||||
return `Tapped ${fmt(point)}${hold ? ` held ${hold}ms` : ''}`;
|
||||
}
|
||||
|
||||
async function swipe(session: TabSession, args: string[]): Promise<string> {
|
||||
const fix = '$B swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]';
|
||||
const distance = flag(args, '--distance', 300, fix);
|
||||
const ms = flag(args, '--ms', 300, fix);
|
||||
const start = await resolvePoint(session, args, fix);
|
||||
const direction = args[0];
|
||||
const delta: Record<string, Point> = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } };
|
||||
const unit = direction ? delta[direction] : undefined;
|
||||
if (!unit || args.length > 1) throw usage(`swipe direction must be up|down|left|right, got "${args.join(' ')}".`, fix);
|
||||
const clamp = await viewportClamp(session);
|
||||
const end = clamp({ x: start.x + unit.x * distance, y: start.y + unit.y * distance });
|
||||
await stroke(session, new Map([[GESTURE_ID, start]]), new Map([[GESTURE_ID, end]]), ms);
|
||||
const moved = Math.round(Math.hypot(end.x - start.x, end.y - start.y));
|
||||
return `Swiped ${direction} ${moved}px from ${fmt(start)} in ${ms}ms${moved < distance ? ' (stopped at the viewport edge)' : ''}`;
|
||||
}
|
||||
|
||||
async function pinch(session: TabSession, args: string[]): Promise<string> {
|
||||
const fix = '$B pinch <@ref|selector|x y> <scale> (2 spreads two fingers apart, 0.5 pinches them together)';
|
||||
const centre = await resolvePoint(session, args, fix);
|
||||
const scale = parseNumber(args[0], 'pinch scale', fix);
|
||||
if (scale <= 0 || scale > 10 || args.length > 1) throw usage(`pinch scale must be one number above 0 and at most 10, got "${args.join(' ')}".`, fix);
|
||||
const clamp = await viewportClamp(session);
|
||||
const startHalf = scale >= 1 ? 40 : 40 / scale;
|
||||
const at = (half: number) => new Map([
|
||||
[GESTURE_ID, clamp({ x: centre.x - half, y: centre.y })],
|
||||
[GESTURE_ID + 1, clamp({ x: centre.x + half, y: centre.y })],
|
||||
]);
|
||||
await stroke(session, at(startHalf), at(startHalf * scale), 300);
|
||||
return `Pinched at ${fmt(centre)} to scale ${scale}`;
|
||||
}
|
||||
|
||||
function fingerArg(raw: string | undefined, fix: string): number {
|
||||
const finger = Number(raw);
|
||||
if (!Number.isInteger(finger) || finger < 0 || finger > 9) throw usage(`finger must be 0-9, got "${raw ?? ''}".`, fix);
|
||||
return finger;
|
||||
}
|
||||
|
||||
function listFingers(held: Points): string {
|
||||
if (held.size === 0) return 'Held fingers: none';
|
||||
return 'Held fingers:\n' + [...held].map(([f, p]) => ` finger ${f} at ${fmt(p)}`).join('\n');
|
||||
}
|
||||
|
||||
async function touch(session: TabSession, args: string[]): Promise<string> {
|
||||
const fix = '$B touch down|move <finger 0-9> <x> <y> | $B touch up <finger> | $B touch cancel | $B touch';
|
||||
const held = session.heldFingers();
|
||||
const [sub, rawFinger, rawX, rawY] = args;
|
||||
if (!sub) return listFingers(held);
|
||||
if (sub === 'cancel') {
|
||||
if (held.size === 0) return 'No fingers held; nothing to cancel';
|
||||
const cdp = await tabCdpSession(session.getPage());
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
|
||||
held.clear();
|
||||
return 'Cancelled all held fingers';
|
||||
}
|
||||
if (!['down', 'move', 'up'].includes(sub)) throw usage(`unknown touch action "${sub}".`, fix);
|
||||
const finger = fingerArg(rawFinger, fix);
|
||||
if (sub === 'down' && held.has(finger)) throw usage(`finger ${finger} is already down.`, `$B touch move ${finger} <x> <y> or $B touch up ${finger}`);
|
||||
if (sub !== 'down' && !held.has(finger)) {
|
||||
throw usage(`finger ${finger} is not down (${listFingers(held).toLowerCase()}).`, `$B touch down ${finger} <x> <y>`);
|
||||
}
|
||||
if (sub === 'up') {
|
||||
await dispatch(session, 'touchEnd', new Map([[finger, held.get(finger)!]]));
|
||||
held.delete(finger);
|
||||
return `Finger ${finger} up; ${listFingers(held).toLowerCase()}`;
|
||||
}
|
||||
const point = { x: parseNumber(rawX, 'x', fix), y: parseNumber(rawY, 'y', fix) };
|
||||
await dispatch(session, sub === 'down' ? 'touchStart' : 'touchMove', new Map([[finger, point]]));
|
||||
held.set(finger, point);
|
||||
return `Finger ${finger} ${sub} at ${fmt(point)}`;
|
||||
}
|
||||
|
||||
export const TOUCH_COMMANDS = new Set(['tap', 'swipe', 'pinch', 'touch']);
|
||||
|
||||
export async function handleTouchCommand(command: string, args: string[], session: TabSession): Promise<string> {
|
||||
if (!session.getDevice()) {
|
||||
throw new Error(resultLine('browse', 'BROWSE_TOUCH_NO_DEVICE', `$B ${command} on this tab`));
|
||||
}
|
||||
const rest = [...args];
|
||||
const run = { tap, swipe, pinch, touch }[command as 'tap' | 'swipe' | 'pinch' | 'touch'];
|
||||
return session.runGesture(() => run(session, rest));
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { TEMP_DIR } from './platform';
|
||||
import { SAFE_DIRECTORIES } from './path-security';
|
||||
import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
import { handleDeviceCommand, reassertDevice, refuseWhileDevice } from './device-emulation';
|
||||
import { TOUCH_COMMANDS, handleTouchCommand } from './touch-commands';
|
||||
|
||||
/**
|
||||
* Aggressive page cleanup selectors and heuristics.
|
||||
@@ -141,6 +143,8 @@ export async function handleWriteCommand(
|
||||
// Frame-aware target for locator-based operations (click, fill, etc.)
|
||||
const target = session.getActiveFrameOrPage();
|
||||
const inFrame = session.getFrame() !== null;
|
||||
if (command === 'device') return handleDeviceCommand(args, session);
|
||||
if (TOUCH_COMMANDS.has(command)) return handleTouchCommand(command, args, session);
|
||||
|
||||
switch (command) {
|
||||
case 'goto': {
|
||||
@@ -526,6 +530,7 @@ export async function handleWriteCommand(
|
||||
}
|
||||
|
||||
case 'viewport': {
|
||||
refuseWhileDevice(session, 'viewport');
|
||||
// Parse args: [<WxH>] [--scale <n>]. Either may be omitted, but NOT both.
|
||||
let sizeArg: string | undefined;
|
||||
let scaleArg: number | undefined;
|
||||
@@ -1126,6 +1131,7 @@ export async function handleWriteCommand(
|
||||
if (viewportWidth && originalViewport) {
|
||||
await page.setViewportSize(originalViewport);
|
||||
}
|
||||
await reassertDevice(session);
|
||||
|
||||
const parts = ['Screenshot saved'];
|
||||
if (doCleanup) parts.push('(cleaned)');
|
||||
|
||||
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Touch fixture</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; touch-action: none; }
|
||||
#carousel, #zoom, #joystick, #fire { position: absolute; display: flex; align-items: center; justify-content: center; }
|
||||
#carousel { left: 10px; top: 10px; width: 300px; height: 120px; background: #def; }
|
||||
#zoom { left: 10px; top: 150px; width: 300px; height: 200px; background: #fed; }
|
||||
#joystick { left: 10px; top: 380px; width: 120px; height: 120px; background: #dfd; border-radius: 60px; }
|
||||
#fire { left: 200px; top: 400px; width: 80px; height: 80px; background: #fdd; border-radius: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="carousel" data-index="0">item 0</div>
|
||||
<div id="zoom" data-scale="1">zoom 1</div>
|
||||
<div id="joystick" data-held="false">joystick</div>
|
||||
<button id="fire" data-shots="0">fire</button>
|
||||
<script>
|
||||
window.__log = [];
|
||||
for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel']) {
|
||||
document.addEventListener(type, (e) => {
|
||||
window.__log.push({ type, touches: e.touches.length, changed: e.changedTouches.length, target: e.target.id || e.target.tagName });
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
const carousel = document.getElementById('carousel');
|
||||
let swipeStart = null;
|
||||
carousel.addEventListener('touchstart', (e) => { swipeStart = { x: e.touches[0].clientX }; carousel.dataset.lastX = String(swipeStart.x); });
|
||||
carousel.addEventListener('touchmove', (e) => { carousel.dataset.lastX = String(e.touches[0].clientX); });
|
||||
carousel.addEventListener('touchend', () => {
|
||||
if (!swipeStart) return;
|
||||
const dx = Number(carousel.dataset.lastX) - swipeStart.x;
|
||||
let index = Number(carousel.dataset.index);
|
||||
if (dx < -50) index += 1;
|
||||
if (dx > 50) index -= 1;
|
||||
carousel.dataset.index = String(index);
|
||||
carousel.textContent = 'item ' + index;
|
||||
swipeStart = null;
|
||||
});
|
||||
|
||||
const zoom = document.getElementById('zoom');
|
||||
let pinchStart = null;
|
||||
const spread = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
|
||||
zoom.addEventListener('touchmove', (e) => {
|
||||
if (e.touches.length < 2) return;
|
||||
if (pinchStart === null) pinchStart = { distance: spread(e.touches), scale: Number(zoom.dataset.scale) };
|
||||
const scale = pinchStart.scale * spread(e.touches) / pinchStart.distance;
|
||||
zoom.dataset.scale = scale.toFixed(2);
|
||||
zoom.textContent = 'zoom ' + zoom.dataset.scale;
|
||||
});
|
||||
zoom.addEventListener('touchend', (e) => { if (e.touches.length < 2) pinchStart = null; });
|
||||
|
||||
const joystick = document.getElementById('joystick');
|
||||
const fire = document.getElementById('fire');
|
||||
joystick.addEventListener('touchstart', (e) => { joystick.dataset.touchId = String(e.changedTouches[0].identifier); joystick.dataset.held = 'true'; });
|
||||
document.addEventListener('touchend', (e) => {
|
||||
for (const t of e.changedTouches) if (String(t.identifier) === joystick.dataset.touchId) joystick.dataset.held = 'false';
|
||||
});
|
||||
fire.addEventListener('touchstart', () => { fire.dataset.shots = String(Number(fire.dataset.shots) + 1); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Which session a CLI call sends (#1961): precedence, the off switch, and the
|
||||
* agent-harness walk that gives each agent its own session across fresh
|
||||
* shells. The live case runs a real process named "claude" on Linux.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { autoSessionName, chooseSession, extractSession, harnessOf, type ProcLookup } from '../src/session-name';
|
||||
|
||||
const table = (rows: Record<number, [number, string]>): ProcLookup => (pid) => {
|
||||
const row = rows[pid];
|
||||
return row ? { ppid: row[0], comm: row[1] } : null;
|
||||
};
|
||||
// 400 bash -> 300 sh -> 200 <harness> -> 1
|
||||
const under = (harness: string) => table({ 400: [300, 'bash'], 300: [200, 'sh'], 200: [1, harness] });
|
||||
|
||||
describe('extractSession', () => {
|
||||
test('strips --session and its value anywhere in args', () => {
|
||||
expect(extractSession(['https://x', '--session', 'a1', '--json'])).toEqual({ session: 'a1', args: ['https://x', '--json'] });
|
||||
expect(extractSession(['text'])).toEqual({ session: undefined, args: ['text'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('harness walk', () => {
|
||||
test('skips shells to the first other ancestor', () => {
|
||||
expect(harnessOf(400, under('claude'))).toEqual({ pid: 200, comm: 'claude' });
|
||||
});
|
||||
test('a known agent harness names the session by its pid', () => {
|
||||
for (const h of ['claude', 'codex', 'cursor-agent']) expect(autoSessionName({}, 400, under(h))).toBe('agent-200');
|
||||
});
|
||||
test('node counts only with CLAUDECODE=1', () => {
|
||||
expect(autoSessionName({}, 400, under('node'))).toBeUndefined();
|
||||
expect(autoSessionName({ CLAUDECODE: '1' }, 400, under('node'))).toBe('agent-200');
|
||||
});
|
||||
test('a human terminal and a shared harness get no session', () => {
|
||||
expect(autoSessionName({}, 400, under('tmux: server'))).toBeUndefined();
|
||||
expect(autoSessionName({ CLAUDECODE: '1' }, 400, under('capy-server'))).toBeUndefined();
|
||||
expect(autoSessionName({}, 400, () => null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseSession precedence', () => {
|
||||
const base = { hasTab: false, flag: undefined, env: {}, parentPid: 400, lookup: under('claude') };
|
||||
test('an explicit tab wins and sends no session', () => {
|
||||
expect(chooseSession({ ...base, hasTab: true, flag: 'a' })).toEqual({});
|
||||
});
|
||||
test('--session beats BROWSE_SESSION, which beats the harness', () => {
|
||||
expect(chooseSession({ ...base, flag: 'f', env: { BROWSE_SESSION: 'e' } })).toEqual({ session: 'f' });
|
||||
expect(chooseSession({ ...base, env: { BROWSE_SESSION: 'e' } })).toEqual({ session: 'e' });
|
||||
expect(chooseSession(base)).toEqual({ session: 'agent-200', auto: true });
|
||||
});
|
||||
test('empty or off turns sessions off', () => {
|
||||
expect(chooseSession({ ...base, env: { BROWSE_SESSION: '' } })).toEqual({});
|
||||
expect(chooseSession({ ...base, env: { BROWSE_SESSION: 'off' } })).toEqual({});
|
||||
expect(chooseSession({ ...base, flag: 'off' })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe.if(process.platform === 'linux')('live /proc walk', () => {
|
||||
test('two fresh shells under one "claude" process get the same session, named by its pid', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-name-'));
|
||||
try {
|
||||
const claude = path.join(dir, 'claude');
|
||||
fs.copyFileSync('/bin/bash', claude);
|
||||
fs.chmodSync(claude, 0o755);
|
||||
const probe = `${JSON.stringify(process.execPath)} -e 'import("${path.resolve(import.meta.dir, '../src/session-name.ts')}").then(m => console.log(m.autoSessionName({}, process.ppid, m.systemProcLookup())))'`;
|
||||
const r = spawnSync(claude, ['-c', `echo HARNESS=$$; bash -c '${probe.replace(/'/g, `'\\''`)}'; sh -c '${probe.replace(/'/g, `'\\''`)}'; true`], { encoding: 'utf8', timeout: 30_000 });
|
||||
const harness = r.stdout.match(/HARNESS=(\d+)/)?.[1];
|
||||
const names = r.stdout.split('\n').filter(l => l.startsWith('agent-'));
|
||||
expect(harness).toBeDefined();
|
||||
expect(names).toEqual([`agent-${harness}`, `agent-${harness}`]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar terminal agent', () => {
|
||||
test('its claude keeps the user\'s tab: the spawn env turns the automatic session off', () => {
|
||||
const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/terminal-agent.ts'), 'utf8');
|
||||
const start = source.indexOf('function spawnClaude');
|
||||
const spawnEnv = source.slice(start, source.indexOf('--append-system-prompt', start));
|
||||
expect(spawnEnv).toContain("BROWSE_SESSION: '',");
|
||||
const lookup: ProcLookup = () => ({ ppid: 0, comm: 'claude' });
|
||||
expect(chooseSession({ hasTab: false, flag: undefined, env: { BROWSE_SESSION: '' }, parentPid: 7, lookup })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI retry after a token change', () => {
|
||||
test('the retried request keeps the --session the caller named', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-session-retry-'));
|
||||
try {
|
||||
const stateFile = path.join(dir, 'browse.json');
|
||||
fs.writeFileSync(stateFile, JSON.stringify({ port: 9, token: 'fresh-token' }));
|
||||
const script = path.join(dir, 'retry.ts');
|
||||
fs.writeFileSync(script, `
|
||||
const bodies = [];
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
bodies.push(JSON.parse(init.body));
|
||||
return bodies.length === 1 ? new Response('Unauthorized', { status: 401 }) : new Response('ok');
|
||||
};
|
||||
const { sendCommand } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cli.ts'))});
|
||||
await sendCommand({ port: 9, token: 'stale-token' }, 'click', ['#delete', '--session', 'intended']);
|
||||
console.log('BODIES=' + JSON.stringify(bodies));
|
||||
`);
|
||||
const r = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, BROWSE_STATE_FILE: stateFile, BROWSE_SESSION: '' } });
|
||||
const bodies = JSON.parse(r.stdout.match(/BODIES=(.*)/)?.[1] ?? '[]');
|
||||
expect(bodies).toHaveLength(2);
|
||||
for (const body of bodies) expect(body).toMatchObject({ command: 'click', args: ['#delete'], session: 'intended' });
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-render browse calls', () => {
|
||||
test('turn the automatic session off: render pins its own tab ids', () => {
|
||||
const source = fs.readFileSync(path.resolve(import.meta.dir, '../../lib/aside-render.ts'), 'utf8');
|
||||
expect(source).toContain("const browseEnv = { ...process.env, BROWSE_SESSION: '' };");
|
||||
const calls = source.match(/runProc\(bin,[^\n]*/g) ?? [];
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const call of calls) expect(call).toContain('browseEnv');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Per-session tabs (#1961) through the real command dispatch (server.ts
|
||||
* handleCommandInternal on the module's own BrowserManager). Four sessions
|
||||
* drive their own tabs at the same time; a slow fixture route forces the
|
||||
* commands to overlap instead of relying on scheduler timing. The first two
|
||||
* steps are the before: unsessioned callers share one tab, and the BROWSE_TAB
|
||||
* pin moved the global active tab for everyone else while it ran. Runs in a
|
||||
* child process so the module-level server state never touches this process.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
let dir = '';
|
||||
let steps: Record<string, any> = {};
|
||||
|
||||
const SCRIPT = (root: string) => `
|
||||
import { __testInternals__ } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))};
|
||||
const fixture = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
|
||||
const u = new URL(req.url);
|
||||
const s = u.searchParams.get('s') || 'x';
|
||||
const ms = Number(u.searchParams.get('ms') || 0);
|
||||
if (ms) await Bun.sleep(ms);
|
||||
const html = '<!doctype html><title>' + s + '</title><h1>' + s + '</h1><input id="q" aria-label="query">'
|
||||
+ '<button onclick="document.title = document.title + \\'-clicked\\'">Go ' + s + '</button>';
|
||||
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
|
||||
} });
|
||||
const U = (s, ms = 0) => 'http://127.0.0.1:' + fixture.port + '/page?s=' + s + '&ms=' + ms;
|
||||
const bm = __testInternals__.browserManager;
|
||||
const run = (command, args = [], extra = {}, tokenInfo = null) => __testInternals__.handleCommandInternal({ command, args, ...extra }, tokenInfo);
|
||||
const owned = (owner) => [...bm.pages.keys()].filter(id => bm.getTabOwner(id) === owner);
|
||||
const steps = {};
|
||||
await bm.launch();
|
||||
await run('goto', [U('root')]);
|
||||
const rootTab = bm.getActiveTabId();
|
||||
|
||||
{ // before: two unsessioned agents share the one active tab
|
||||
const a = run('goto', [U('a', 400)]);
|
||||
await Bun.sleep(100);
|
||||
const b = run('goto', [U('b')]);
|
||||
await Promise.all([a, b]);
|
||||
steps.shared = { title: (await run('js', ['document.title'])).result };
|
||||
await run('goto', [U('root')]);
|
||||
}
|
||||
|
||||
{ // BROWSE_TAB pin: an unsessioned caller during the pinned command, and the global tab afterwards
|
||||
const t2 = JSON.parse((await run('newtab', ['--json'])).result).tabId;
|
||||
const t3 = JSON.parse((await run('newtab', ['--json'])).result).tabId;
|
||||
bm.switchTab(rootTab, { bringToFront: false });
|
||||
const pinned = run('goto', [U('p', 400)], { tabId: t2 });
|
||||
await Bun.sleep(100);
|
||||
const during = (await run('url')).result;
|
||||
await pinned;
|
||||
const p1 = run('goto', [U('p1', 200)], { tabId: t2 });
|
||||
await Bun.sleep(100);
|
||||
const p2 = run('goto', [U('p2', 400)], { tabId: t3 });
|
||||
await Promise.all([p1, p2]);
|
||||
steps.pin = { during, activeAfter: bm.getActiveTabId(), rootTab };
|
||||
await bm.closeTab(t2); await bm.closeTab(t3);
|
||||
bm.switchTab(rootTab, { bringToFront: false });
|
||||
}
|
||||
|
||||
{ // four sessions at once, three rounds, overlapping on the slow route
|
||||
const names = ['a', 'b', 'c', 'd'];
|
||||
const seen = [];
|
||||
const flow = async (s, round) => {
|
||||
await run('goto', [U(s, 60 * (names.indexOf(s) + 1) + 40 * round)], { session: s });
|
||||
const snap = (await run('snapshot', ['-i'], { session: s })).result;
|
||||
await run('click', ['button'], { session: s });
|
||||
await run('fill', ['#q', 'hello-' + s], { session: s });
|
||||
const title = (await run('js', ['document.title'], { session: s })).result;
|
||||
const value = (await run('js', ["document.querySelector('#q').value"], { session: s })).result;
|
||||
seen.push({ s, round, snapOk: snap.includes('Go ' + s), title, value });
|
||||
};
|
||||
let unsessioned = '';
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const all = Promise.all(names.map(s => flow(s, round)));
|
||||
await Bun.sleep(80);
|
||||
unsessioned = (await run('url')).result;
|
||||
await all;
|
||||
}
|
||||
steps.sessions = { seen, unsessioned, activeAfter: bm.getActiveTabId(), rootTab,
|
||||
tabsPer: names.map(s => owned('session:' + s).length) };
|
||||
}
|
||||
|
||||
{ // first use from four parallel commands opens one tab
|
||||
await Promise.all([1, 2, 3, 4].map(() => run('url', [], { session: 'e' })));
|
||||
steps.firstUse = { tabs: owned('session:e').length };
|
||||
}
|
||||
|
||||
{ // chain subcommands stay on the session tab
|
||||
const r = await run('chain', [JSON.stringify([['goto', U('ch', 200)], ['js', 'document.title']])], { session: 'a' });
|
||||
steps.chain = { result: r.result, rootUrl: (await run('url')).result };
|
||||
}
|
||||
|
||||
{ // per-session newtab and tabs
|
||||
await run('newtab', [U('n2')], { session: 'c' });
|
||||
steps.newtab = { title: (await run('js', ['document.title'], { session: 'c' })).result,
|
||||
tabsC: owned('session:c').length, activeAfter: bm.getActiveTabId(), rootTab,
|
||||
tabs: (await run('tabs', [], { session: 'c' })).result };
|
||||
}
|
||||
|
||||
{ // a closed session tab: reads fail with the reset code, goto reopens
|
||||
const id = owned('session:a')[0];
|
||||
await bm.closeTab(id);
|
||||
const text = await run('text', [], { session: 'a' });
|
||||
const goto = await run('goto', [U('a2')], { session: 'a' });
|
||||
steps.reset = { textStatus: text.status, text: text.result, gotoStatus: goto.status, goto: goto.result,
|
||||
title: (await run('js', ['document.title'], { session: 'a' })).result };
|
||||
}
|
||||
|
||||
{ // context recreation keeps the session's page
|
||||
await run('goto', [U('rb')], { session: 'b' });
|
||||
const ua = await run('useragent', ['Mozilla/5.0 gstack-session-test'], { session: 'b' });
|
||||
steps.rebuild = { uaStatus: ua.status, url: (await run('url', [], { session: 'b' })).result, tabsB: owned('session:b').length };
|
||||
}
|
||||
|
||||
{ // context recreation under a tab pin keeps every tab and the global active tab
|
||||
const home = bm.getActiveTabId();
|
||||
const t = JSON.parse((await run('newtab', [U('pinrb'), '--json'])).result).tabId;
|
||||
bm.switchTab(home, { bringToFront: false });
|
||||
const before = bm.pages.size;
|
||||
const ua = await run('useragent', ['Mozilla/5.0 gstack-pin-test'], { tabId: t });
|
||||
const pinned = [...bm.pages.entries()].find(([, p]) => p.url().includes('s=pinrb'))?.[0];
|
||||
steps.pinRebuild = { status: ua.status, result: ua.result, before, after: bm.pages.size, rootUrl: (await run('url')).result,
|
||||
pinnedUrl: pinned === undefined ? null : (await run('url', [], { tabId: pinned })).result };
|
||||
if (pinned !== undefined) await bm.closeTab(pinned);
|
||||
}
|
||||
|
||||
{ // only root may name a session; names are validated
|
||||
const scoped = { token: 't', clientId: 'agent-x', type: 'session', scopes: ['read', 'write', 'admin', 'meta'], tabPolicy: 'shared', rateLimit: 0, createdAt: new Date().toISOString(), commandCount: 0 };
|
||||
const denied = await run('url', [], { session: 'z' }, scoped);
|
||||
const bad = await run('url', [], { session: 'bad name!' });
|
||||
steps.auth = { deniedStatus: denied.status, denied: denied.result, zTabs: owned('session:z').length, badStatus: bad.status, bad: bad.result };
|
||||
}
|
||||
|
||||
{ // idle sessions are closed on the next session lookup
|
||||
process.env.BROWSE_SESSION_IDLE_MINUTES = '0.01';
|
||||
await Bun.sleep(1200);
|
||||
await run('url', [], { session: 'fresh' });
|
||||
steps.sweep = { a: owned('session:a').length, d: owned('session:d').length, fresh: owned('session:fresh').length };
|
||||
}
|
||||
|
||||
{ // a session the CLI derived on its own is ignored for a scoped token
|
||||
const scoped = { token: 't', clientId: 'agent-y', type: 'session', scopes: ['read', 'write', 'admin', 'meta'], tabPolicy: 'shared', rateLimit: 0, createdAt: new Date().toISOString(), commandCount: 0 };
|
||||
const auto = await run('url', [], { session: 'auto1', sessionAuto: true }, scoped);
|
||||
steps.auto = { status: auto.status, tabs: owned('session:auto1').length };
|
||||
}
|
||||
|
||||
{ // the session cap: refused while every other session is busy, LRU eviction once one is idle
|
||||
process.env.BROWSE_SESSION_IDLE_MINUTES = '0';
|
||||
process.env.BROWSE_MAX_SESSIONS = '1';
|
||||
const busy = run('goto', [U('busy', 600)], { session: 'fresh' });
|
||||
await Bun.sleep(150);
|
||||
const limited = await run('url', [], { session: 'limit' });
|
||||
await busy;
|
||||
const freshTab = owned('session:fresh')[0];
|
||||
const evicted = await run('url', [], { session: 'limit2' });
|
||||
steps.limit = { status: limited.status, result: limited.result, limitTabs: owned('session:limit').length,
|
||||
evictedStatus: evicted.status, fresh: owned('session:fresh').length, freshOpen: bm.pages.has(freshTab),
|
||||
limit2: owned('session:limit2').length };
|
||||
process.env.BROWSE_MAX_SESSIONS = '16';
|
||||
const victim = await run('url', [], { session: 'fresh' });
|
||||
steps.evictedRead = { status: victim.status, result: victim.result, tabs: owned('session:fresh').length };
|
||||
await run('goto', [U('back')], { session: 'fresh' });
|
||||
steps.evictedRead.reopened = (await run('js', ['document.title'], { session: 'fresh' })).result;
|
||||
process.env.BROWSE_MAX_SESSIONS = '1';
|
||||
}
|
||||
|
||||
{ // in headed mode an evicted session's tab is released to the user, not closed
|
||||
const tab = owned('session:limit2')[0];
|
||||
const mode = bm.getConnectionMode;
|
||||
bm.getConnectionMode = () => 'headed';
|
||||
try { await run('url', [], { session: 'limit3' }); } finally { bm.getConnectionMode = mode; }
|
||||
steps.headed = { open: bm.pages.has(tab), owner: bm.getTabOwner(tab) ?? null, limit2: owned('session:limit2').length };
|
||||
}
|
||||
|
||||
{ // tab <id> inside a session sticks for the session's next command
|
||||
await run('goto', [U('mine')], { session: 'pick' });
|
||||
const other = JSON.parse((await run('newtab', [U('theirs'), '--json'])).result).tabId;
|
||||
const switched = await run('tab', [String(other)], { session: 'pick' });
|
||||
const next = (await run('js', ['document.title'], { session: 'pick' })).result;
|
||||
steps.pick = { switched: switched.status, next, owner: bm.getTabOwner(other) ?? null };
|
||||
}
|
||||
|
||||
{ // another session's context rebuild leaves this session on the tab it picked
|
||||
process.env.BROWSE_MAX_SESSIONS = '16';
|
||||
await run('goto', [U('first')], { session: 'two' });
|
||||
const first = owned('session:two')[0];
|
||||
await run('newtab', [U('second')], { session: 'two' });
|
||||
await run('tab', [String(first)], { session: 'two' });
|
||||
const ua = await run('useragent', ['Mozilla/5.0 gstack-other-rebuild'], { session: 'other' });
|
||||
steps.otherRebuild = { ua: ua.status, title: (await run('js', ['document.title'], { session: 'two' })).result };
|
||||
}
|
||||
|
||||
{ // a first-use notice never prefixes --json output
|
||||
const r = await run('newtab', [U('j'), '--json'], { session: 'jsonfirst' });
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(r.result); } catch {}
|
||||
steps.jsonFirst = { status: r.status, parsed: parsed !== null && typeof parsed.tabId === 'number' };
|
||||
}
|
||||
|
||||
{ // headed mode: a session's new tab is registered once and the user's active tab stays put
|
||||
const ctx = bm.context;
|
||||
const listener = (page) => {
|
||||
const id = bm.nextTabId++;
|
||||
bm.pages.set(id, page);
|
||||
bm.tabSessions.set(id, new (bm.tabSessions.values().next().value.constructor)(page));
|
||||
bm.activeTabId = id;
|
||||
};
|
||||
ctx.on('page', listener);
|
||||
const userTab = bm.getActiveTabId();
|
||||
const before = bm.pages.size;
|
||||
try { await run('url', [], { session: 'headednew' }); } finally { ctx.off('page', listener); }
|
||||
const tab = owned('session:headednew')[0];
|
||||
steps.headedNew = { activeKept: bm.getActiveTabId() === userTab, added: bm.pages.size - before,
|
||||
sameObject: [...bm.pages.values()].filter(p => p === bm.pages.get(tab)).length };
|
||||
}
|
||||
|
||||
{ // headed viewport auto under a tab pin rebuilds the pinned tab, not the global one
|
||||
const userTab = bm.getActiveTabId();
|
||||
const userPage = bm.pages.get(userTab);
|
||||
const pinned = JSON.parse((await run('newtab', [U('vp'), '--json'])).result).tabId;
|
||||
bm.switchTab(userTab, { bringToFront: false });
|
||||
const mode = bm.connectionMode;
|
||||
const listener = (page) => {
|
||||
const id = bm.nextTabId++;
|
||||
bm.pages.set(id, page);
|
||||
bm.tabSessions.set(id, new (bm.tabSessions.values().next().value.constructor)(page));
|
||||
bm.activeTabId = id;
|
||||
};
|
||||
bm.context.on('page', listener);
|
||||
bm.connectionMode = 'headed';
|
||||
let r;
|
||||
try { r = await run('viewport', ['auto'], { tabId: pinned }); } finally { bm.connectionMode = mode; bm.context.off('page', listener); }
|
||||
const pinnedPage = bm.pages.get(pinned);
|
||||
steps.headedViewport = { status: r.status, userTracked: bm.pages.get(userTab) === userPage,
|
||||
pinnedLive: bm.pages.has(pinned), pinnedUrl: pinnedPage?.url() ?? null, activeKept: bm.getActiveTabId() === userTab,
|
||||
sameObject: [...bm.pages.values()].filter(p => p === pinnedPage).length, rootUrl: (await run('url')).result };
|
||||
}
|
||||
|
||||
{ // state save and load inside a session land the session on its own saved page
|
||||
await run('goto', [U('st')], { session: 'st' });
|
||||
const save = await run('state', ['save', 'gsta227-session'], { session: 'st' });
|
||||
const load = await run('state', ['load', 'gsta227-session'], { session: 'st' });
|
||||
const after = await run('js', ['document.title'], { session: 'st' });
|
||||
steps.stateLoad = { save: save.status, load: load.status, loadText: load.result, afterStatus: after.status, title: after.result,
|
||||
rootStatus: (await run('url')).status };
|
||||
}
|
||||
|
||||
await Bun.write(${JSON.stringify(path.join(root, 'steps.json'))}, JSON.stringify(steps));
|
||||
fixture.stop(true);
|
||||
await Promise.race([bm.close(), Bun.sleep(3000)]);
|
||||
process.exit(0);
|
||||
`;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'browse-session-tabs-')));
|
||||
fs.writeFileSync(path.join(dir, 'drive.ts'), SCRIPT(dir));
|
||||
const r = spawnSync(process.execPath, [path.join(dir, 'drive.ts')], {
|
||||
encoding: 'utf8', timeout: 150_000,
|
||||
env: { ...process.env, GSTACK_HOME: path.join(dir, 'state'), CHROMIUM_PROFILE: path.join(dir, 'profile'),
|
||||
BROWSE_STATE_FILE: path.join(dir, 'state', 'browse.json'), BROWSE_PARENT_PID: '0' },
|
||||
});
|
||||
if (!fs.existsSync(path.join(dir, 'steps.json'))) throw new Error(`driver failed (${r.status}): ${r.stderr.slice(-2000)}`);
|
||||
steps = JSON.parse(fs.readFileSync(path.join(dir, 'steps.json'), 'utf8'));
|
||||
}, 180_000);
|
||||
afterAll(() => { if (dir) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
describe('before: callers without a session share one tab', () => {
|
||||
test('the second agent navigates the first agent away (negative control)', () => {
|
||||
expect(steps.shared.title).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pinned tab no longer moves the global active tab', () => {
|
||||
test('an unsessioned caller during a pinned command still sees its own tab', () => {
|
||||
expect(steps.pin.during).toContain('s=root');
|
||||
});
|
||||
test('overlapping pinned commands leave the global active tab where it was', () => {
|
||||
expect(steps.pin.activeAfter).toBe(steps.pin.rootTab);
|
||||
});
|
||||
});
|
||||
|
||||
describe('four sessions at once (#1961)', () => {
|
||||
test('every session only ever sees its own page', () => {
|
||||
expect(steps.sessions.seen).toHaveLength(12);
|
||||
for (const row of steps.sessions.seen) {
|
||||
expect(row).toMatchObject({ snapOk: true, title: row.s + '-clicked', value: 'hello-' + row.s });
|
||||
}
|
||||
});
|
||||
test('each session owns one tab and the global active tab is untouched', () => {
|
||||
expect(steps.sessions.tabsPer).toEqual([1, 1, 1, 1]);
|
||||
expect(steps.sessions.activeAfter).toBe(steps.sessions.rootTab);
|
||||
expect(steps.sessions.unsessioned).toContain('s=root');
|
||||
});
|
||||
test('parallel first commands of one session open a single tab', () => {
|
||||
expect(steps.firstUse.tabs).toBe(1);
|
||||
});
|
||||
test('chain subcommands run on the session tab', () => {
|
||||
expect(steps.chain.result).toContain('ch');
|
||||
expect(steps.chain.rootUrl).toContain('s=root');
|
||||
});
|
||||
test('newtab inside a session moves only that session', () => {
|
||||
expect(steps.newtab).toMatchObject({ title: 'n2', tabsC: 2 });
|
||||
expect(steps.newtab.activeAfter).toBe(steps.newtab.rootTab);
|
||||
expect(steps.newtab.tabs).toContain('session:c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session lifecycle', () => {
|
||||
test('a read on a closed session tab fails with the last URL; goto reopens it', () => {
|
||||
expect(steps.reset.textStatus).not.toBe(200);
|
||||
expect(steps.reset.text).toContain('BROWSE_SESSION_TAB_RESET');
|
||||
expect(steps.reset.text).toContain('s=ch');
|
||||
expect(steps.reset.gotoStatus).toBe(200);
|
||||
expect(steps.reset.title).toBe('a2');
|
||||
});
|
||||
test('context recreation keeps the session on its page', () => {
|
||||
expect(steps.rebuild.uaStatus).toBe(200);
|
||||
expect(steps.rebuild.url).toContain('s=rb');
|
||||
expect(steps.rebuild.tabsB).toBe(1);
|
||||
});
|
||||
test('context recreation under a tab pin keeps every tab', () => {
|
||||
expect(steps.pinRebuild.status).toBe(200);
|
||||
expect(steps.pinRebuild.result).not.toContain('Browser reset');
|
||||
expect(steps.pinRebuild.after).toBe(steps.pinRebuild.before);
|
||||
expect(steps.pinRebuild.rootUrl).toContain('s=root');
|
||||
expect(steps.pinRebuild.pinnedUrl).toContain('s=pinrb');
|
||||
});
|
||||
test('a scoped token cannot name a session and opens no tab', () => {
|
||||
expect(steps.auth.deniedStatus).toBe(403);
|
||||
expect(steps.auth.denied).toContain('BROWSE_SESSION_ROOT_ONLY');
|
||||
expect(steps.auth.zTabs).toBe(0);
|
||||
});
|
||||
test('an invalid session name is refused', () => {
|
||||
expect(steps.auth.badStatus).toBe(400);
|
||||
expect(steps.auth.bad).toContain('BROWSE_SESSION_INVALID');
|
||||
});
|
||||
test('idle sessions are closed on the next lookup', () => {
|
||||
expect(steps.sweep).toEqual({ a: 0, d: 0, fresh: 1 });
|
||||
});
|
||||
test('an automatic session from a scoped token falls through and opens no tab', () => {
|
||||
expect(steps.auto).toEqual({ status: 200, tabs: 0 });
|
||||
});
|
||||
test('at the cap a new session is refused while the others are busy', () => {
|
||||
expect(steps.limit.status).toBe(429);
|
||||
expect(steps.limit.result).toContain('BROWSE_SESSION_LIMIT');
|
||||
expect(steps.limit.limitTabs).toBe(0);
|
||||
});
|
||||
test('at the cap the least recently used idle session is evicted', () => {
|
||||
expect(steps.limit).toMatchObject({ evictedStatus: 200, fresh: 0, freshOpen: false, limit2: 1 });
|
||||
});
|
||||
test('tab <id> in a session moves that session to the tab for its next command', () => {
|
||||
expect(steps.pick).toEqual({ switched: 200, next: 'theirs', owner: null });
|
||||
});
|
||||
test('a session evicted at the cap reports the reset on its next read; goto reopens it', () => {
|
||||
expect(steps.evictedRead.status).toBe(409);
|
||||
expect(steps.evictedRead.result).toContain('BROWSE_SESSION_TAB_RESET');
|
||||
expect(steps.evictedRead.result).toContain('s=busy');
|
||||
expect(steps.evictedRead.tabs).toBe(0);
|
||||
expect(steps.evictedRead.reopened).toBe('back');
|
||||
});
|
||||
test('another session\'s context rebuild keeps this session on the tab it picked', () => {
|
||||
expect(steps.otherRebuild).toEqual({ ua: 200, title: 'first' });
|
||||
});
|
||||
test('the first-use notice never corrupts --json output', () => {
|
||||
expect(steps.jsonFirst).toEqual({ status: 200, parsed: true });
|
||||
});
|
||||
test('headed: a session tab is registered once and the user keeps the active tab', () => {
|
||||
expect(steps.headedNew).toEqual({ activeKept: true, added: 1, sameObject: 1 });
|
||||
});
|
||||
test('headed: viewport auto under a tab pin rebuilds only the pinned tab', () => {
|
||||
expect(steps.headedViewport).toMatchObject({ status: 200, userTracked: true, pinnedLive: true, activeKept: true, sameObject: 1 });
|
||||
expect(steps.headedViewport.rootUrl).not.toContain('s=vp');
|
||||
expect(steps.headedViewport.pinnedUrl).toContain('s=vp');
|
||||
});
|
||||
test('state save and load inside a session leave it on its saved page', () => {
|
||||
expect(steps.stateLoad).toMatchObject({ save: 200, load: 200, afterStatus: 200, title: 'st', rootStatus: 200 });
|
||||
});
|
||||
test('in headed mode an evicted session keeps its tab open and unowned', () => {
|
||||
expect(steps.headed).toEqual({ open: true, owner: null, limit2: 0 });
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,11 @@ describe('token-registry', () => {
|
||||
expect(() => createToken({ clientId: ' root ' })).toThrow(ReservedClientIdError);
|
||||
});
|
||||
|
||||
it('createToken rejects the session: prefix agent sessions own tabs under', () => {
|
||||
expect(() => createToken({ clientId: 'session:agent-1234' })).toThrow(ReservedClientIdError);
|
||||
expect(() => createToken({ clientId: 'Session:x' })).toThrow(ReservedClientIdError);
|
||||
});
|
||||
|
||||
it('createToken rejects empty / whitespace clientId', () => {
|
||||
expect(() => createToken({ clientId: '' })).toThrow(ReservedClientIdError);
|
||||
expect(() => createToken({ clientId: ' ' })).toThrow(ReservedClientIdError);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Phone emulation and touch input on gstack's own Chromium (#3058, GSTA-227 F3).
|
||||
*
|
||||
* Drives a real in-process BrowserManager (same harness as snapshot.test.ts)
|
||||
* against browse/test/fixtures/touch.html: a touch logger, a swipe carousel,
|
||||
* a two-finger zoom surface and a joystick plus fire button.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { startTestServer } from './test-server';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { handleWriteCommand } from '../src/write-commands';
|
||||
import { handleMetaCommand } from '../src/meta-commands';
|
||||
import { WRITE_COMMANDS, COMMAND_DESCRIPTIONS } from '../src/commands';
|
||||
import { SCOPE_WRITE } from '../src/token-registry';
|
||||
import { TUNNEL_COMMANDS } from '../src/routes/commands';
|
||||
|
||||
const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE;
|
||||
let CHROMIUM_PROFILE_DIR: string | undefined;
|
||||
let testServer: ReturnType<typeof startTestServer>;
|
||||
let bm: BrowserManager;
|
||||
let baseUrl: string;
|
||||
const shutdown = async () => {};
|
||||
const VERBS = ['device', 'tap', 'swipe', 'pinch', 'touch'];
|
||||
setDefaultTimeout(20_000);
|
||||
|
||||
beforeAll(async () => {
|
||||
CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-'));
|
||||
process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR;
|
||||
testServer = startTestServer(0);
|
||||
baseUrl = testServer.url;
|
||||
bm = new BrowserManager();
|
||||
await bm.launch();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { testServer.server.stop(true); } catch {}
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE;
|
||||
else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE;
|
||||
if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} }
|
||||
});
|
||||
|
||||
const run = (cmd: string, args: string[] = []) => handleWriteCommand(cmd, args, bm.getActiveSession(), bm);
|
||||
const page = () => bm.getActiveSession().getPage();
|
||||
const probe = () => page().evaluate(() => ({
|
||||
coarse: matchMedia('(pointer: coarse)').matches,
|
||||
maxTouchPoints: navigator.maxTouchPoints,
|
||||
ua: navigator.userAgent,
|
||||
width: innerWidth,
|
||||
}));
|
||||
const data = (sel: string, key: string) => page().evaluate(([s, k]) => (document.querySelector(s) as HTMLElement).dataset[k], [sel, key]);
|
||||
|
||||
async function freshTouchTab(device: string | null = 'iPhone 15'): Promise<number> {
|
||||
const id = await bm.newTab(baseUrl + '/touch.html');
|
||||
bm.switchTab(id, { bringToFront: false });
|
||||
if (device) await run('device', [device]);
|
||||
await page().reload({ waitUntil: 'domcontentloaded' });
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('device and touch: registration', () => {
|
||||
test('verbs are write commands with descriptions, in the write scope, never on the tunnel', () => {
|
||||
for (const verb of VERBS) {
|
||||
expect(WRITE_COMMANDS.has(verb)).toBe(true);
|
||||
expect(SCOPE_WRITE.has(verb)).toBe(true);
|
||||
expect(TUNNEL_COMMANDS.has(verb)).toBe(false);
|
||||
expect(COMMAND_DESCRIPTIONS[verb]?.usage).toContain(verb);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('device', () => {
|
||||
test('unknown name suggests the nearest names and leaves the tab alone', async () => {
|
||||
await freshTouchTab(null);
|
||||
const before = await probe();
|
||||
let message = '';
|
||||
try { await run('device', ['iPhone 99']); } catch (err: any) { message = err.message; }
|
||||
expect(message).toContain('BROWSE_DEVICE_UNKNOWN');
|
||||
expect(message).toContain('iPhone 99');
|
||||
expect(message).toContain('fix: $B device --list');
|
||||
expect(message.match(/iPhone \d/g)?.length ?? 0).toBeGreaterThanOrEqual(3);
|
||||
expect(await probe()).toEqual(before);
|
||||
expect(await run('device')).toContain('none');
|
||||
});
|
||||
|
||||
test('--list prints Playwright device names', async () => {
|
||||
const list = await run('device', ['--list']);
|
||||
expect(list).toContain('iPhone 15');
|
||||
expect(list).toContain('Pixel 7');
|
||||
});
|
||||
|
||||
test('emulates the phone on this tab only; device off restores', async () => {
|
||||
const desktopId = await freshTouchTab(null);
|
||||
const desktop = await probe();
|
||||
await freshTouchTab('iphone 15');
|
||||
const phone = await probe();
|
||||
expect(phone.coarse).toBe(true);
|
||||
expect(phone.maxTouchPoints).toBe(5);
|
||||
expect(phone.ua).toContain('iPhone');
|
||||
expect(phone.width).toBe(393);
|
||||
expect(await run('device')).toContain('iPhone 15');
|
||||
|
||||
const other = bm.getSession(desktopId).getPage();
|
||||
const otherNow = await other.evaluate(() => ({ coarse: matchMedia('(pointer: coarse)').matches, maxTouchPoints: navigator.maxTouchPoints, ua: navigator.userAgent, width: innerWidth }));
|
||||
expect(otherNow).toEqual(desktop);
|
||||
expect(otherNow.ua).not.toContain('iPhone');
|
||||
|
||||
await run('device', ['off']);
|
||||
await page().reload({ waitUntil: 'domcontentloaded' });
|
||||
expect(await probe()).toEqual(desktop);
|
||||
expect(await run('device')).toContain('none');
|
||||
});
|
||||
|
||||
test('--landscape swaps the orientation', async () => {
|
||||
await freshTouchTab(null);
|
||||
await run('device', ['Pixel 7', '--landscape']);
|
||||
const p = await page().evaluate(() => ({ w: innerWidth, h: innerHeight }));
|
||||
expect(p.w).toBeGreaterThan(p.h);
|
||||
await run('device', ['off']);
|
||||
});
|
||||
|
||||
test('viewport and responsive refuse while a device is active', async () => {
|
||||
await freshTouchTab();
|
||||
await expect(run('viewport', ['800x600'])).rejects.toThrow('fix: $B device off');
|
||||
await expect(handleMetaCommand('responsive', [], bm, shutdown)).rejects.toThrow('fix: $B device off');
|
||||
expect((await probe()).width).toBe(393);
|
||||
await run('device', ['off']);
|
||||
});
|
||||
|
||||
test('screenshots capture the phone and keep its metrics', async () => {
|
||||
await freshTouchTab();
|
||||
const out = path.join(os.tmpdir(), `touch-shot-${process.pid}.png`);
|
||||
for (const extra of [[], ['--viewport'], ['#zoom'], ['--clip', '0,0,50,50']]) {
|
||||
await handleMetaCommand('screenshot', [...extra, out], bm, shutdown);
|
||||
const png = fs.readFileSync(out);
|
||||
if (extra.length === 0 || extra[0] === '--viewport') expect(png.readUInt32BE(16)).toBe(393 * 3);
|
||||
const p = await probe();
|
||||
expect({ extra, width: p.width, coarse: p.coarse, touch: p.maxTouchPoints }).toEqual({ extra, width: 393, coarse: true, touch: 5 });
|
||||
}
|
||||
fs.rmSync(out, { force: true });
|
||||
await run('device', ['off']);
|
||||
});
|
||||
|
||||
test('annotated and heatmap snapshots and prettyscreenshot keep the phone', async () => {
|
||||
await freshTouchTab();
|
||||
const out = path.join(os.tmpdir(), `touch-annotate-${process.pid}.png`);
|
||||
const captures: Array<[string, string[]]> = [
|
||||
['snapshot', ['-i', '-a', '-o', out]],
|
||||
['snapshot', ['-i', '-H', '{"@e1":"green"}', '-o', out]],
|
||||
['prettyscreenshot', [out]],
|
||||
];
|
||||
for (const [cmd, args] of captures) {
|
||||
if (cmd === 'prettyscreenshot') await run(cmd, args);
|
||||
else await handleMetaCommand(cmd, args, bm, shutdown);
|
||||
const p = await probe();
|
||||
expect({ cmd, args, width: p.width, coarse: p.coarse }).toEqual({ cmd, args, width: 393, coarse: true });
|
||||
}
|
||||
fs.rmSync(out, { force: true });
|
||||
await run('device', ['off']);
|
||||
});
|
||||
|
||||
test('survives a useragent context rebuild; the first restored request carries the phone UA', async () => {
|
||||
const id = await bm.newTab(baseUrl + '/echo');
|
||||
bm.switchTab(id, { bringToFront: false });
|
||||
await run('device', ['iPhone 15']);
|
||||
await run('useragent', ['GstackDesktop/1.0']);
|
||||
expect(await run('device')).toContain('iPhone 15');
|
||||
const echoed = JSON.parse(await page().evaluate(() => document.body.innerText));
|
||||
expect(echoed['user-agent']).toContain('iPhone');
|
||||
expect((await probe()).coarse).toBe(true);
|
||||
await run('device', ['off']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touch input', () => {
|
||||
test('touch verbs need an active device', async () => {
|
||||
await freshTouchTab(null);
|
||||
await expect(run('tap', ['#fire'])).rejects.toThrow('BROWSE_TOUCH_NO_DEVICE');
|
||||
await expect(run('touch', ['down', '0', '10', '10'])).rejects.toThrow('fix: $B device "iPhone 15"');
|
||||
});
|
||||
|
||||
test('tap fires touchstart and touchend', async () => {
|
||||
await freshTouchTab();
|
||||
await run('tap', ['#fire']);
|
||||
expect(await data('#fire', 'shots')).toBe('1');
|
||||
const types = await page().evaluate(() => (window as any).__log.map((e: any) => e.type));
|
||||
expect(types).toContain('touchstart');
|
||||
expect(types).toContain('touchend');
|
||||
await run('tap', ['240', '440', '--hold', '50']);
|
||||
expect(await data('#fire', 'shots')).toBe('2');
|
||||
});
|
||||
|
||||
test('swipe left advances the carousel; right goes back', async () => {
|
||||
await freshTouchTab();
|
||||
await run('swipe', ['#carousel', 'left', '--distance', '200']);
|
||||
expect(await data('#carousel', 'index')).toBe('1');
|
||||
await run('swipe', ['#carousel', 'right', '--distance', '200', '--ms', '100']);
|
||||
expect(await data('#carousel', 'index')).toBe('0');
|
||||
});
|
||||
|
||||
test('pinch spreads above 1 and pinches below 1', async () => {
|
||||
await freshTouchTab();
|
||||
await run('pinch', ['#zoom', '2']);
|
||||
const spread = Number(await data('#zoom', 'scale'));
|
||||
expect(spread).toBeGreaterThan(1.5);
|
||||
await run('pinch', ['#zoom', '0.5']);
|
||||
expect(Number(await data('#zoom', 'scale'))).toBeLessThan(spread * 0.7);
|
||||
});
|
||||
|
||||
test('holding finger 0 while tapping finger 1 leaves finger 0 down', async () => {
|
||||
await freshTouchTab();
|
||||
await run('touch', ['down', '0', '70', '440']);
|
||||
await run('touch', ['down', '1', '240', '440']);
|
||||
await run('touch', ['up', '1']);
|
||||
expect(await data('#fire', 'shots')).toBe('1');
|
||||
expect(await data('#joystick', 'held')).toBe('true');
|
||||
expect(await page().evaluate(() => (window as any).__log.at(-1).touches)).toBe(1);
|
||||
const held = await run('touch');
|
||||
expect(held).toContain('0');
|
||||
expect(held).not.toMatch(/\b1\b.*\(/);
|
||||
await run('touch', ['move', '0', '90', '440']);
|
||||
await run('touch', ['up', '0']);
|
||||
expect(await data('#joystick', 'held')).toBe('false');
|
||||
expect(await run('touch')).toContain('none');
|
||||
});
|
||||
|
||||
test('usage errors name the problem and give a fix', async () => {
|
||||
await freshTouchTab();
|
||||
const message = (cmd: string, args: string[]) => run(cmd, args).then(() => '', (err: Error) => err.message);
|
||||
expect(await message('touch', ['up', '3'])).toMatch(/finger 3 is not down[\s\S]*fix:/);
|
||||
expect(await message('touch', ['down', '12', '1', '1'])).toMatch(/0-9[\s\S]*fix:/);
|
||||
expect(await message('swipe', ['#carousel', 'sideways'])).toMatch(/up\|down\|left\|right[\s\S]*fix:/);
|
||||
expect(await message('pinch', ['#zoom', '-1'])).toMatch(/scale[\s\S]*fix:/);
|
||||
});
|
||||
|
||||
test('held fingers are cancelled on navigation and by touch cancel', async () => {
|
||||
await freshTouchTab();
|
||||
await run('touch', ['down', '0', '70', '440']);
|
||||
expect(await run('touch')).toContain('0');
|
||||
await page().goto(baseUrl + '/touch.html', { waitUntil: 'domcontentloaded' });
|
||||
expect(await run('touch')).toContain('none');
|
||||
await run('touch', ['down', '2', '240', '440']);
|
||||
expect(await data('#fire', 'shots')).toBe('1');
|
||||
await run('touch', ['cancel']);
|
||||
expect(await run('touch')).toContain('none');
|
||||
expect(await page().evaluate(() => (window as any).__log.at(-1).type)).toBe('touchcancel');
|
||||
});
|
||||
|
||||
test('touch cancel with no finger held is a no-op, not a protocol error', async () => {
|
||||
await freshTouchTab();
|
||||
expect(await run('touch', ['cancel'])).toContain('nothing to cancel');
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,8 @@ export interface BrowseClientOptions {
|
||||
token?: string;
|
||||
/** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */
|
||||
tabId?: number;
|
||||
/** Session whose own tab runs every command (root token only; tabId wins when both are set). Default: none. */
|
||||
session?: string;
|
||||
/** Per-request timeout in milliseconds. Default: 30_000. */
|
||||
timeoutMs?: number;
|
||||
/** Override state-file path. Default: BROWSE_STATE_FILE env or <git-root>/.gstack/browse.json. */
|
||||
@@ -133,6 +135,7 @@ export class BrowseClient {
|
||||
readonly port: number;
|
||||
readonly token: string;
|
||||
readonly tabId?: number;
|
||||
readonly session?: string;
|
||||
readonly timeoutMs: number;
|
||||
|
||||
constructor(opts: BrowseClientOptions = {}) {
|
||||
@@ -140,6 +143,7 @@ export class BrowseClient {
|
||||
this.port = auth.port;
|
||||
this.token = auth.token;
|
||||
this.tabId = opts.tabId ?? parseIntegerEnvValue(process.env.BROWSE_TAB);
|
||||
this.session = opts.session;
|
||||
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
||||
}
|
||||
|
||||
@@ -150,7 +154,7 @@ export class BrowseClient {
|
||||
const body = JSON.stringify({
|
||||
command: cmd,
|
||||
args,
|
||||
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}),
|
||||
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : this.session ? { session: this.session } : {}),
|
||||
});
|
||||
|
||||
let resp: Response;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
bump: minor
|
||||
---
|
||||
- **Parallel agents no longer take over each other's browser tab.** When several agents drive gstack's own browser at once, each agent process now gets its own tab on its first command, and its `goto`, `snapshot`, `click` and chain commands stay on that tab (#1961). Name a session yourself with `--session <name>` or `BROWSE_SESSION=<name>` (subagents that share one agent process need this); `BROWSE_SESSION=` turns it off. Sessions isolate tabs, not logins, so cookies and storage stay shared. A session idle for 120 minutes is closed (`BROWSE_SESSION_IDLE_MINUTES`, 0 keeps it), at most 16 run at once (`BROWSE_MAX_SESSIONS`), and only the local root token can name one. `tabs` now shows each tab's owner.
|
||||
- **`BROWSE_TAB` and `--tab-id` no longer move the active tab for everyone else.** A command pinned to a tab used to switch the daemon's active tab while it ran, so another caller's command during that window landed on the pinned tab, and two overlapping pinned commands could leave the daemon on the wrong tab. Each request now runs on its own tab without touching the shared one.
|
||||
- **QA can now test phone pages with real touch input.** `$B device "iPhone 15"` makes the current tab act like that phone (size, pixel ratio, touch support, mobile user agent and `pointer: coarse`) without resizing any other tab; `$B device off` restores it and `$B device --list` names every profile. `$B tap`, `$B swipe`, `$B pinch` and `$B touch down|move|up <finger> <x> <y>` send real touch events, including several fingers at once (hold a joystick while tapping fire) (#3058). This is Chromium emulating a phone, not Safari; /qa's report names the phones it emulated and what still needs a real device through /ios-qa. When the Aside browser drives, its cookbook has a touch recipe, checked on Aside 1.26.
|
||||
- New result codes, each with a troubleshooting entry: `BROWSE_SESSION_INVALID`, `BROWSE_SESSION_ROOT_ONLY`, `BROWSE_SESSION_TAB_RESET`, `BROWSE_SESSION_LIMIT`, `BROWSE_DEVICE_UNKNOWN`, `BROWSE_DEVICE_CDP_FAILED`, `BROWSE_TOUCH_NO_DEVICE`.
|
||||
@@ -331,6 +331,17 @@ console.log("ASIDE_DIR=" + pwd); await closeTab(pg); console.log("GSTACK_STEP_OK
|
||||
'
|
||||
```
|
||||
|
||||
**Touch (verified in Aside 1.26; add the Responsive override for phone size):**
|
||||
|
||||
```bash
|
||||
aside repl '
|
||||
const pg = await openTab("<url>"), t = (m, p) => pg._sendToTarget(m, p);
|
||||
await t("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
||||
for (const [type, touchPoints] of [["touchStart", [{ x: <tap-x>, y: <tap-y> }]], ["touchEnd", []]]) await t("Input.dispatchTouchEvent", { type, touchPoints });
|
||||
await closeTab(pg); console.log("GSTACK_STEP_OK");
|
||||
'
|
||||
```
|
||||
|
||||
**Links and their status (same-origin; on a LOCAL target each link is HEAD-checked, on a real site the user's cookies would ride every request so links are listed as `LINK ?` unfetched — consent to LOOK is not consent to hit every URL):**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -154,7 +154,7 @@ CSS selectors. Always `snapshot -i` first, then use the refs.
|
||||
|
||||
Paired agents get `read+write+admin+meta` by default; the pairing ceremony is the trust boundary. `--restrict` narrows the list (it can never grant `control`). `--control` adds the control scope (`--admin` is a legacy alias). Over the tunnel, the `js`/`cookies`/`storage` commands are blocked by the command allowlist regardless of scope; `eval` works. Pair with `--restrict "read,write"` when the agent will read untrusted web content — scope caps the prompt-injection blast radius.
|
||||
|
||||
To tighten an already-paired agent, re-pair it with the **same `--client` name** and the narrower `--restrict`/`--domain`: a reducing re-pair revokes the previous session and releases its tabs immediately (the agent must reconnect with the new key), so the old wide access never lingers. Broadening or refreshing keeps the working session with no outage. Re-pairing without `--client` mints a new agent instead. `root` is a reserved client name.
|
||||
To tighten an already-paired agent, re-pair it with the **same `--client` name** and the narrower `--restrict`/`--domain`: a reducing re-pair revokes the previous session and releases its tabs immediately (the agent must reconnect with the new key), so the old wide access never lingers. Broadening or refreshing keeps the working session with no outage. Re-pairing without `--client` mints a new agent instead. `root` and names starting with `session:` (kept for local agent sessions) are reserved client names.
|
||||
|
||||
## Tab Isolation
|
||||
|
||||
|
||||
@@ -2184,6 +2184,78 @@ command again.
|
||||
|
||||
**Fixed in.** The first release after v1.91.101.0 (#3167).
|
||||
|
||||
<a id="browse-session-invalid"></a>
|
||||
### ``browse: the session name is not 1-64 letters, digits, dots, colons, underscores or dashes ("<name>"); fix: pick a name like agent-1 ... (BROWSE_SESSION_INVALID)``
|
||||
|
||||
**Meaning.** `BROWSE_SESSION` or `--session` holds a name browse will not use
|
||||
as a tab owner.
|
||||
|
||||
**Fix.** Use a short name such as `agent-1`. An empty `BROWSE_SESSION=` turns
|
||||
sessions off and uses the shared active tab.
|
||||
|
||||
<a id="browse-session-root-only"></a>
|
||||
### ``browse: only the local root token may name a session ... (BROWSE_SESSION_ROOT_ONLY)``
|
||||
|
||||
**Meaning.** A paired or scoped agent sent a session name. Sessions are for
|
||||
local agents that share the root token; scoped agents already get their own
|
||||
tabs.
|
||||
|
||||
**Fix.** Run `newtab` and pass the returned `tabId` on every command.
|
||||
|
||||
<a id="browse-session-tab-reset"></a>
|
||||
### ``browse: this session's tab was closed ... (session <name>'s tab <id> was closed; it was on <url>); fix: open the page again ... (BROWSE_SESSION_TAB_RESET)``
|
||||
|
||||
**Meaning.** The tab this session was working in is gone: someone ran
|
||||
`closetab` on it, closed the window, or it sat idle past
|
||||
`BROWSE_SESSION_IDLE_MINUTES` (default 120). browse refuses reads instead of
|
||||
answering from a blank page.
|
||||
|
||||
**Fix.** `browse goto <url>` in the same session opens a new tab for it, then
|
||||
`snapshot` again before using `@refs`.
|
||||
|
||||
<a id="browse-session-limit"></a>
|
||||
### ``browse: every session slot is running a command ... (BROWSE_SESSION_LIMIT)``
|
||||
|
||||
**Meaning.** `BROWSE_MAX_SESSIONS` sessions (default 16) are all mid-command,
|
||||
so browse could not close an idle one to open a tab for a new session.
|
||||
|
||||
**Fix.** Retry in a moment, or raise `BROWSE_MAX_SESSIONS`.
|
||||
|
||||
<a id="browse-device-unknown"></a>
|
||||
### ``browse: no Playwright device has that name, so nothing on the tab changed ("<name>"; nearest: ...); fix: $B device --list (BROWSE_DEVICE_UNKNOWN)``
|
||||
|
||||
**Meaning.** `$B device <name>` matches names from Playwright's `devices`
|
||||
table, case-insensitively. The name you gave is not in it, so the tab was not
|
||||
touched. The line lists the three nearest names.
|
||||
|
||||
**Fix.** `$B device --list` prints every name; quote names with spaces
|
||||
(`$B device "iPhone 15"`). Add `--landscape` for the rotated profile.
|
||||
|
||||
**Fixed in.** The first release after v1.92.61.0 (#3058).
|
||||
|
||||
<a id="browse-device-cdp-failed"></a>
|
||||
### ``browse: Chromium refused a device emulation call on this tab (<method>: <error>; the tab is partly emulated); fix: $B device off (BROWSE_DEVICE_CDP_FAILED)``
|
||||
|
||||
**Meaning.** Phone emulation is three DevTools calls on the tab's own session
|
||||
(size, touch, user agent). One failed; the line names it and says whether the
|
||||
calls before it already changed the tab.
|
||||
|
||||
**Fix.** `$B device off` clears all three, then try the device again. If it
|
||||
keeps failing, the tab or browser is gone: `$B restart`.
|
||||
|
||||
**Fixed in.** The first release after v1.92.61.0 (#3058).
|
||||
|
||||
<a id="browse-touch-no-device"></a>
|
||||
### ``browse: touch input needs a phone profile on this tab first ($B tap on this tab); fix: $B device "iPhone 15" (BROWSE_TOUCH_NO_DEVICE)``
|
||||
|
||||
**Meaning.** `tap`, `swipe`, `pinch` and `touch` send real touch events,
|
||||
which pages only see on a touch device. The current tab has no phone profile.
|
||||
|
||||
**Fix.** `$B device "iPhone 15"` (or any name from `$B device --list`) on
|
||||
the same tab, then repeat the touch command.
|
||||
|
||||
**Fixed in.** The first release after v1.92.61.0 (#3058).
|
||||
|
||||
---
|
||||
|
||||
## Windows
|
||||
|
||||
@@ -105,15 +105,20 @@ Run with `browse <command> [args]`. Full reference: `browse/SKILL.md`.
|
||||
- `cookie <name>=<value>`: Set cookie on current page domain
|
||||
- `cookie-import <json>`: Import cookies from JSON file
|
||||
- `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc.
|
||||
- `device [<name> [--landscape] | off | --list]`: Emulate a phone on the current tab only: size, pixel ratio, touch support (pointer: coarse) and user agent from Playwright's devices table (names match case-insensitively).
|
||||
- `dialog-accept [text]`: Auto-accept next alert/confirm/prompt.
|
||||
- `dialog-dismiss`: Auto-dismiss next dialog
|
||||
- `fill <sel> <val>`: Fill input
|
||||
- `header <name>:<value>`: Set custom request header (colon-separated, sensitive values auto-redacted)
|
||||
- `hover <sel>`: Hover element
|
||||
- `pinch <@ref|selector|x y> <scale>`: Two-finger pinch around an element's center or x y: scale above 1 spreads the fingers (zoom in), below 1 brings them together.
|
||||
- `press <key>`: Press a Playwright keyboard key against the focused element.
|
||||
- `scroll [sel|@ref]`: With a selector, smooth-scrolls the element into view.
|
||||
- `select <sel> <val>`: Select dropdown option by value, label, or visible text
|
||||
- `style <sel> <prop> <value> | style --undo [N]`: Modify CSS property on element (with undo support)
|
||||
- `swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]`: One-finger swipe from an element's center or x y; the finger moves in the given direction (default 300px over 300ms, 10 moves).
|
||||
- `tap <@ref|selector|x y> [--hold <ms>]`: Touch and release at an element's center (scrolled into view) or at x y.
|
||||
- `touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]`: Hold, move and lift numbered fingers for multi-finger flows (hold a joystick with finger 0, tap fire with finger 1).
|
||||
- `type [--selector <sel>] [--] <text>`: Send keystrokes to the focused element, or to <sel> with --selector (focuses it first).
|
||||
- `upload <sel> <file> [file2...]`: Upload file(s)
|
||||
- `useragent <string>`: Set user agent
|
||||
|
||||
+5
-4
@@ -321,10 +321,10 @@ export function serveDir(root: string, nonce: string = randomBytes(16).toString(
|
||||
|
||||
// ─── Async spawn (keeps the loopback server's event loop free) ────────────────
|
||||
|
||||
async function runProc(cmd: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string; error?: string }> {
|
||||
async function runProc(cmd: string, args: string[], timeoutMs: number, env?: Record<string, string | undefined>): Promise<{ code: number | null; stdout: string; stderr: string; error?: string }> {
|
||||
let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
|
||||
try {
|
||||
child = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
|
||||
child = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', ...(env ? { env } : {}) });
|
||||
} catch (e) {
|
||||
return { code: null, stdout: '', stderr: '', error: (e as Error).message };
|
||||
}
|
||||
@@ -529,8 +529,9 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
|
||||
if (target.kind === 'error') return fail(target.error);
|
||||
|
||||
const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
const browseEnv = { ...process.env, BROWSE_SESSION: '' };
|
||||
const run = async (args: string[]): Promise<string> => {
|
||||
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())));
|
||||
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())), browseEnv);
|
||||
log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim());
|
||||
if (r.error || r.code !== 0) {
|
||||
const first = (r.stderr || r.stdout || r.error || '').trim().split('\n')[0];
|
||||
@@ -647,7 +648,7 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
|
||||
} catch (e) {
|
||||
return fail((e as Error).message);
|
||||
} finally {
|
||||
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000);
|
||||
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000, browseEnv);
|
||||
srv?.stop();
|
||||
if (work) fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Result codes for the browse daemon's tabs: tab recovery, per-session tabs
|
||||
* (#1961) and phone emulation with touch input (#3058). Kept beside
|
||||
* lib/result-codes.ts (which spreads this table into RESULT_CODES) so the
|
||||
* owner module stays within the module-size ratchet; the same rules apply:
|
||||
* one stable docs/troubleshooting.md anchor, a summary and a fix clause each.
|
||||
*/
|
||||
import type { ResultCode } from './result-codes';
|
||||
|
||||
export const BROWSE_RESULT_CODES = {
|
||||
// Fix wave 2026-10-15, PR 1a (items 7 and 11).
|
||||
BROWSE_TAB_UNRECOVERABLE: {
|
||||
anchor: 'browse-tab-unrecoverable',
|
||||
summary: 'every tab was closed outside browse and a new one could not be opened, because the browser or its context is gone',
|
||||
fix: 'run `browse restart` (`$B restart` in a gstack skill); `browse status` and `browse stop` keep working',
|
||||
},
|
||||
// Per-session tabs (#1961).
|
||||
BROWSE_SESSION_INVALID: {
|
||||
anchor: 'browse-session-invalid',
|
||||
summary: 'the session name is not 1-64 letters, digits, dots, colons, underscores or dashes',
|
||||
fix: 'pick a name like agent-1 (BROWSE_SESSION=agent-1 or --session agent-1)',
|
||||
},
|
||||
BROWSE_SESSION_ROOT_ONLY: {
|
||||
anchor: 'browse-session-root-only',
|
||||
summary: 'only the local root token may name a session; paired and scoped agents get their own tab with newtab',
|
||||
fix: 'drop the session field and run newtab, then pass its tabId on every command',
|
||||
},
|
||||
BROWSE_SESSION_TAB_RESET: {
|
||||
anchor: 'browse-session-tab-reset',
|
||||
summary: 'this session\'s tab was closed (by closetab, the window, or the idle sweep), so the page it was reading is gone',
|
||||
fix: 'open the page again with `browse goto <url>` in the same session, then snapshot again before using @refs',
|
||||
},
|
||||
BROWSE_SESSION_LIMIT: {
|
||||
anchor: 'browse-session-limit',
|
||||
summary: 'every session slot is running a command, so no idle session could be closed to make room',
|
||||
fix: 'retry when a command finishes, or raise BROWSE_MAX_SESSIONS (default 16)',
|
||||
},
|
||||
// Phone emulation and touch (#3058).
|
||||
BROWSE_DEVICE_UNKNOWN: {
|
||||
anchor: 'browse-device-unknown',
|
||||
summary: 'no Playwright device has that name, so nothing on the tab changed',
|
||||
fix: '$B device --list',
|
||||
},
|
||||
BROWSE_DEVICE_CDP_FAILED: {
|
||||
anchor: 'browse-device-cdp-failed',
|
||||
summary: 'Chromium refused a device emulation call on this tab',
|
||||
fix: '$B device off',
|
||||
},
|
||||
BROWSE_TOUCH_NO_DEVICE: {
|
||||
anchor: 'browse-touch-no-device',
|
||||
summary: 'touch input needs a phone profile on this tab first',
|
||||
fix: '$B device "iPhone 15"',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
+2
-6
@@ -16,6 +16,7 @@ import { BUDGET_RESULT_CODES } from './result-codes-budget';
|
||||
import { EVAL_RESULT_CODES } from './result-codes-eval';
|
||||
import { PLAN_RESULT_CODES } from './result-codes-plan';
|
||||
import { AUTOPLAN_RESULT_CODES } from './result-codes-autoplan';
|
||||
import { BROWSE_RESULT_CODES } from './result-codes-browse';
|
||||
|
||||
export interface ResultCode {
|
||||
/** Stable `<a id>` in docs/troubleshooting.md. */
|
||||
@@ -634,12 +635,6 @@ export const RESULT_CODES = {
|
||||
summary: 'a Capy session (GSTACK_CAPY=1) has no passing install verdict for this checkout, HEAD and workflow, so the workflow refused to start',
|
||||
fix: 'run the Capy skill installer with --for <workflow> on this machine and launch with the GSTACK_LAUNCH line it prints',
|
||||
},
|
||||
// Fix wave 2026-10-15, PR 1a (items 7 and 11).
|
||||
BROWSE_TAB_UNRECOVERABLE: {
|
||||
anchor: 'browse-tab-unrecoverable',
|
||||
summary: 'every tab was closed outside browse and a new one could not be opened, because the browser or its context is gone',
|
||||
fix: 'run `browse restart` (`$B restart` in a gstack skill); `browse status` and `browse stop` keep working',
|
||||
},
|
||||
BUN_VERSION_UNREADABLE: {
|
||||
anchor: 'bun-version-unreadable',
|
||||
summary: 'bun --version did not print a version gstack can compare with the incoming release\'s Bun floor, so the update was held and nothing was changed',
|
||||
@@ -772,6 +767,7 @@ export const RESULT_CODES = {
|
||||
...EVAL_RESULT_CODES,
|
||||
...PLAN_RESULT_CODES,
|
||||
...AUTOPLAN_RESULT_CODES,
|
||||
...BROWSE_RESULT_CODES,
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
|
||||
export type ResultCodeName = keyof typeof RESULT_CODES;
|
||||
|
||||
+2
-2
@@ -315,8 +315,8 @@ Prefer the richer of recent project test plans and plans in conversation over gi
|
||||
## Phases 1-6: QA Baseline
|
||||
|
||||
Follow the shared section's ordered preparation, then run its probe loop.
|
||||
Inside that loop, browser runs apply qa-patterns.md's numbered Phases 1-6 as techniques,
|
||||
and functional runs apply system-functional.md's contract map; neither is a separate workflow.
|
||||
In that loop, browser runs use qa-patterns.md's Phases 1-6 as techniques (touch: `$B device "iPhone 15"`, then `tap`),
|
||||
and functional runs use system-functional.md's contract map; neither is a separate workflow.
|
||||
|
||||
> **STOP.** Before running the selected target's QA baseline and exploratory probes, with caller-owned authority, Read `sections/exploratory.md` relative to the installed `qa`/`gstack-qa` SKILL.md directory in full and follow it.
|
||||
> Use this host's installed path, never the product working directory or another host's assets.
|
||||
|
||||
+2
-2
@@ -128,8 +128,8 @@ Prefer the richer of recent project test plans and plans in conversation over gi
|
||||
## Phases 1-6: QA Baseline
|
||||
|
||||
Follow the shared section's ordered preparation, then run its probe loop.
|
||||
Inside that loop, browser runs apply qa-patterns.md's numbered Phases 1-6 as techniques,
|
||||
and functional runs apply system-functional.md's contract map; neither is a separate workflow.
|
||||
In that loop, browser runs use qa-patterns.md's Phases 1-6 as techniques (touch: `$B device "iPhone 15"`, then `tap`),
|
||||
and functional runs use system-functional.md's contract map; neither is a separate workflow.
|
||||
|
||||
{{SECTION:exploratory}}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
| **Pages visited** | {COUNT} |
|
||||
| **Screenshots** | {COUNT} |
|
||||
| **Framework** | {DETECTED or "Unknown"} |
|
||||
| **Phones** | {Chromium device profiles emulated, or "—"}; real device still needed (/ios-qa): {what, or "—"} |
|
||||
|
||||
## Health Score: {SCORE}/100
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export const SELECTION_DATA_MODULES: readonly string[] = [
|
||||
// The build entry list: every compiled binary, which skills reach through their `--outfile` path instead.
|
||||
'scripts/build.sh',
|
||||
// The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes.
|
||||
'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts',
|
||||
'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts',
|
||||
// The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case).
|
||||
'test/helpers/exercised-commands.json',
|
||||
];
|
||||
|
||||
@@ -240,6 +240,17 @@ console.log("ASIDE_DIR=" + pwd); await closeTab(pg); console.log("GSTACK_STEP_OK
|
||||
'
|
||||
\`\`\`
|
||||
|
||||
**Touch (verified in Aside 1.26; add the Responsive override for phone size):**
|
||||
|
||||
\`\`\`bash
|
||||
aside repl '
|
||||
const pg = await openTab("<url>"), t = (m, p) => pg._sendToTarget(m, p);
|
||||
await t("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
||||
for (const [type, touchPoints] of [["touchStart", [{ x: <tap-x>, y: <tap-y> }]], ["touchEnd", []]]) await t("Input.dispatchTouchEvent", { type, touchPoints });
|
||||
await closeTab(pg); console.log("GSTACK_STEP_OK");
|
||||
'
|
||||
\`\`\`
|
||||
|
||||
**Links and their status (same-origin; on a LOCAL target each link is HEAD-checked, on a real site the user's cookies would ride every request so links are listed as \`LINK ?\` unfetched — consent to LOOK is not consent to hit every URL):**
|
||||
|
||||
\`\`\`bash
|
||||
|
||||
@@ -138,6 +138,9 @@ describe('Aside driver contract ({{ASIDE_SETUP}})', () => {
|
||||
test('cookbook uses only the verified Aside APIs', () => {
|
||||
expect(section).toContain('Page.addScriptToEvaluateOnNewDocument');
|
||||
expect(section).toContain('Emulation.setDeviceMetricsOverride');
|
||||
// Touch and phone recipe: verified on Garry's Mac in Aside 1.26 (GSTA-227 F3, #3058).
|
||||
expect(section).toContain('Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 }');
|
||||
expect(section).toContain('Input.dispatchTouchEvent');
|
||||
expect(section).toContain('annotatedScreenshot(pg)');
|
||||
expect(section).toContain('snapshot(pg, { interactive: true })');
|
||||
// Verified NOT to exist or NOT to persist across CLI calls — must never be recommended.
|
||||
|
||||
+5
@@ -72,6 +72,8 @@
|
||||
{ "file": "browse/src/routes/pty.ts", "movedFrom": "browse/src/server.ts" },
|
||||
{ "file": "browse/src/routes/table.ts", "movedFrom": "browse/src/server.ts" },
|
||||
{ "file": "browse/src/routes/tokens.ts", "movedFrom": "browse/src/server.ts" },
|
||||
{ "file": "browse/src/session-tabs.ts", "movedFrom": "browse/src/server.ts (the BROWSE_TAB pin and restore, replaced by per-request tab scopes and sessions; GSTA-227)" },
|
||||
{ "file": "browse/src/session-name.ts", "movedFrom": "(new: the CLI side of per-session tabs, the agent harness walk; GSTA-227)" },
|
||||
{ "file": "browse/src/routes/tunnel.ts", "movedFrom": "browse/src/server.ts" },
|
||||
{ "file": "scripts/resolvers/review-dashboard.ts", "movedFrom": "scripts/resolvers/review.ts" },
|
||||
{ "file": "scripts/resolvers/plan-gates.ts", "movedFrom": "scripts/resolvers/review.ts" },
|
||||
@@ -106,6 +108,9 @@
|
||||
{ "file": "lib/result-codes-release.ts", "movedFrom": "lib/result-codes.ts (the release-path rows: ship queue, pregate timing, approval classes, state matrix; field list 2 wave B)" },
|
||||
{ "file": "lib/result-codes-capy.ts", "movedFrom": "lib/result-codes.ts (the Capy rows: approval record, Captain brief, PR screenshots; GSTA-61)" },
|
||||
{ "file": "lib/result-codes-budget.ts", "movedFrom": "lib/result-codes.ts (the /ship token budget and handoff rows; GSTA-152)" },
|
||||
{ "file": "lib/result-codes-browse.ts", "movedFrom": "lib/result-codes.ts (the browse per-session tab rows, #1961, and device and touch rows, #3058; GSTA-227)" },
|
||||
{ "file": "browse/src/device-emulation.ts", "movedFrom": "(new: per-tab phone emulation for $B device, #3058, GSTA-227 F3)" },
|
||||
{ "file": "browse/src/touch-commands.ts", "movedFrom": "(new: $B tap, swipe, pinch and touch through Input.dispatchTouchEvent, #3058, GSTA-227 F3)" },
|
||||
{ "file": "lib/result-codes-eval.ts", "movedFrom": "lib/result-codes.ts (the /eval-plan preregistration rows and the no-gstack arm; GSTA-168)" },
|
||||
{ "file": "lib/result-codes-plan.ts", "movedFrom": "lib/result-codes.ts (the plan-review fact-check and task-list rows: REALITY receipts, the office-hours probe, gstack-tasks; GSTA-228)" },
|
||||
{ "file": "lib/result-codes-autoplan.ts", "movedFrom": "lib/result-codes.ts (the CEO-only driver run rows; GST-537)" },
|
||||
|
||||
@@ -23,7 +23,7 @@ const COPIED = [
|
||||
'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks
|
||||
'bin/gstack-launch-probe.sh',
|
||||
'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts',
|
||||
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', // the windows bash row
|
||||
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', // the windows bash row
|
||||
];
|
||||
const bases: string[] = [];
|
||||
/** Registers the per-test fixture cleanup; each test file calls it at top level (a hook in this module would bind to the first importing file only). */
|
||||
|
||||
@@ -56,6 +56,8 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
|
||||
// /eval-plan (plan E1): the pilot's ledger inputs.
|
||||
'<cap-usd>': { grammar: NUMBER, what: 'spend cap in USD' },
|
||||
'<total-items>': { grammar: NUM, what: 'items in the full eval run' },
|
||||
'<tap-x>': { grammar: NUM, what: 'viewport x in CSS pixels for the Aside touch recipe' },
|
||||
'<tap-y>': { grammar: NUM, what: 'viewport y in CSS pixels for the Aside touch recipe' },
|
||||
'<workers>': { grammar: NUM, what: 'concurrent eval workers' },
|
||||
'<attempt-id>': { grammar: ID, what: 'PILOT_ATTEMPT id printed by pilot start' },
|
||||
'<usd|unknown>': { grammar: /^(?:[0-9]+(?:\.[0-9]+)?|unknown)$/, what: 'actual cost in USD from the provider usage field, or unknown' },
|
||||
|
||||
@@ -70,7 +70,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
|
||||
// These imports are resolved relative to the copied install, not the source checkout.
|
||||
const libDir = path.join(workDir, 'lib');
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts']) {
|
||||
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts', 'result-codes-browse.ts']) {
|
||||
fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file));
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const HOOK_FILES = [
|
||||
'lib/result-codes-eval.ts',
|
||||
'lib/result-codes-plan.ts',
|
||||
'lib/result-codes-autoplan.ts',
|
||||
'lib/result-codes-browse.ts',
|
||||
];
|
||||
const SLUG = 'stop-hook-slug-test';
|
||||
const BUDGET_MS = 2000;
|
||||
|
||||
Reference in new issue
Block a user