mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
Merge remote-tracking branch 'origin/gsta227-f3-touch' into gsta227-tabs-touch
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # docs/troubleshooting.md # lib/result-codes-browse.ts
This commit is contained in:
commit
4356eca9f6
25 files changed
+900
-15
No files matched your search
@@ -350,10 +350,6 @@ gstack_result_anchor() {
|
||||
PROJECT_FLOOR_UNMET) printf '%s' 'project-floor-unmet' ;;
|
||||
PROJECT_NODE_PIN_UNMET) printf '%s' 'project-node-pin-unmet' ;;
|
||||
UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;;
|
||||
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' ;;
|
||||
BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;;
|
||||
BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;;
|
||||
BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;;
|
||||
@@ -366,6 +362,13 @@ gstack_result_anchor() {
|
||||
PREREG_NO_GSTACK_ARM) printf '%s' 'prereg-no-gstack-arm' ;;
|
||||
EVAL_REPORT_NO_GSTACK_ROW) printf '%s' 'eval-report-no-gstack-row' ;;
|
||||
EVAL_ARM_DUPLICATE) printf '%s' 'eval-arm-duplicate' ;;
|
||||
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: <x>, y: <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 |
|
||||
|
||||
@@ -22,13 +22,14 @@ 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';
|
||||
@@ -353,6 +354,8 @@ export interface BrowserState {
|
||||
* In-memory only.
|
||||
*/
|
||||
owner?: string;
|
||||
/** `$B device` profile, re-applied before the restored tab's first request. */
|
||||
device?: DeviceProfile;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -1882,6 +1885,7 @@ export class BrowserManager {
|
||||
loadedHtml: loaded?.html,
|
||||
loadedHtmlWaitUntil: loaded?.waitUntil,
|
||||
owner,
|
||||
device: session?.getDevice() ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1944,6 +1948,9 @@ export class BrowserManager {
|
||||
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
|
||||
|
||||
@@ -1860,6 +1860,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
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ───────────────────────
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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') {
|
||||
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, 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;
|
||||
|
||||
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,234 @@
|
||||
/**
|
||||
* 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('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');
|
||||
});
|
||||
});
|
||||
@@ -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: <x>, y: <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
|
||||
|
||||
+35
-1
@@ -2057,7 +2057,6 @@ 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)``
|
||||
|
||||
@@ -2095,6 +2094,41 @@ 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.2.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.2.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.2.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
|
||||
|
||||
@@ -29,4 +29,20 @@ export const BROWSE_RESULT_CODES = {
|
||||
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)',
|
||||
},
|
||||
} satisfies Record<string, ResultCode>;
|
||||
// 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
-2
@@ -12,9 +12,9 @@
|
||||
*/
|
||||
import { RELEASE_RESULT_CODES } from './result-codes-release';
|
||||
import { CAPY_RESULT_CODES } from './result-codes-capy';
|
||||
import { BROWSE_RESULT_CODES } from './result-codes-browse';
|
||||
import { BUDGET_RESULT_CODES } from './result-codes-budget';
|
||||
import { EVAL_RESULT_CODES } from './result-codes-eval';
|
||||
import { BROWSE_RESULT_CODES } from './result-codes-browse';
|
||||
|
||||
export interface ResultCode {
|
||||
/** Stable `<a id>` in docs/troubleshooting.md. */
|
||||
@@ -762,9 +762,9 @@ export const RESULT_CODES = {
|
||||
},
|
||||
...RELEASE_RESULT_CODES,
|
||||
...CAPY_RESULT_CODES,
|
||||
...BROWSE_RESULT_CODES,
|
||||
...BUDGET_RESULT_CODES,
|
||||
...EVAL_RESULT_CODES,
|
||||
...BROWSE_RESULT_CODES,
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
|
||||
export type ResultCodeName = keyof typeof RESULT_CODES;
|
||||
|
||||
@@ -317,6 +317,7 @@ Prefer the richer of recent project test plans and plans in conversation over gi
|
||||
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.
|
||||
Touch: `$B device "iPhone 15"` then tap.
|
||||
|
||||
> **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.
|
||||
|
||||
@@ -129,6 +129,7 @@ Prefer the richer of recent project test plans and plans in conversation over gi
|
||||
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.
|
||||
Touch: `$B device "iPhone 15"` then tap.
|
||||
|
||||
{{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
|
||||
|
||||
|
||||
@@ -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: <x>, y: <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.
|
||||
|
||||
+3
-1
@@ -104,8 +104,10 @@
|
||||
{ "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-browse.ts", "movedFrom": "lib/result-codes.ts (the browse per-session tab and phone emulation rows; GSTA-227)" },
|
||||
{ "file": "lib/ship-budget.ts", "movedFrom": "(new: /ship token budget ledger, meters and admission, GSTA-152)" },
|
||||
{ "file": "lib/ship-budget-cli.ts", "movedFrom": "(new: bin/gstack-ship-budget start/check/settle/close/resume, GSTA-152)" },
|
||||
{ "file": "lib/ship-handoff.ts", "movedFrom": "(new: /ship fresh-context handoff, verify, return and reconcile, GSTA-152)" },
|
||||
|
||||
Reference in new issue
Block a user