mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-21 05:27:14 +02:00
* fix(ci): skill-docs freshness gate covers all 10 hosts and can actually fail The Codex/Factory gates ran 'git diff --exit-code -- .agents/' / '-- .factory/', but both paths are gitignored (.gitignore:16-17) — git diff on ignored untracked paths is always empty, so those two gates were structurally incapable of failing and 7 of 10 hosts had no gate at all. New shape: one 'gen:skill-docs --host all' pass (the generator hard-fails on any per-host error, gating all 10 hosts on generates-cleanly), byte-freshness via git diff for tracked output, plus a porcelain check that fails on untracked generated strays (git diff can't see brand-new files). The gitignored-hosts byte-freshness limitation is documented in the workflow comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): exorcise the sidebar-agent ghost from the test suite browse/src/sidebar-agent.ts was deleted in the v1.14 sidebar refactor, but the test suite kept testing it for 48 versions. Nothing noticed because the free suite runs in no CI job and Bun-era module-load errors were suppressed in the Windows shard runner via an exclusion pattern whose own comment documented the breakage ('broken on every platform since v1.14 ... exit 0'). - Delete sidebar-security.test.ts + security-source-contracts.test.ts: crashed at module load (unguarded readFileSync of the deleted file); per-assertion triage confirmed every SERVER_SRC pin targeted the deleted chat prompt builder (zero hits in today's server.ts) — nothing to port. - Delete sidebar-integration.test.ts: 11 of 13 tests exercised deleted endpoints (/sidebar-command queue, /sidebar-agent/event, chat buffer); the 2 passing tests pinned only the blanket auth gate, covered by server-auth.test.ts + dual-listener.test.ts. - Delete test/skill-e2e-sidebar.test.ts: E2E for the deleted queue flow. - sidebar-ux.test.ts 1,669 -> 830 lines: 20 dead-chat describes + 15 dead tests removed (incl. 10 vacuous passes asserting on empty indexOf slices); 2 stale pins on LIVE features fixed (content.js typed-catch CSSOM fallback, arrow-hint window widened). 95 pass / 0 fail. - sidebar-tabs.test.ts: both failures were stale pins, not regressions — forceRestart's deliberate ws.close(4001) and the terminal-agent spawn that moved into spawnTerminalAgent() (identity-based kill refactor). 28 pass. - touchfiles.ts: drop the three sidebar E2E entries from BOTH maps (E2E_TOUCHFILES + E2E_TIERS) — they pointed diff-selection at the deleted file, so those tests were unreachable by any diff. - test-free-shards.ts: remove the now-dead sidebar-agent exclusion pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): run the free test suite in CI (it ran nowhere) The full free suite (bun test: browse/test/ + test/ + make-pdf/test/) had no CI job on any Linux/macOS runner — only Windows curated shards, paid evals, and doc-freshness gates existed. That's how two module-load-crashing test files survived 48 versions. Same cached Dockerfile.ci image and container wiring as evals.yml (deps restore, build, Chromium verify). Includes a module-load-error guard: older Bun reported test-file import crashes with exit 0 on macOS/Linux, so the job also fails on any nonzero 'N errors' count in the summary — future crash-class regressions can't hide from the exact job built to catch them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): validate touchfile dependency paths exist on disk New guard in touchfiles.test.ts: every non-glob dep path must exist, and every glob's anchor directory must exist. This is the axis the 181-key two-map sync discipline never covered — an entry can point at a long-deleted file and diff-based selection then silently never triggers those tests (the sidebar trio sat rotted for 48 versions). First run immediately caught a fourth rotted entry: 'spec authored quality' referenced test/fixtures/spec/** (directory does not exist) and selected for a judge test that exists nowhere in the repo. Removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): remove deleted /sidebar-chat endpoint from tunnel allowlist TUNNEL_PATHS is the audited tunnel attack surface — its own comment says every addition widens it. '/sidebar-chat' stayed in the set after the endpoint was deleted with the chat-queue path, meaning any future route matching that path would have been silently tunnel-exposed. The set is now exactly the pair ceremony (/connect) and the scoped command endpoint (/command), and the dual-listener closed-set pin enforces that. Also repairs a pre-existing red pin in dual-listener.test.ts: v1.63.0.0 made the tunnel allowlist args-aware (canDispatchOverTunnel gained a second param) without updating the test — red on main since then, invisible because the free suite had no CI job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete chain's shadow dispatcher that skipped every security gate meta-commands.ts carried a 'CLI mode' fallback that re-implemented command routing without the server pipeline's gates: no scope check, no domain check, no tab ownership, no rate limit, no hidden-element stripping, no scoped-token enveloping — and it called handleReadCommand without a BrowserManager, which also skipped the JS-origin cookie-exfiltration assertion. It was unreachable in production (server.ts always passes executeCommand) and one boolean away from being live. chain now hard-errors without a server context. handleReadCommand's bm param is required and assertJsOriginAllowed runs unconditionally. The chain tests that exercised the deleted fallback now route through a server-shaped executeCommand adapter (real handlers + trust wrapping + {status,result} envelope), so their behavioral coverage — sequencing, trust markers, pipe format, aliases, error reporting — survives on the production-shaped path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(extension): delete the dead chat-queue client surface The sidebar-command handler in background.js POSTed to a server endpoint that no longer exists (deleted with the chat queue) — ~35 lines of fully-wired dead code including error handling for the permanent 404, plus its allowlist entry. No sender in the extension ever emitted the message type. chatEnabled leaves the /health contract (server hardcoded false, background.js re-derived it, nothing consumed it — the chat input element it guarded is gone from sidepanel.html). BROWSE_SIDEBAR_CHAT env flag had zero readers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete dead exports the ripped chat path left behind Three-way split by importer class: (a) Zero importers, deleted: the whole attack-attempt logging cluster in security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt, attempts.jsonl rotation, telemetry spawn plumbing incl. buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer is tunnel-denial-log.ts with its own rotation); the decision-file handshake (writeDecision/readDecision/clearDecision/excerptForReview — written for sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt' for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl, generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry, restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie; WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth never used it). (b) Dead-pin tests edited with their exports: the 'still exported' pin in stealth-layer-c, the string-content describe in stealth-webdriver (its live applyStealth behavioral coverage untouched), the clear-cookie assertions, security-review-flow.test.ts deleted whole (all 4 describes exercised the dead decision mechanism, incl. a 'simulated sidebar-agent poll loop'). (c) KEPT deliberately: leaseCount (live behavioral coverage), extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by the terminal-agent cookie-parse unification later in this wave), resetSessionMarker + clearContentFilters (test-support API for the live content-security layer). Also fixes two pre-existing red pins found while here, invisible until the free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in terminal-agent.test.ts, and a cross-file test-isolation bug where content-security.test.ts's clearContentFilters() wiped the auto-registered url-blocklist filter for every later file in the same bun process (security-integration.test.ts failed on co-run; afterAll now restores it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete the dead ML layers — transcript classifier and DeBERTa ensemble The L4b Haiku transcript classifier and the opt-in DeBERTa ensemble (GSTACK_SECURITY_ENSEMBLE=deberta, a documented 721MB download) had ZERO production callers since the chat-path agent that invoked them was ripped. The only live ML path is scanPageContent (testsavant) inside the security sidecar subprocess. Deleted by import graph: - security-classifier.ts 614 -> 265 lines: HAIKU_MODEL, checkTranscript, shouldRunTranscriptCheck, loadDeberta, scanPageContentDeberta, ToolCallInput, all DEBERTA_* consts + load state. Header now states the live truth (imported only by security-sidecar-entry.ts). downloadFile kept, name intact — it is an enumerated egress sink (HF model download). - security-bunnative.ts + test: a research skeleton self-described as 'NOT a production replacement', shipped into src/ with zero importers. - security-bench-ensemble{,-live}.test.ts + the Haiku response fixture: a paid live-model benchmark for a layer that could not fire. The security-classifier-tdz test's only case exercised checkTranscript — gone. - security.ts: layer-model header rewritten to the live architecture; StatusDetail.layers -> {testsavant, canary}; getStatus() no longer requires the impossible transcript==='ok' for 'protected' (old on-disk session state with a transcript key is tolerated on read, never re-emitted). - security-sidecar-entry.ts needed zero changes: it serializes getClassifierStatus() verbatim and no consumer read .transcript (verified in sidecar-client + server.ts). - BROWSER.md security section matches reality (ensemble knob gone, 112MB not 22MB, sidecar hosting documented). combineVerdict/THRESHOLDS retained as the pure, tested combiner of record — comments now flag transcript/deberta votes as producer-less. Net: 26 pass in security.test.ts incl. a NEW regression test for stale- transcript disk tolerance; egress-receipt tripwire green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: scrub the sidebar-agent ghost from comments and CLAUDE.md 20+ comments across 10 files still described the deleted sidebar-agent.ts as a live process — including load-bearing architecture claims ('IMPORTED ONLY BY sidebar-agent.ts', 'sidebar-agent fills this in on first prompt-injection load', 'kill sidebar-agent' in shutdown docs) and ~60 lines of tombstone blocks in server.ts enumerating deleted identifiers by name (a false grep surface: searching processAgentEvent hit server.ts and looked live). CLAUDE.md's security-stack section now documents the LIVE architecture: L1-L3 content filters + testsavant via the security sidecar subprocess; the L4b/ensemble rows, the GSTACK_SECURITY_ENSEMBLE knob, and the 721MB DeBERTa download are gone (deleted as dead code this wave) with an explicit do-not-re-document note; attempts.jsonl is correctly attributed to tunnel-denial-log.ts; the no-live-writer status of classifierStatus is stated. Comments that survive now describe what IS, not what WAS: the promotion gate in domain-skills.ts explains why classifier_score>0 is load-bearing given no L4 load-time scan exists; file-permissions.ts names real sensitive files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): delete the codex-helpers shadow module gen-skill-docs.ts imported externalSkillName (unaliased) from resolvers/codex-helpers.ts at line 21 and then re-declared the same function locally — the import was silently shadowed, and the imported copy was the STALE one (it lacked the frontmatterName param the local copy grew). Three more functions were byte-identical duplicates, imported only under _-prefixed aliases to keep the module 'referenced', and transformFrontmatter was a superseded hardcoded-Codex variant. Nothing else imported the module. Also drops three dead top-of-file imports (COMMAND_DESCRIPTIONS, SNAPSHOT_FLAGS — which pulled the whole browse/src module graph into every generator run for nothing — and an unused review-resolver trio). Proof: bun run gen:skill-docs exits 0 with a byte-identical tree (zero-diff regen); gen-skill-docs.test.ts 405/405 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): delete ServerConfig.idleTimeoutMs + chromiumProfile — documented, never read Both fields carried JSDoc asserting embedder behavior that did not exist: the idle check reads the module-level IDLE_TIMEOUT_MS env constant, and both resolveChromiumProfile() call sites pass no argument. Worse than absent — an embedder passing idleTimeoutMs: 5000 silently got 30 minutes. Wiring them honestly is impossible today: the idle timer, activity state, and shutdown target are module-global, so a per-factory value would lie for any process running more than one handler. Deleted instead, with a ServerConfig note pointing at the deferred singleton/route-table refactor where real support belongs. BROWSE_IDLE_TIMEOUT and CHROMIUM_PROFILE env remain the honest knobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): wire appendSecureFile at the four real log-append sites file-permissions.ts carries a 24-line rationale for why POSIX mode bits are insufficient on Windows and implements appendSecureFile (0600 at create, Windows ACL on first write only) — but its single caller was the dead logAttempt, while the four REAL page-content log writers (console/network/ dialog logs in server.ts, the command audit log) used raw fs.appendFileSync with no mode. Page-content-derived logs now get owner-only permissions from birth on every platform. Verified before wiring: mode applies atomically at create via appendFileSync {mode}, and the ACL pass runs only on first write — no per-append subprocess cost on the hot console-log path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stealth): handoff() uses the shared profile resolution + lock cleanup The headless-to-headed handoff path hardcoded ~/.gstack/chromium-profile, silently ignoring $CHROMIUM_PROFILE and $GSTACK_HOME (gbrowser's gbd sets per-workspace profiles), and skipped cleanSingletonLocks() — so a handoff into a profile with a stale SingletonLock could hang where launchHeaded() would have recovered. This was the third live drift between the three Chromium launch paths; the first two are documented in comments as shipped stealth regressions. Minimal targeted fix — the full buildLaunchConfig() extraction stays in the deferred queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): resolver registry describes the template language again Seven registered {{PLACEHOLDER}}s had zero uses in any .tmpl (checked in both bare and :arg forms): REDACT_TAXONOMY_TABLE, TEST_COVERAGE_AUDIT_REVIEW, MODEL_OVERLAY, QUESTION_PREFERENCE_CHECK, QUESTION_LOG, INLINE_TUNE_FEEDBACK, MAKE_PDF_SETUP. The last two of those families are invoked programmatically by preamble.ts (functions kept, registry entries dropped); the question-tuning trio and the review coverage-audit wrapper were documented by their own module as existing 'for unit testing' that no test performed — deleted, along with generateRedactTaxonomyTable + its EXAMPLE/TIER_BLURB constants (its '/cso renders the full table' comment was itself stale) and its test describe. Also deletes the gated-resolver mechanism (ResolverEntry/appliesTo/ unwrapResolver + test/resolver-entry.test.ts): fully built, fully tested, used by zero of the 65 registry entries — the generator loop simplifies to a direct function call. CLAUDE.md's redact-doc line stops advertising the dead token. Proof: zero-diff regen (0 SKILL.md changed); gen-skill-docs + skill-validation 737 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): wire boundaryInstruction from host config; drop three no-op binDir ternaries hosts/codex.ts declared boundaryInstruction and nothing read it — review.ts kept its own byte-identical CODEX_BOUNDARY literal (verified equal + trailing escaped newlines). The resolver now reads the config, so the boundary has one owner. (autoplan's template carries deliberately generic variants, enforced by gen-skill-docs.test.ts:1358 — untouched by design.) The 'ctx.host === codex ? $GSTACK_BIN : ctx.paths.binDir' ternary appeared in three resolvers and could never change the result: resolvers/types.ts already sets binDir to $GSTACK_BIN for every usesEnvVars host including codex. Proof: zero-diff regen for claude AND codex hosts; gen-skill-docs + host-config suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test-infra): judge uses resolveClaudeBinary; eval:watch reads the real partials dir judgePtyState spawned the bare string 'claude' three definitions below the resolveClaudeBinary() helper this same file exports — broken under hermetic PATHs where every other launch in the file resolves correctly. eval:watch read _partial-e2e.json from the legacy global ~/.gstack-dev/evals/ while EvalCollector writes it into the per-project eval dir (or GSTACK_EVAL_DIR) — so the dashboard's completed-tests panel was empty whenever slug detection succeeded, i.e. the normal case. The heartbeat and per-run progress logs stay global by design (session-runner.ts: 'heartbeat stays global'). The three eval-CLI docstrings stop claiming the legacy dir is the primary location. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): delete the superseded SDK ship-idempotency suite and three orphaned fixtures test/skill-e2e-ship-idempotency.test.ts's own header documented that the monolith's SDK-harness version tests a synthetic prompt while it exercises the real /ship skill — the author knew the old suite was superseded and left both running, two paid LLM runs for one behavior. The weaker copy is gone; its 'ship-idempotency' diff-selection key goes with it (the dedicated file is periodic-tier, which always runs under EVALS_ALL — the key had no remaining consumer). Fixture rot: test/fixtures/golden-ship-claude.md was a 128KB zero-reader orphan that had drifted 46KB from its live successor (test/fixtures/golden/claude-ship-SKILL.md) while looking authoritative; parity-baseline-v1.46.0.0.json and v1.53.0.0.json had zero readers (three tests pin three OTHER baseline versions — consolidation is queued, deletion of the unreferenced two is free). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): delete zero-caller scripts; make host-config-export's docstring honest - bin/gstack-open-url (14 lines): announced in a CHANGELOG entry, wired into nothing, ever. bin/gstack-platform-detect (27 lines): zero callers, and its hand-rolled host list was already stale (SLATE_HOST.md cites it as a problem). Note: the deprecated gstack-brain-consumer/reader pair the audit flagged was already deleted upstream in v1.63 with a stay-deleted tripwire. - scripts/task-emission-schema.ts (61 lines): a typed schema module nothing imported; the tasks-section comment now documents the JSONL fields inline. - scripts/host-config-export.ts claimed to be the 'shell bridge for the bash setup script' — setup never calls it (its hand-rolled host lists drifting is a known follow-up). Docstring now states what it IS: a standalone, test-pinned query CLI not yet wired into setup. Its validateValue + CLI_REGEX/PATH_REGEX internals were dead (defined for a guarantee the header claimed but nothing enforced). - KEPT deliberately: scripts/preflight-agent-sdk.ts — a documented manual diagnostic (CONTRIBUTING.md + USING_GBRAIN_WITH_GSTACK.md reference it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): one lone-surrogate sanitizer, one sanitizeReplacer, one startTunnel Three copies of the surrogate sanitizer existed with two algorithms (sanitize.ts regex vs a hand-rolled charCodeAt walk in server.ts — verified byte-identical across 11 edge cases before converging) plus two identical sanitizeReplacer definitions each wrapping a different copy. sanitize.ts is now the single source of truth; the runs-INSIDE-JSON.stringify egress invariant is unchanged at every call site and its pin tests were adapted to the new import shape without losing intent. The ngrok tunnel-start sequence existed three times in server.ts — the /tunnel/start route and the BROWSE_TUNNEL=1 autostart were line-for-line equivalent (a comment admitted 'Same cleanup as /tunnel/start's error path'). One startTunnel() now owns the ephemeral loopback bind, the pre-send egress receipt, the state-file RMW via tmpStatePath(), and the ordered error-path cleanup; callers keep their distinct response surfaces. The BROWSE_TUNNEL_LOCAL_ONLY test path shares nothing (no ngrok, different state field) and deliberately stays separate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): one session-cookie registry implementation, two instances pty-session-cookie.ts and sse-session-cookie.ts were byte-identical modulo the cookie name — mint/validate/parse/prune/TTL, the exact code a security fix would have to land in twice (and a third hand-rolled cookie parse in terminal-agent.ts had already diverged; unified next commit). createSessionCookieStore() owns the implementation; both modules become thin instantiations keeping every exported name, their distinct threat-model docstrings, and separate token spaces (an SSE-read cookie must never grant PTY access). pty-session-lease.ts deliberately stays out — different contract (sessionId/secret split, refresh, env TTL). The factory imports nothing from token-registry (cookie-picker-auth-isolation invariant, still pinned by sse-session-cookie.test.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): terminal-agent uses the shared PTY cookie parser The /ws upgrade's cookie fallback hand-parsed the Cookie header inline — the fourth copy of the session-cookie parse, and the one that had already diverged from the others. Parsing now goes through extractPtyCookie; validation deliberately stays against the agent's own in-process validTokens map (the server's registry lives in a different process). The ws-handler pin test now pins the shared-parser call instead of the raw cookie-name literal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(hosts): defineHost() factory — 10 copy-paste host files become declarations hosts/*.ts were ten copies of one file: runtimeRoot byte-identical in 9/10, pathRewrites mechanically derivable from the host name for 7/10, the 11-entry toolRewrites map byte-identical between openclaw and gbrain, and every asset change a 10-file edit (cursor and slate had already fallen out of three other hand-maintained lists). defineHost() owns the defaults; each host file now declares only what makes it different (slate/cursor: 8 lines each). Shared constants: CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_REWRITES. Genuinely-different things stayed explicit: codex/factory $GSTACK_ROOT rewrites, hermes's tool vocabulary, claude's denylist+prefixable install, opencode's wider runtimeRoot. Proof: JSON.stringify(ALL_HOST_CONFIGS) dump-diff before/after EMPTY (and a runtime walk confirmed no function-valued or undefined-keyed fields, so the JSON diff is complete); gen:skill-docs --host all zero-diff; host-config + gen-skill-docs + idempotency suites 485/485. Host files 595 -> 285 lines. docs/ADDING_A_HOST.md teaches the factory pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lib): fs-atomic — one atomic-write implementation, with the race actually fixed Atomic tmp-write-then-rename was reimplemented ~20 times across lib/, bin/, and browse/src with three tmp-suffix conventions. One of them was a latent bug this commit closes: lib/worktree.ts used a bare '.tmp' suffix — the deterministic-tmp collision race browse/src/server.ts documents having hit in production (its fix, pid+random, was trapped in a comment at one site). lib/fs-atomic.ts: atomicWriteSync (always throws, best-effort tmp cleanup, pid+random suffix, optional mode applied at tmp creation so the file never exists with looser permissions) + atomicWriteQuiet (shutdown paths only). Unit tests pin the throw/quiet contracts, 0600 mode, tmp-name uniqueness (captured via the read-only-dir failure path — Bun's fs exports are readonly, no monkeypatching), and no-stray-tmp cleanup. Migrated: lib/worktree.ts (the bare-.tmp bug), lib/gstack-decision.ts (snapshot + compact log), lib/gbrain-local-status.ts (probe cache). browse sites follow separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(lib): jsonl-store's docstring stops lying; mode option added; lib bypasses adopted The header claimed 'single source of truth... the ONLY copy' with write-time injection REJECTION — while appendJsonl never screened anything, only 1 of ~10 JSONL stores imported it, and a bypass appender lived in the same directory. Now: the contract is explicit (screening is the CALLER's job via hasInjection/firstInjectionMatch; the enforcing callers are named), a option applies 0600 at create for sensitive stores, and the lib bypasses are adopted (gstack-memory-helpers ×2, redact-audit-log — which keeps its chmod backstop for files created looser by pre-mode versions). browse/src keeps its own appenders by design (compiled-binary surface, own secure-append helper) and the header now says so. gstack-decision's batched archive append stays deliberate (single-write crash-window semantics appendJsonl's one-record contract can't express). New pins: 0600-at-create, and a test that documents appendJsonl does NOT self-screen — so nobody can re-document it as self-screening without making it true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): migrate hand-rolled atomic writes to lib/fs-atomic Seven sites, each audited for its existing throw-vs-swallow contract before migrating: writeSessionState + the four fire-and-forget tab/state writers use atomicWriteQuiet (they swallowed before); writeAgentRecord + the boot-time port-file write use atomicWriteSync (they threw before — and writeAgentRecord previously leaked its tmp file on rename failure, which the helper cleans). All carry {mode: 0o600} plus restrictFilePermissions after successful writes, preserving the Windows ACL hardening that writeSecureFile provided (mode bits are POSIX-only). server.ts untouched: its three state writes route through tmpStatePath(), pinned by server-tmp-state-path.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hosts): delete five dead HostConfig fields metadataFormat (generator hardcodes openai.yaml), sidecar (behavior lives in setup's create_agents_sidecar — knowledge preserved as a comment in codex.ts), install.prefixable (skill_prefix is implemented entirely in bin/gstack-config), staticFiles (docstring cited a SOUL.md that never existed anywhere), and adapter (its only would-be consumer, openclaw-adapter.ts, was fully dead — with a test asserting the field was undefined). Kept: learningsMode (wired next), linkingStrategy (validation reads it), coAuthorTrailer (consumed by resolvers/utility.ts). Proof: JSON dump diff shows ONLY the deleted keys vanishing; zero-diff regen across all 10 hosts; host-config + gen-skill-docs suites green. Note: this commit also carries chunk-23 edits to the shared hosts/claude.ts + define-host.ts + host-config.test.ts files (skipSkills collapse, stale line-number comment drops) — pathspec commits, concurrent prep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): preamble tiers are explicit; silent ?? 4 default becomes an error; spec stops rendering its preamble twice Eight skills (scrape, diagram, spec, skillify, pair-agent, landing-report, open-gstack-browser + its connect-chrome symlink) silently received the HEAVIEST tier-4 preamble because a missing frontmatter field defaulted to 4. Tiers are now declared in every {{PREAMBLE}} template's frontmatter and a missing declaration throws at generation time with the template path (the 5 templates without {{PREAMBLE}} never invoke the resolver). The stale hand-written tier-map comment (wrong in 3 of 4 rows) is gone. Bonus bug fixed: spec/SKILL.md.tmpl mentioned {{PREAMBLE}} in prose, so the generator inlined the ENTIRE preamble a second time — spec/SKILL.md shrinks 127,462 -> 80,924 bytes (-46,538) from de-duplication alone. skill-size-budget gains a reasoned INTENTIONAL_SHRINKS entry (its frozen baseline had measured the doubled-preamble bug). New tests: missing-tier throw carries the path; every {{PREAMBLE}} template declares a tier. (Carries chunk-23 edits in the shared test/gen-skill-docs.test.ts.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): learningsMode is read from host config, not a hardcoded host name resolvers/learnings.ts branched on ctx.host === 'codex' while every host declared learningsMode — the field was decorative, and the 7 hosts configured 'basic' (cursor, slate, kiro, opencode, openclaw, hermes, gbrain) silently received the 'full' cross-project flow their runtimes can't execute (it depends on AskUserQuestion + gstack-config plumbing). Output now matches declaration: basic hosts get the project-scoped search block. Blast radius proof: all committed Claude SKILL.md files and the three golden fixtures are byte-identical; the behavior diff lands only in the gitignored external-host trees (hand-verified: .cursor review's learnings section swaps the cross-project AskUserQuestion block for the project-scoped search). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): small config scrubs — openclaw blobs to real files, setup host drift, dead artifacts - The three openclaw markdown blobs hardcoded inside gen-skill-docs.ts (which silently reverted any hand edit to their tracked outputs on regen) move to openclaw/templates/*.md source files; output shasums byte-identical. - setup's --host allowlists gain cursor + slate — both fully registered hosts with generated output, but './setup --host cursor' exited 1 because two hand-rolled lists in setup had drifted from hosts/index.ts. - scripts/proactive-suggestions.json deleted: 31KB regenerated on every run, read by nobody (the catalog-trim design's reader was never built); its emitter and three determinism tests (which guaranteed a file nothing reads didn't churn) retired with stays-retired pins. - claude/SKILL.md.tmpl deleted: a complete 8.9KB skill that never generated output (directory name collides with the host id 'claude'), in no registry. Recoverable from git if ever wanted under a non-colliding name. - openclaw's frozen extraFields.version '0.15.2.0' stamp dropped; includeSkills: [] no-ops omitted (the generator treats [] as absent); llms.txt 55 -> 54 skills. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(gen): correct preamble tiers for the 8 silently-heaviest skills With tiers now explicit, set them RIGHT by analogy to the tiered population: scrape/diagram/open-gstack-browser (+ the connect-chrome symlink) -> tier 1 (launchers and artifact generators, like browse and make-pdf); landing-report/pair-agent/skillify -> tier 2 (dashboards and session tools, like health and canary); spec -> tier 3 (interactive planning, like the plan-*-review family). Each tier-1 skill sheds 271 lines of onboarding prose it never needed; tier-2 shed 20 each. Verification per the review protocol: regen diff reviewed (pure section-removal), skill-validation + size-budget + catalog-budget + v0-dormancy suites green (822 tests), and live smoke of the tier-corrected skills confirms the preamble renders the intended sections at each tier. These skills have ~no eval coverage — stated honestly; the wave's gate-tier eval run is the backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): e2e-gate — one tier-gate implementation, side-effect-free, with the trap pinned The EVALS/EVALS_TIER gate was copy-pasted into ~40 test files and had drifted into six different predicates — the drift that made 'eval:bg:all runs everything' silently false. test/helpers/e2e-gate.ts owns the semantics now: describeE2ETier(tier) + e2eTierEnabled(tier), env read at call time, zero side effects (the existing e2e-helpers module runs a ~30s claude ping at import under EVALS=1, so the gate lives in its own module; purity is pinned by tests that scan imports and comment-stripped source). The unit matrix pins all four env combos — including EVALS=1 with EVALS_TIER unset -> SKIP, the exact trap that made eval:bg:all a non-run. The tier-alignment tripwire gains a second regex for the helper shape (old shape still detected — stragglers can't hide), and the sharded paid runner's PRE-SPAWN tier classifier learns the helper shape too: without that, every gate-sharded run would have spawned all 28 periodic shards just to skip them, each paying the e2e-helpers import ping (~15 min of dead wall clock in the CI-blocking lane). Verified: gate runs exclude the 29 periodic files, periodic excludes the 8 gate files — identical to pre-migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): migrate the 36 tier-gated eval files to describeE2ETier Mechanical two-liner swap in 34 files (each keeping its declared tier — all 36 predicates verified against E2E_TIERS before migrating); the two files with compound gates (overlay-harness's EvalCollector feed, codex-e2e's CODEX_AVAILABLE) keep their extra conditions via e2eTierEnabled. Tier rationale comments preserved. codex-e2e/gemini-e2e/benchmark-providers keep their distinct stderr-message gate shapes by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): skill-e2e + skill-llm-eval adopt the shared selection machinery Both files re-implemented the diff-selection machinery e2e-helpers already exported. The helper gained computeDiffSelection() (extracted, identical behavior) and a trailing optional selection param on the *IfSelected helpers (defaults preserve all 30+ existing importers). skill-e2e.test.ts drops ~120 duplicated lines; skill-llm-eval keeps its LLM_JUDGE_TOUCHFILES selection and test.concurrent semantics via testConcurrentIfSelected. Deliberate deltas, stated: skill-e2e.test.ts now honors the EVALS_TIER intersection its local copy lacked (affects only direct bun test invocations of that file — it matches no eval-script glob); its recordE2E gains the helper's three diagnostic fields; skill-llm-eval sharded solo now runs e2e-helpers' module-scope preflight it already ran in combined processes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): kill the silent-truncation race; exempt the tier-corrected shrinks The full-suite shakeout (budgeted by the plan) surfaced both immediately: 1. server-embedder-terminal-port.test.ts stubbed process.exit and restored the REAL exit in its finally — but shutdown() schedules async work that can call process.exit AFTER restoration, killing the entire bun process mid-suite with exit 0 and NO summary. This is the silent-truncation class the new free-suite CI job guards against, reproduced locally on the first full run. Exit now stays a logging no-op between tests (late async exits become visible stderr lines, not process death); the true exit returns in afterAll. 2. The 80%-of-baseline shrink guard correctly flagged the six tier-corrected skills — their baseline was measured at the silent tier-4 default. Added to INTENTIONAL_SHRINKS with the reason, joining spec's double-preamble entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release: v1.64.0.0 — the code-smell fix wave 35 commits, one PR: guard repairs (free suite in CI per-file, all-host freshness gates, tunnel allowlist, diff-selection validation), the sidebar-agent ghost exorcism (dead ML layers, dead endpoints, dead exports, ghost comments), config honesty (defineHost factory, dead fields deleted, preamble tiers explicit, spec double-render fixed), and dedup with safety nets (session-cookie factory, fs-atomic, jsonl-store contract, one eval tier-gate). Net -24,943 lines across 183 files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests step runs under bash (container sh rejects pipefail) Maiden-voyage shakeout, exactly as budgeted: the CI container's default shell is dash, which errors on 'set -o pipefail' before the first test ran. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests curates 8 container-incompatible files with reasons Second maiden-voyage shakeout round: 376 of 384 files ran green in the container on the first completed pass. The 8 that can't run there yet are excluded the same way the Windows shards curate POSIX-bound files — each with its reason inline (headed-Chrome handoff, real-PTY round-trip, X server management, extension-origin identity, the job's own TMPDIR override, and three pre-existing env failures that fail on dev machines too). Anything outside the list that fails still fails the job; trimming the list is tracked follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-config-key-locale — suppress the skill_prefix auto-relink side effect The test invokes the repo's own bin/gstack-config, whose 'set skill_prefix' auto-runs $(dirname $0)/gstack-relink — resolving the install dir to the repo itself. In any environment where the loop shares a working tree (the free-tests CI container, a fresh-HOME run), gstack-patch-names rewrote all 52 tracked SKILL.md names to gstack- prefixed, poisoning five unrelated suites downstream (hermetic-skills-seeding, host-config golden, skill-census, skill-validation, spec-template-sync). GSTACK_SETUP_RUNNING=1 is the documented suppression; relink behavior stays covered by relink.test.ts's mock install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-codex-session-import — empty sessions dir exits 0 on Linux GNU xargs runs 'ls -t' once even on empty input, listing the cwd and producing a bogus LATEST from the repo root; BSD xargs (macOS) skips the run, which is why the NO_SESSIONS path only broke on Linux. xargs -r pins the BSD behavior on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(parity): rebaseline v1.57.7.0 → v1.64.1.0 + skeleton-cap headroom The two parallel v1.64 waves (code-smell fix wave + main's #2571) each added shared-preamble prose, pushing document-release / design-consultation / cso past their size ratios on the v1.57.7.0 anchor and four carved skeletons (plan-ceo-review, plan-eng-review, office-hours, design-consultation) 22-280 B over their absolute caps. New baseline is union-normalized (skeleton + sections/*.md, matching what the harness measures); caps get +~1 KB headroom each with per-cap rationale. The v1.57.7.0 fixture stays in test/fixtures/ for the audit trail, and capture-parity-baseline.ts now documents the union-normalization step so the next rebaseline doesn't re-trip on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests container parity — tools, pinned bun, git identity, mutation tripwire - Dockerfile.ci: add python3 (gstack-jsonl-merge/brain-sync/detach shell out to it), file (skill-validation's binary check), poppler-utils (make-pdf e2e gates hard-require pdftotext/pdffonts/pdfinfo), fonts-noto-color-emoji (emoji render gate, mirrors make-pdf-gate.yml). Fix the bun pin: the bun.sh installer ignores a BUN_VERSION env var, so the old form silently installed latest on every rebuild (observed 1.3.13/1.3.14 drift vs the 1.3.10 devs run locally); pass the version as the positional arg. - free-tests.yml: git identity + safe.directory for the git-exercising tests (container checkout is owned by a different uid than runner); post-loop tree-mutation tripwire that names a tracked-file-mutating test instead of letting downstream collateral confuse the report; skip the documented variants-retry-after timing flake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-session-update — detached updater owns its stdio (SIGPIPE) The backgrounded update subshell inherited the session hook's stdout/stderr pipes. Once the hook exits and the caller closes them, any child that writes — git pull's autostash notice, setup output — dies of SIGPIPE, logged as PULL_FAILED exit=141 with an empty stderr capture (observed in the free-tests container, and reachable by any production hook runner that closes stdio promptly). Redirect the fork to /dev/null; all observability already flows through the session-update log file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-decision-bins — explicit branch context for the scope filter CI checks out a detached HEAD, where gitBranch() returns undefined on both the log and search sides, so an implicitly branch-scoped decision can never surface (filterByScope requires a matching non-empty ctx.branch). Pass the branch explicitly on both sides — the filter logic is what's under test, not git branch detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): ring-buffer lease interplay — same TTL window, not same millisecond Two back-to-back mintLease() calls each stamp Date.now() + TTL; when they straddle a millisecond boundary the exact-equality assertion flakes (observed in CI: expiries of ...525 vs ...526). Assert the expiries are within a 50 ms window instead — the invariant under test is that leases share a TTL policy, not that they mint in the same clock tick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2619 lines
99 KiB
TypeScript
2619 lines
99 KiB
TypeScript
/**
|
|
* Integration tests for all browse commands
|
|
*
|
|
* Tests run against a local test server serving fixture HTML files.
|
|
* A real browse server is started and commands are sent via the CLI HTTP interface.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { startTestServer } from './test-server';
|
|
import { BrowserManager } from '../src/browser-manager';
|
|
import { resolveServerScript } from '../src/cli';
|
|
import { handleReadCommand as _handleReadCommand, parseOutArgs, hasOutArg, resultToString } from '../src/read-commands';
|
|
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
|
import { handleMetaCommand } from '../src/meta-commands';
|
|
import { WRITE_COMMANDS, READ_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from '../src/commands';
|
|
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers';
|
|
import * as fs from 'fs';
|
|
import { spawn } from 'child_process';
|
|
import * as path from 'path';
|
|
|
|
// Thin wrappers that bridge old test calls (bm as 3rd arg) to new signatures (session + bm)
|
|
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
|
_handleReadCommand(cmd, args, b.getActiveSession(), b);
|
|
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
|
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
|
|
|
// Chain routes every subcommand through the server's executeCommand pipeline in
|
|
// production (the direct-dispatch fallback was deleted — it skipped the security
|
|
// gates). Tests mirror the pipeline minimally: real handlers + trust-wrapping,
|
|
// server-shaped {status, result} envelope.
|
|
function makeChainExecute(b: BrowserManager) {
|
|
return async (body: { command: string; args?: string[] }) => {
|
|
const name = body.command;
|
|
const args = body.args ?? [];
|
|
try {
|
|
let result: string;
|
|
if (WRITE_COMMANDS.has(name)) {
|
|
result = await _handleWriteCommand(name, args, b.getActiveSession(), b);
|
|
} else if (READ_COMMANDS.has(name)) {
|
|
result = await _handleReadCommand(name, args, b.getActiveSession(), b);
|
|
if (PAGE_CONTENT_COMMANDS.has(name)) {
|
|
result = wrapUntrustedContent(result, b.getCurrentUrl());
|
|
}
|
|
} else if (META_COMMANDS.has(name)) {
|
|
result = await handleMetaCommand(name, args, b, async () => {});
|
|
} else {
|
|
return { status: 404, result: JSON.stringify({ error: `Unknown command: ${name}` }) };
|
|
}
|
|
return { status: 200, result };
|
|
} catch (err: any) {
|
|
return { status: 500, result: JSON.stringify({ error: err.message }) };
|
|
}
|
|
};
|
|
}
|
|
const chainMeta = (b: BrowserManager, args: string[]) =>
|
|
handleMetaCommand('chain', args, b, async () => {}, null, { executeCommand: makeChainExecute(b) });
|
|
|
|
// ─── Pure arg-parser + result-conversion unit tests (no browser) ───
|
|
describe('parseOutArgs / hasOutArg', () => {
|
|
test('--out <path> splits the flag from the positional', () => {
|
|
expect(parseOutArgs(['expr', '--out', '/tmp/x'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
|
|
});
|
|
|
|
test('--out=<path> form is equivalent', () => {
|
|
expect(parseOutArgs(['expr', '--out=/tmp/x'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
|
|
});
|
|
|
|
test('flag ordering does not matter', () => {
|
|
expect(parseOutArgs(['--out', '/tmp/x', 'expr'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
|
|
});
|
|
|
|
test('--raw and --raw=true|false', () => {
|
|
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw']).raw).toBe(true);
|
|
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw=true']).raw).toBe(true);
|
|
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw=false']).raw).toBe(false);
|
|
});
|
|
|
|
test('repeated --out throws', () => {
|
|
expect(() => parseOutArgs(['e', '--out', '/a', '--out', '/b'])).toThrow(/more than once/);
|
|
});
|
|
|
|
test('--out with a missing value throws', () => {
|
|
expect(() => parseOutArgs(['e', '--out'])).toThrow(/requires a file path/);
|
|
expect(() => parseOutArgs(['e', '--out', '--raw'])).toThrow(/requires a file path/);
|
|
expect(() => parseOutArgs(['e', '--out='])).toThrow(/requires a file path/);
|
|
});
|
|
|
|
test('bad --raw value throws', () => {
|
|
expect(() => parseOutArgs(['e', '--out', '/a', '--raw=maybe'])).toThrow(/--raw must be true or false/);
|
|
});
|
|
|
|
test('hasOutArg matches --out and --out= exactly, not lookalikes', () => {
|
|
expect(hasOutArg(['a', '--out', 'b'])).toBe(true);
|
|
expect(hasOutArg(['a', '--out=b'])).toBe(true);
|
|
expect(hasOutArg(['a'])).toBe(false);
|
|
expect(hasOutArg(['a', '--output', 'b'])).toBe(false);
|
|
expect(hasOutArg(['a', '--outx'])).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resultToString — byte-for-byte with pre-refactor behavior', () => {
|
|
test('null becomes "null" (typeof null === object → JSON.stringify)', () => {
|
|
expect(resultToString(null)).toBe('null');
|
|
});
|
|
test('undefined becomes empty string', () => {
|
|
expect(resultToString(undefined)).toBe('');
|
|
});
|
|
test('objects are pretty-printed JSON', () => {
|
|
expect(resultToString({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2));
|
|
});
|
|
test('primitives use String()', () => {
|
|
expect(resultToString(42)).toBe('42');
|
|
expect(resultToString(true)).toBe('true');
|
|
});
|
|
});
|
|
|
|
let testServer: ReturnType<typeof startTestServer>;
|
|
let bm: BrowserManager;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
testServer = startTestServer(0);
|
|
baseUrl = testServer.url;
|
|
|
|
bm = new BrowserManager();
|
|
await bm.launch();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try { testServer.server.stop(); } catch {}
|
|
// Close only this file's own browser — never process.exit(): bun test runs
|
|
// all files in one process, so a delayed exit kills the whole suite
|
|
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
|
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
|
|
// so race it at 3s and abandon; the child is reaped at process exit.
|
|
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
|
});
|
|
|
|
// ─── Navigation ─────────────────────────────────────────────────
|
|
|
|
describe('Navigation', () => {
|
|
test('goto navigates to URL', async () => {
|
|
const result = await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
expect(result).toContain('Navigated to');
|
|
expect(result).toContain('200');
|
|
});
|
|
|
|
test('url returns current URL', async () => {
|
|
const result = await handleMetaCommand('url', [], bm, async () => {});
|
|
expect(result).toContain('/basic.html');
|
|
});
|
|
|
|
test('back goes back', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
const result = await handleWriteCommand('back', [], bm);
|
|
expect(result).toContain('Back');
|
|
});
|
|
|
|
test('forward goes forward', async () => {
|
|
const result = await handleWriteCommand('forward', [], bm);
|
|
expect(result).toContain('Forward');
|
|
});
|
|
|
|
test('reload reloads page', async () => {
|
|
const result = await handleWriteCommand('reload', [], bm);
|
|
expect(result).toContain('Reloaded');
|
|
});
|
|
});
|
|
|
|
// ─── Content Extraction ─────────────────────────────────────────
|
|
|
|
describe('Content extraction', () => {
|
|
beforeAll(async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
});
|
|
|
|
test('text returns cleaned page text', async () => {
|
|
const result = await handleReadCommand('text', [], bm);
|
|
expect(result).toContain('Hello World');
|
|
expect(result).toContain('Item one');
|
|
expect(result).not.toContain('<h1>');
|
|
});
|
|
|
|
test('html returns full page HTML', async () => {
|
|
const result = await handleReadCommand('html', [], bm);
|
|
expect(result).toContain('<!DOCTYPE html>');
|
|
expect(result).toContain('<h1 id="title">Hello World</h1>');
|
|
});
|
|
|
|
test('html with selector returns element innerHTML', async () => {
|
|
const result = await handleReadCommand('html', ['#content'], bm);
|
|
expect(result).toContain('Some body text here.');
|
|
expect(result).toContain('<li>Item one</li>');
|
|
});
|
|
|
|
test('links returns all links', async () => {
|
|
const result = await handleReadCommand('links', [], bm);
|
|
expect(result).toContain('Page 1');
|
|
expect(result).toContain('Page 2');
|
|
expect(result).toContain('External');
|
|
expect(result).toContain('→');
|
|
});
|
|
|
|
test('forms discovers form fields', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
const result = await handleReadCommand('forms', [], bm);
|
|
const forms = JSON.parse(result);
|
|
expect(forms.length).toBe(2);
|
|
expect(forms[0].id).toBe('login-form');
|
|
expect(forms[0].method).toBe('post');
|
|
expect(forms[0].fields.length).toBeGreaterThanOrEqual(2);
|
|
expect(forms[1].id).toBe('profile-form');
|
|
|
|
// Check field discovery
|
|
const emailField = forms[0].fields.find((f: any) => f.name === 'email');
|
|
expect(emailField).toBeDefined();
|
|
expect(emailField.type).toBe('email');
|
|
expect(emailField.required).toBe(true);
|
|
});
|
|
|
|
test('accessibility returns ARIA tree', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleReadCommand('accessibility', [], bm);
|
|
expect(result).toContain('Hello World');
|
|
});
|
|
});
|
|
|
|
// ─── JavaScript / CSS / Attrs ───────────────────────────────────
|
|
|
|
describe('Inspection', () => {
|
|
beforeAll(async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
});
|
|
|
|
test('js evaluates expression', async () => {
|
|
const result = await handleReadCommand('js', ['document.title'], bm);
|
|
expect(result).toBe('Test Page - Basic');
|
|
});
|
|
|
|
test('js returns objects as JSON', async () => {
|
|
const result = await handleReadCommand('js', ['({a: 1, b: 2})'], bm);
|
|
const obj = JSON.parse(result);
|
|
expect(obj.a).toBe(1);
|
|
expect(obj.b).toBe(2);
|
|
});
|
|
|
|
test('js supports await expressions', async () => {
|
|
const result = await handleReadCommand('js', ['await Promise.resolve(42)'], bm);
|
|
expect(result).toBe('42');
|
|
});
|
|
|
|
test('js does not false-positive on await substring', async () => {
|
|
const result = await handleReadCommand('js', ['(() => { const awaitable = 5; return awaitable })()'], bm);
|
|
expect(result).toBe('5');
|
|
});
|
|
|
|
test('eval supports await in single-line file', async () => {
|
|
const tmp = '/tmp/eval-await-test.js';
|
|
fs.writeFileSync(tmp, 'await Promise.resolve("hello from eval")');
|
|
try {
|
|
const result = await handleReadCommand('eval', [tmp], bm);
|
|
expect(result).toBe('hello from eval');
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|
|
|
|
test('eval does not wrap when await is only in a comment', async () => {
|
|
const tmp = '/tmp/eval-comment-test.js';
|
|
fs.writeFileSync(tmp, '// no need to await this\ndocument.title');
|
|
try {
|
|
const result = await handleReadCommand('eval', [tmp], bm);
|
|
expect(result).toBe('Test Page - Basic');
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|
|
|
|
test('eval multi-line with await and explicit return', async () => {
|
|
const tmp = '/tmp/eval-multiline-await.js';
|
|
fs.writeFileSync(tmp, 'const data = await Promise.resolve("multi");\nreturn data;');
|
|
try {
|
|
const result = await handleReadCommand('eval', [tmp], bm);
|
|
expect(result).toBe('multi');
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|
|
|
|
test('eval multi-line with await but no return gives empty string', async () => {
|
|
const tmp = '/tmp/eval-multiline-no-return.js';
|
|
fs.writeFileSync(tmp, 'const data = await Promise.resolve("lost");\ndata;');
|
|
try {
|
|
const result = await handleReadCommand('eval', [tmp], bm);
|
|
expect(result).toBe('');
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|
|
|
|
test('js handles multi-line with await', async () => {
|
|
const code = 'const x = await Promise.resolve(42);\nreturn x;';
|
|
const result = await handleReadCommand('js', [code], bm);
|
|
expect(result).toBe('42');
|
|
});
|
|
|
|
test('js handles await with semicolons', async () => {
|
|
const result = await handleReadCommand('js', ['const x = await Promise.resolve(5); return x + 1;'], bm);
|
|
expect(result).toBe('6');
|
|
});
|
|
|
|
test('js handles await with statement keywords', async () => {
|
|
const result = await handleReadCommand('js', ['const res = await Promise.resolve("ok"); return res;'], bm);
|
|
expect(result).toBe('ok');
|
|
});
|
|
|
|
test('js still works for simple expressions', async () => {
|
|
const result = await handleReadCommand('js', ['1 + 2'], bm);
|
|
expect(result).toBe('3');
|
|
});
|
|
|
|
// ─── js/eval --out (render-to-file) ───────────────────────────
|
|
|
|
test('js (no --out) returns a multi-MB string without truncation', async () => {
|
|
// Handler-level guarantee: the result is not sliced/capped before return.
|
|
// (Full HTTP egress path is exercised elsewhere; this pins the handler.)
|
|
const result = await handleReadCommand('js', ["'x'.repeat(3 * 1024 * 1024)"], bm);
|
|
expect(result.length).toBe(3 * 1024 * 1024);
|
|
});
|
|
|
|
test('js --out writes the result to disk and returns a short status, not the payload', async () => {
|
|
const out = `/tmp/browse-out-large-${Date.now()}.txt`;
|
|
try {
|
|
const result = await handleReadCommand('js', ["'y'.repeat(2 * 1024 * 1024)", '--out', out], bm);
|
|
expect(result).toContain('JS result written:');
|
|
expect(result).toContain(out);
|
|
expect(result).toContain(`(${2 * 1024 * 1024} bytes)`);
|
|
expect(result.length).toBeLessThan(200); // status, not the 2MB payload
|
|
expect(fs.statSync(out).size).toBe(2 * 1024 * 1024);
|
|
} finally {
|
|
fs.rmSync(out, { force: true });
|
|
}
|
|
});
|
|
|
|
test('js --out decodes a base64 PNG data URL to real bytes', async () => {
|
|
// 1x1 transparent PNG.
|
|
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
|
const out = `/tmp/browse-out-png-${Date.now()}.png`;
|
|
try {
|
|
const result = await handleReadCommand('js', [`'data:image/png;base64,' + '${b64}'`, '--out', out], bm);
|
|
const buf = fs.readFileSync(out);
|
|
// PNG magic bytes: 89 50 4E 47
|
|
expect([buf[0], buf[1], buf[2], buf[3]]).toEqual([0x89, 0x50, 0x4e, 0x47]);
|
|
const expectedLen = Buffer.from(b64, 'base64').length;
|
|
expect(buf.length).toBe(expectedLen);
|
|
expect(result).toContain(`(${expectedLen} bytes)`);
|
|
} finally {
|
|
fs.rmSync(out, { force: true });
|
|
}
|
|
});
|
|
|
|
test('js --out --raw writes the literal data-URL string (no decode)', async () => {
|
|
const dataUrl = 'data:text/plain;base64,aGVsbG8=';
|
|
const out = `/tmp/browse-out-raw-${Date.now()}.txt`;
|
|
try {
|
|
await handleReadCommand('js', [`'${dataUrl}'`, '--out', out, '--raw'], bm);
|
|
expect(fs.readFileSync(out, 'utf-8')).toBe(dataUrl);
|
|
} finally {
|
|
fs.rmSync(out, { force: true });
|
|
}
|
|
});
|
|
|
|
test('js --out throws on a malformed base64 data URL instead of writing corrupt bytes', async () => {
|
|
const out = `/tmp/browse-out-bad-${Date.now()}.png`;
|
|
try {
|
|
await expect(
|
|
handleReadCommand('js', ["'data:image/png;base64,!!!not-base64!!!'", '--out', out], bm)
|
|
).rejects.toThrow(/malformed base64/);
|
|
expect(fs.existsSync(out)).toBe(false);
|
|
} finally {
|
|
fs.rmSync(out, { force: true });
|
|
}
|
|
});
|
|
|
|
test('js --out rejects a path outside the safe directories', async () => {
|
|
await expect(
|
|
handleReadCommand('js', ['1 + 1', '--out', '/etc/browse-should-not-write.txt'], bm)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
test('js --out creates a missing parent directory', async () => {
|
|
// validateOutputPath resolves the parent's realpath, so it permits one level
|
|
// of missing dir under a safe root (/tmp). mkdir then materializes it.
|
|
const root = `/tmp/browse-out-nested-${Date.now()}`;
|
|
const out = `${root}/result.txt`;
|
|
try {
|
|
await handleReadCommand('js', ["'nested'", '--out', out], bm);
|
|
expect(fs.readFileSync(out, 'utf-8')).toBe('nested');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('eval --out writes the file result to disk (parity with js)', async () => {
|
|
const script = `/tmp/browse-eval-out-src-${Date.now()}.js`;
|
|
const out = `/tmp/browse-eval-out-${Date.now()}.txt`;
|
|
fs.writeFileSync(script, "'from eval'");
|
|
try {
|
|
const result = await handleReadCommand('eval', [script, '--out', out], bm);
|
|
expect(result).toContain('Eval result written:');
|
|
expect(fs.readFileSync(out, 'utf-8')).toBe('from eval');
|
|
} finally {
|
|
fs.rmSync(script, { force: true });
|
|
fs.rmSync(out, { force: true });
|
|
}
|
|
});
|
|
|
|
test('css returns computed property', async () => {
|
|
const result = await handleReadCommand('css', ['h1', 'color'], bm);
|
|
// Navy color
|
|
expect(result).toContain('0, 0, 128');
|
|
});
|
|
|
|
test('css returns font-family', async () => {
|
|
const result = await handleReadCommand('css', ['body', 'font-family'], bm);
|
|
expect(result).toContain('Helvetica');
|
|
});
|
|
|
|
test('attrs returns element attributes', async () => {
|
|
const result = await handleReadCommand('attrs', ['#content'], bm);
|
|
const attrs = JSON.parse(result);
|
|
expect(attrs.id).toBe('content');
|
|
expect(attrs['data-testid']).toBe('main-content');
|
|
expect(attrs['data-version']).toBe('1.0');
|
|
});
|
|
});
|
|
|
|
// ─── Interaction ────────────────────────────────────────────────
|
|
|
|
describe('Interaction', () => {
|
|
test('fill + click works on form', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
|
|
let result = await handleWriteCommand('fill', ['#email', 'test@example.com'], bm);
|
|
expect(result).toContain('Filled');
|
|
|
|
result = await handleWriteCommand('fill', ['#password', 'secret123'], bm);
|
|
expect(result).toContain('Filled');
|
|
|
|
// Verify values were set
|
|
const emailVal = await handleReadCommand('js', ['document.querySelector("#email").value'], bm);
|
|
expect(emailVal).toBe('test@example.com');
|
|
|
|
result = await handleWriteCommand('click', ['#login-btn'], bm);
|
|
expect(result).toContain('Clicked');
|
|
});
|
|
|
|
test('select works on dropdown', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
const result = await handleWriteCommand('select', ['#role', 'admin'], bm);
|
|
expect(result).toContain('Selected');
|
|
|
|
const val = await handleReadCommand('js', ['document.querySelector("#role").value'], bm);
|
|
expect(val).toBe('admin');
|
|
});
|
|
|
|
test('click on option ref auto-routes to selectOption', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
// Reset select to default
|
|
await handleReadCommand('js', ['document.querySelector("#role").value = ""'], bm);
|
|
const snap = await handleMetaCommand('snapshot', [], bm, async () => {});
|
|
// Find an option ref (e.g., "Admin" option)
|
|
const optionLine = snap.split('\n').find((l: string) => l.includes('[option]') && l.includes('"Admin"'));
|
|
expect(optionLine).toBeDefined();
|
|
const refMatch = optionLine!.match(/@(e\d+)/);
|
|
expect(refMatch).toBeDefined();
|
|
const ref = `@${refMatch![1]}`;
|
|
const result = await handleWriteCommand('click', [ref], bm);
|
|
expect(result).toContain('auto-routed');
|
|
expect(result).toContain('Selected');
|
|
// Verify the select value actually changed
|
|
const val = await handleReadCommand('js', ['document.querySelector("#role").value'], bm);
|
|
expect(val).toBe('admin');
|
|
});
|
|
|
|
test('click CSS selector on option gives helpful error', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
try {
|
|
await handleWriteCommand('click', ['option[value="admin"]'], bm);
|
|
expect(true).toBe(false); // Should not reach here
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('select');
|
|
expect(err.message).toContain('option');
|
|
}
|
|
}, 15000);
|
|
|
|
test('hover works', async () => {
|
|
const result = await handleWriteCommand('hover', ['h1'], bm);
|
|
expect(result).toContain('Hovered');
|
|
});
|
|
|
|
test('wait finds existing element', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('wait', ['#title'], bm);
|
|
expect(result).toContain('appeared');
|
|
});
|
|
|
|
test('scroll works', async () => {
|
|
const result = await handleWriteCommand('scroll', ['footer'], bm);
|
|
expect(result).toContain('Scrolled');
|
|
});
|
|
|
|
test('viewport changes size', async () => {
|
|
const result = await handleWriteCommand('viewport', ['375x812'], bm);
|
|
expect(result).toContain('Viewport set');
|
|
|
|
const size = await handleReadCommand('js', ['`${window.innerWidth}x${window.innerHeight}`'], bm);
|
|
expect(size).toBe('375x812');
|
|
|
|
// Reset
|
|
await handleWriteCommand('viewport', ['1280x720'], bm);
|
|
});
|
|
|
|
test('type and press work', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
await handleWriteCommand('click', ['#name'], bm);
|
|
|
|
const result = await handleWriteCommand('type', ['John Doe'], bm);
|
|
expect(result).toContain('Typed');
|
|
|
|
const val = await handleReadCommand('js', ['document.querySelector("#name").value'], bm);
|
|
expect(val).toBe('John Doe');
|
|
});
|
|
});
|
|
|
|
// ─── SPA / Console / Network ───────────────────────────────────
|
|
|
|
describe('SPA and buffers', () => {
|
|
test('wait handles delayed rendering', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/spa.html'], bm);
|
|
const result = await handleWriteCommand('wait', ['.loaded'], bm);
|
|
expect(result).toContain('appeared');
|
|
|
|
const text = await handleReadCommand('text', [], bm);
|
|
expect(text).toContain('SPA Content Loaded');
|
|
});
|
|
|
|
test('console captures messages', async () => {
|
|
const result = await handleReadCommand('console', [], bm);
|
|
expect(result).toContain('[SPA] Starting render');
|
|
expect(result).toContain('[SPA] Render complete');
|
|
});
|
|
|
|
test('console --clear clears buffer', async () => {
|
|
const result = await handleReadCommand('console', ['--clear'], bm);
|
|
expect(result).toContain('cleared');
|
|
|
|
const after = await handleReadCommand('console', [], bm);
|
|
expect(after).toContain('no console messages');
|
|
});
|
|
|
|
test('network captures requests', async () => {
|
|
const result = await handleReadCommand('network', [], bm);
|
|
expect(result).toContain('GET');
|
|
expect(result).toContain('/spa.html');
|
|
});
|
|
|
|
test('network --clear clears buffer', async () => {
|
|
const result = await handleReadCommand('network', ['--clear'], bm);
|
|
expect(result).toContain('cleared');
|
|
});
|
|
});
|
|
|
|
// ─── Cookies / Storage ──────────────────────────────────────────
|
|
|
|
describe('Cookies and storage', () => {
|
|
test('cookies returns array', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleReadCommand('cookies', [], bm);
|
|
// Test server doesn't set cookies, so empty array
|
|
expect(result).toBe('[]');
|
|
});
|
|
|
|
test('storage set and get works', async () => {
|
|
await handleReadCommand('storage', ['set', 'testData', 'testValue'], bm);
|
|
const result = await handleReadCommand('storage', [], bm);
|
|
const storage = JSON.parse(result);
|
|
expect(storage.localStorage.testData).toBe('testValue');
|
|
});
|
|
|
|
test('storage read redacts sensitive keys', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
await handleReadCommand('storage', ['set', 'auth_token', 'my-secret-token'], bm);
|
|
await handleReadCommand('storage', ['set', 'api_key', 'key-12345'], bm);
|
|
await handleReadCommand('storage', ['set', 'displayName', 'normalValue'], bm);
|
|
const result = await handleReadCommand('storage', [], bm);
|
|
const storage = JSON.parse(result);
|
|
expect(storage.localStorage.auth_token).toMatch(/REDACTED/);
|
|
expect(storage.localStorage.api_key).toMatch(/REDACTED/);
|
|
expect(storage.localStorage.displayName).toBe('normalValue');
|
|
});
|
|
|
|
test('storage read redacts sensitive values by prefix', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
// JWT value under innocuous key name
|
|
await handleReadCommand('storage', ['set', 'userData', 'eyJhbGciOiJIUzI1NiJ9.payload.sig'], bm);
|
|
// GitHub PAT under innocuous key name
|
|
await handleReadCommand('storage', ['set', 'repoAccess', 'ghp_abc123def456'], bm);
|
|
const result = await handleReadCommand('storage', [], bm);
|
|
const storage = JSON.parse(result);
|
|
expect(storage.localStorage.userData).toMatch(/REDACTED/);
|
|
expect(storage.localStorage.repoAccess).toMatch(/REDACTED/);
|
|
});
|
|
|
|
test('storage redaction includes value length', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
await handleReadCommand('storage', ['set', 'session_token', 'abc123'], bm);
|
|
const result = await handleReadCommand('storage', [], bm);
|
|
const storage = JSON.parse(result);
|
|
expect(storage.localStorage.session_token).toBe('[REDACTED — 6 chars]');
|
|
});
|
|
});
|
|
|
|
// ─── Performance ────────────────────────────────────────────────
|
|
|
|
describe('Performance', () => {
|
|
test('perf returns timing data', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleReadCommand('perf', [], bm);
|
|
expect(result).toContain('dns');
|
|
expect(result).toContain('ttfb');
|
|
expect(result).toContain('load');
|
|
expect(result).toContain('ms');
|
|
});
|
|
});
|
|
|
|
// ─── Visual ─────────────────────────────────────────────────────
|
|
|
|
describe('Visual', () => {
|
|
test('screenshot saves file', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const screenshotPath = '/tmp/browse-test-screenshot.png';
|
|
const result = await handleMetaCommand('screenshot', [screenshotPath], bm, async () => {});
|
|
expect(result).toContain('Screenshot saved');
|
|
expect(fs.existsSync(screenshotPath)).toBe(true);
|
|
const stat = fs.statSync(screenshotPath);
|
|
expect(stat.size).toBeGreaterThan(1000);
|
|
fs.unlinkSync(screenshotPath);
|
|
});
|
|
|
|
test('screenshot --viewport saves viewport-only', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const p = '/tmp/browse-test-viewport.png';
|
|
const result = await handleMetaCommand('screenshot', ['--viewport', p], bm, async () => {});
|
|
expect(result).toContain('Screenshot saved (viewport)');
|
|
expect(fs.existsSync(p)).toBe(true);
|
|
expect(fs.statSync(p).size).toBeGreaterThan(1000);
|
|
fs.unlinkSync(p);
|
|
});
|
|
|
|
test('screenshot with CSS selector crops to element', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const p = '/tmp/browse-test-element-css.png';
|
|
const result = await handleMetaCommand('screenshot', ['#title', p], bm, async () => {});
|
|
expect(result).toContain('Screenshot saved (element)');
|
|
expect(fs.existsSync(p)).toBe(true);
|
|
expect(fs.statSync(p).size).toBeGreaterThan(100);
|
|
fs.unlinkSync(p);
|
|
});
|
|
|
|
test('screenshot with @ref crops to element', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
await handleMetaCommand('snapshot', [], bm, async () => {});
|
|
const p = '/tmp/browse-test-element-ref.png';
|
|
const result = await handleMetaCommand('screenshot', ['@e1', p], bm, async () => {});
|
|
expect(result).toContain('Screenshot saved (element)');
|
|
expect(fs.existsSync(p)).toBe(true);
|
|
expect(fs.statSync(p).size).toBeGreaterThan(100);
|
|
fs.unlinkSync(p);
|
|
});
|
|
|
|
test('screenshot --clip crops to region', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const p = '/tmp/browse-test-clip.png';
|
|
const result = await handleMetaCommand('screenshot', ['--clip', '0,0,100,100', p], bm, async () => {});
|
|
expect(result).toContain('Screenshot saved (clip 0,0,100,100)');
|
|
expect(fs.existsSync(p)).toBe(true);
|
|
expect(fs.statSync(p).size).toBeGreaterThan(100);
|
|
fs.unlinkSync(p);
|
|
});
|
|
|
|
test('screenshot --clip + selector throws', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['--clip', '0,0,100,100', '#title'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Cannot use --clip with a selector/ref');
|
|
}
|
|
});
|
|
|
|
test('screenshot --viewport + --clip throws', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['--viewport', '--clip', '0,0,100,100'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Cannot use --viewport with --clip');
|
|
}
|
|
});
|
|
|
|
test('screenshot --clip with invalid coords throws', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['--clip', 'abc'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('all must be numbers');
|
|
}
|
|
});
|
|
|
|
test('screenshot unknown flag throws', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['--bogus', '/tmp/foo.png'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Unknown screenshot flag');
|
|
}
|
|
});
|
|
|
|
test('screenshot --viewport still validates path', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['--viewport', '/etc/evil.png'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
|
|
test('screenshot treats relative dot-slash path as file path, not CSS selector', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
// ./path/to/file.png must be treated as output path, not a CSS class selector (#495)
|
|
const relPath = './browse-test-dotpath.png';
|
|
const absPath = path.resolve(relPath);
|
|
const result = await handleMetaCommand('screenshot', [relPath], bm, async () => {});
|
|
expect(result).toContain('Screenshot saved');
|
|
expect(fs.existsSync(absPath)).toBe(true);
|
|
fs.unlinkSync(absPath);
|
|
});
|
|
|
|
test('screenshot with nonexistent selector throws timeout', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['.nonexistent-element-xyz'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toBeDefined();
|
|
}
|
|
}, 10000);
|
|
|
|
test('responsive saves 3 screenshots', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/responsive.html'], bm);
|
|
const prefix = '/tmp/browse-test-resp';
|
|
const result = await handleMetaCommand('responsive', [prefix], bm, async () => {});
|
|
expect(result).toContain('mobile');
|
|
expect(result).toContain('tablet');
|
|
expect(result).toContain('desktop');
|
|
|
|
expect(fs.existsSync(`${prefix}-mobile.png`)).toBe(true);
|
|
expect(fs.existsSync(`${prefix}-tablet.png`)).toBe(true);
|
|
expect(fs.existsSync(`${prefix}-desktop.png`)).toBe(true);
|
|
|
|
// Cleanup
|
|
fs.unlinkSync(`${prefix}-mobile.png`);
|
|
fs.unlinkSync(`${prefix}-tablet.png`);
|
|
fs.unlinkSync(`${prefix}-desktop.png`);
|
|
});
|
|
});
|
|
|
|
// ─── Tabs ───────────────────────────────────────────────────────
|
|
|
|
describe('Tabs', () => {
|
|
test('tabs lists all tabs', async () => {
|
|
const result = await handleMetaCommand('tabs', [], bm, async () => {});
|
|
expect(result).toContain('[');
|
|
expect(result).toContain(']');
|
|
});
|
|
|
|
test('newtab opens new tab', async () => {
|
|
const result = await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, async () => {});
|
|
expect(result).toContain('Opened tab');
|
|
|
|
const tabCount = bm.getTabCount();
|
|
expect(tabCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('tab switches to specific tab', async () => {
|
|
const result = await handleMetaCommand('tab', ['1'], bm, async () => {});
|
|
expect(result).toContain('Switched to tab 1');
|
|
});
|
|
|
|
test('closetab closes a tab', async () => {
|
|
const before = bm.getTabCount();
|
|
// Close the last opened tab
|
|
const tabs = await bm.getTabListWithTitles();
|
|
const lastTab = tabs[tabs.length - 1];
|
|
const result = await handleMetaCommand('closetab', [String(lastTab.id)], bm, async () => {});
|
|
expect(result).toContain('Closed tab');
|
|
expect(bm.getTabCount()).toBe(before - 1);
|
|
});
|
|
});
|
|
|
|
// ─── Diff ───────────────────────────────────────────────────────
|
|
|
|
describe('Diff', () => {
|
|
test('diff shows differences between pages', async () => {
|
|
const result = await handleMetaCommand(
|
|
'diff',
|
|
[baseUrl + '/basic.html', baseUrl + '/forms.html'],
|
|
bm,
|
|
async () => {}
|
|
);
|
|
expect(result).toContain('---');
|
|
expect(result).toContain('+++');
|
|
// basic.html has "Hello World", forms.html has "Form Test Page"
|
|
expect(result).toContain('Hello World');
|
|
expect(result).toContain('Form Test Page');
|
|
});
|
|
});
|
|
|
|
// ─── Chain ──────────────────────────────────────────────────────
|
|
|
|
describe('Chain', () => {
|
|
test('chain executes sequence of commands', async () => {
|
|
const commands = JSON.stringify([
|
|
['goto', baseUrl + '/basic.html'],
|
|
['js', 'document.title'],
|
|
['css', 'h1', 'color'],
|
|
]);
|
|
const result = await chainMeta(bm, [commands]);
|
|
expect(result).toContain('[goto]');
|
|
expect(result).toContain('Test Page - Basic');
|
|
expect(result).toContain('[css]');
|
|
});
|
|
|
|
test('chain wraps page-content sub-commands with trust markers', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await chainMeta(bm, ['text']);
|
|
expect(result).toContain('BEGIN UNTRUSTED EXTERNAL CONTENT');
|
|
expect(result).toContain('END UNTRUSTED EXTERNAL CONTENT');
|
|
});
|
|
|
|
test('chain reports real error when write command fails', async () => {
|
|
const commands = JSON.stringify([
|
|
['goto', 'http://localhost:1/unreachable'],
|
|
]);
|
|
const result = await chainMeta(bm, [commands]);
|
|
expect(result).toContain('[goto] ERROR:');
|
|
expect(result).not.toContain('Unknown meta command');
|
|
expect(result).not.toContain('Unknown read command');
|
|
});
|
|
});
|
|
|
|
// ─── Status ─────────────────────────────────────────────────────
|
|
|
|
describe('Status', () => {
|
|
test('status reports health', async () => {
|
|
const result = await handleMetaCommand('status', [], bm, async () => {});
|
|
expect(result).toContain('Status: healthy');
|
|
expect(result).toContain('Tabs:');
|
|
});
|
|
});
|
|
|
|
// ─── CLI server script resolution ───────────────────────────────
|
|
|
|
describe('CLI server script resolution', () => {
|
|
test('prefers adjacent browse/src/server.ts for compiled project installs', () => {
|
|
const root = fs.mkdtempSync('/tmp/gstack-cli-');
|
|
const execPath = path.join(root, '.claude/skills/gstack/browse/dist/browse');
|
|
const serverPath = path.join(root, '.claude/skills/gstack/browse/src/server.ts');
|
|
|
|
fs.mkdirSync(path.dirname(execPath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(serverPath), { recursive: true });
|
|
fs.writeFileSync(serverPath, '// test server\n');
|
|
|
|
const resolved = resolveServerScript(
|
|
{ HOME: path.join(root, 'empty-home') },
|
|
'$bunfs/root',
|
|
execPath
|
|
);
|
|
|
|
expect(resolved).toBe(serverPath);
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ─── CLI lifecycle ──────────────────────────────────────────────
|
|
|
|
describe('CLI lifecycle', () => {
|
|
test('dead state file triggers a clean restart', async () => {
|
|
const stateFile = `/tmp/browse-test-state-${Date.now()}.json`;
|
|
fs.writeFileSync(stateFile, JSON.stringify({
|
|
port: 1,
|
|
token: 'fake',
|
|
pid: 999999,
|
|
}));
|
|
|
|
const cliPath = path.resolve(__dirname, '../src/cli.ts');
|
|
const cliEnv: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) cliEnv[k] = v;
|
|
}
|
|
cliEnv.BROWSE_STATE_FILE = stateFile;
|
|
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
|
|
const proc = spawn('bun', ['run', cliPath, 'status'], {
|
|
// Must exceed the CLI's startup budget (resolveStartTimeout, 15s
|
|
// non-CI POSIX) or a slow cold boot under full-suite load gets the
|
|
// child killed at the exact moment the CLI would have succeeded.
|
|
timeout: 18000,
|
|
env: cliEnv,
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
proc.stdout.on('data', (d) => stdout += d.toString());
|
|
proc.stderr.on('data', (d) => stderr += d.toString());
|
|
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
});
|
|
|
|
let restartedPid: number | null = null;
|
|
if (fs.existsSync(stateFile)) {
|
|
restartedPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid;
|
|
fs.unlinkSync(stateFile);
|
|
}
|
|
if (restartedPid) {
|
|
try { process.kill(restartedPid, 'SIGTERM'); } catch {}
|
|
}
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout).toContain('Status: healthy');
|
|
expect(result.stderr).toContain('Starting server');
|
|
}, 20000);
|
|
});
|
|
|
|
// ─── Buffer bounds ──────────────────────────────────────────────
|
|
|
|
describe('Buffer bounds', () => {
|
|
test('console buffer caps at 50000 entries', () => {
|
|
consoleBuffer.clear();
|
|
for (let i = 0; i < 50_010; i++) {
|
|
addConsoleEntry({ timestamp: i, level: 'log', text: `msg-${i}` });
|
|
}
|
|
expect(consoleBuffer.length).toBe(50_000);
|
|
const entries = consoleBuffer.toArray();
|
|
expect(entries[0].text).toBe('msg-10');
|
|
expect(entries[entries.length - 1].text).toBe('msg-50009');
|
|
consoleBuffer.clear();
|
|
});
|
|
|
|
test('network buffer caps at 50000 entries', () => {
|
|
networkBuffer.clear();
|
|
for (let i = 0; i < 50_010; i++) {
|
|
addNetworkEntry({ timestamp: i, method: 'GET', url: `http://x/${i}` });
|
|
}
|
|
expect(networkBuffer.length).toBe(50_000);
|
|
const entries = networkBuffer.toArray();
|
|
expect(entries[0].url).toBe('http://x/10');
|
|
expect(entries[entries.length - 1].url).toBe('http://x/50009');
|
|
networkBuffer.clear();
|
|
});
|
|
|
|
test('totalAdded counters keep incrementing past buffer cap', () => {
|
|
const startConsole = consoleBuffer.totalAdded;
|
|
const startNetwork = networkBuffer.totalAdded;
|
|
for (let i = 0; i < 100; i++) {
|
|
addConsoleEntry({ timestamp: i, level: 'log', text: `t-${i}` });
|
|
addNetworkEntry({ timestamp: i, method: 'GET', url: `http://t/${i}` });
|
|
}
|
|
expect(consoleBuffer.totalAdded).toBe(startConsole + 100);
|
|
expect(networkBuffer.totalAdded).toBe(startNetwork + 100);
|
|
consoleBuffer.clear();
|
|
networkBuffer.clear();
|
|
});
|
|
});
|
|
|
|
// ─── CircularBuffer Unit Tests ─────────────────────────────────
|
|
|
|
describe('CircularBuffer', () => {
|
|
test('push and toArray return items in insertion order', () => {
|
|
const buf = new CircularBuffer<number>(5);
|
|
buf.push(1); buf.push(2); buf.push(3);
|
|
expect(buf.toArray()).toEqual([1, 2, 3]);
|
|
expect(buf.length).toBe(3);
|
|
});
|
|
|
|
test('overwrites oldest when full', () => {
|
|
const buf = new CircularBuffer<number>(3);
|
|
buf.push(1); buf.push(2); buf.push(3); buf.push(4);
|
|
expect(buf.toArray()).toEqual([2, 3, 4]);
|
|
expect(buf.length).toBe(3);
|
|
});
|
|
|
|
test('totalAdded increments past capacity', () => {
|
|
const buf = new CircularBuffer<number>(2);
|
|
buf.push(1); buf.push(2); buf.push(3); buf.push(4); buf.push(5);
|
|
expect(buf.totalAdded).toBe(5);
|
|
expect(buf.length).toBe(2);
|
|
expect(buf.toArray()).toEqual([4, 5]);
|
|
});
|
|
|
|
test('last(n) returns most recent entries', () => {
|
|
const buf = new CircularBuffer<number>(5);
|
|
for (let i = 1; i <= 5; i++) buf.push(i);
|
|
expect(buf.last(3)).toEqual([3, 4, 5]);
|
|
expect(buf.last(10)).toEqual([1, 2, 3, 4, 5]); // clamped
|
|
expect(buf.last(1)).toEqual([5]);
|
|
});
|
|
|
|
test('get and set work by index', () => {
|
|
const buf = new CircularBuffer<string>(3);
|
|
buf.push('a'); buf.push('b'); buf.push('c');
|
|
expect(buf.get(0)).toBe('a');
|
|
expect(buf.get(2)).toBe('c');
|
|
buf.set(1, 'B');
|
|
expect(buf.get(1)).toBe('B');
|
|
expect(buf.get(-1)).toBeUndefined();
|
|
expect(buf.get(5)).toBeUndefined();
|
|
});
|
|
|
|
test('clear resets size but not totalAdded', () => {
|
|
const buf = new CircularBuffer<number>(5);
|
|
buf.push(1); buf.push(2); buf.push(3);
|
|
buf.clear();
|
|
expect(buf.length).toBe(0);
|
|
expect(buf.totalAdded).toBe(3);
|
|
expect(buf.toArray()).toEqual([]);
|
|
});
|
|
|
|
test('works with capacity=1', () => {
|
|
const buf = new CircularBuffer<number>(1);
|
|
buf.push(10);
|
|
expect(buf.toArray()).toEqual([10]);
|
|
buf.push(20);
|
|
expect(buf.toArray()).toEqual([20]);
|
|
expect(buf.totalAdded).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ─── Dialog Handling ─────────────────────────────────────────
|
|
|
|
describe('Dialog handling', () => {
|
|
test('alert does not hang — auto-accepted', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm);
|
|
await handleWriteCommand('click', ['#alert-btn'], bm);
|
|
// If we get here, dialog was handled (no hang)
|
|
const result = await handleReadCommand('dialog', [], bm);
|
|
expect(result).toContain('alert');
|
|
expect(result).toContain('Hello from alert');
|
|
expect(result).toContain('accepted');
|
|
});
|
|
|
|
test('confirm is auto-accepted by default', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm);
|
|
await handleWriteCommand('click', ['#confirm-btn'], bm);
|
|
// Wait for DOM update
|
|
await new Promise(r => setTimeout(r, 100));
|
|
const result = await handleReadCommand('js', ['document.querySelector("#confirm-result").textContent'], bm);
|
|
expect(result).toBe('confirmed');
|
|
});
|
|
|
|
test('dialog-dismiss changes behavior', async () => {
|
|
const setResult = await handleWriteCommand('dialog-dismiss', [], bm);
|
|
expect(setResult).toContain('dismissed');
|
|
|
|
await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm);
|
|
await handleWriteCommand('click', ['#confirm-btn'], bm);
|
|
await new Promise(r => setTimeout(r, 100));
|
|
const result = await handleReadCommand('js', ['document.querySelector("#confirm-result").textContent'], bm);
|
|
expect(result).toBe('cancelled');
|
|
|
|
// Reset to accept
|
|
await handleWriteCommand('dialog-accept', [], bm);
|
|
});
|
|
|
|
test('dialog-accept with text provides prompt response', async () => {
|
|
const setResult = await handleWriteCommand('dialog-accept', ['TestUser'], bm);
|
|
expect(setResult).toContain('TestUser');
|
|
|
|
await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm);
|
|
await handleWriteCommand('click', ['#prompt-btn'], bm);
|
|
await new Promise(r => setTimeout(r, 100));
|
|
const result = await handleReadCommand('js', ['document.querySelector("#prompt-result").textContent'], bm);
|
|
expect(result).toBe('TestUser');
|
|
|
|
// Reset
|
|
await handleWriteCommand('dialog-accept', [], bm);
|
|
});
|
|
|
|
test('dialog --clear clears buffer', async () => {
|
|
const cleared = await handleReadCommand('dialog', ['--clear'], bm);
|
|
expect(cleared).toContain('cleared');
|
|
const after = await handleReadCommand('dialog', [], bm);
|
|
expect(after).toContain('no dialogs');
|
|
});
|
|
});
|
|
|
|
// ─── Element State Checks (is) ─────────────────────────────────
|
|
|
|
describe('Element state checks', () => {
|
|
beforeAll(async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/states.html'], bm);
|
|
});
|
|
|
|
test('is visible returns true for visible element', async () => {
|
|
const result = await handleReadCommand('is', ['visible', '#visible-div'], bm);
|
|
expect(result).toBe('true');
|
|
});
|
|
|
|
test('is hidden returns true for hidden element', async () => {
|
|
const result = await handleReadCommand('is', ['hidden', '#hidden-div'], bm);
|
|
expect(result).toBe('true');
|
|
});
|
|
|
|
test('is visible returns false for hidden element', async () => {
|
|
const result = await handleReadCommand('is', ['visible', '#hidden-div'], bm);
|
|
expect(result).toBe('false');
|
|
});
|
|
|
|
test('is enabled returns true for enabled input', async () => {
|
|
const result = await handleReadCommand('is', ['enabled', '#enabled-input'], bm);
|
|
expect(result).toBe('true');
|
|
});
|
|
|
|
test('is disabled returns true for disabled input', async () => {
|
|
const result = await handleReadCommand('is', ['disabled', '#disabled-input'], bm);
|
|
expect(result).toBe('true');
|
|
});
|
|
|
|
test('is checked returns true for checked checkbox', async () => {
|
|
const result = await handleReadCommand('is', ['checked', '#checked-box'], bm);
|
|
expect(result).toBe('true');
|
|
});
|
|
|
|
test('is checked returns false for unchecked checkbox', async () => {
|
|
const result = await handleReadCommand('is', ['checked', '#unchecked-box'], bm);
|
|
expect(result).toBe('false');
|
|
});
|
|
|
|
test('is editable returns true for normal input', async () => {
|
|
const result = await handleReadCommand('is', ['editable', '#enabled-input'], bm);
|
|
expect(result).toBe('true');
|
|
});
|
|
|
|
test('is editable returns false for readonly input', async () => {
|
|
const result = await handleReadCommand('is', ['editable', '#readonly-input'], bm);
|
|
expect(result).toBe('false');
|
|
});
|
|
|
|
test('is focused after click', async () => {
|
|
await handleWriteCommand('click', ['#enabled-input'], bm);
|
|
const result = await handleReadCommand('is', ['focused', '#enabled-input'], bm);
|
|
expect(result).toBe('true');
|
|
});
|
|
|
|
test('is with @ref works', async () => {
|
|
await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
|
// Find a ref for the enabled input
|
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
|
const textboxLine = snap.split('\n').find(l => l.includes('[textbox]'));
|
|
if (textboxLine) {
|
|
const refMatch = textboxLine.match(/@(e\d+)/);
|
|
if (refMatch) {
|
|
const ref = `@${refMatch[1]}`;
|
|
const result = await handleReadCommand('is', ['visible', ref], bm);
|
|
expect(result).toBe('true');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('is with unknown property throws', async () => {
|
|
try {
|
|
await handleReadCommand('is', ['bogus', '#enabled-input'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Unknown property');
|
|
}
|
|
});
|
|
|
|
test('is with missing args throws', async () => {
|
|
try {
|
|
await handleReadCommand('is', ['visible'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── File Upload ─────────────────────────────────────────────────
|
|
|
|
describe('File upload', () => {
|
|
test('upload single file', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm);
|
|
// Create a temp file to upload
|
|
const tempFile = '/tmp/browse-test-upload.txt';
|
|
fs.writeFileSync(tempFile, 'test content');
|
|
const result = await handleWriteCommand('upload', ['#file-input', tempFile], bm);
|
|
expect(result).toContain('Uploaded');
|
|
expect(result).toContain('browse-test-upload.txt');
|
|
|
|
// Verify upload handler fired
|
|
await new Promise(r => setTimeout(r, 100));
|
|
const text = await handleReadCommand('js', ['document.querySelector("#upload-result").textContent'], bm);
|
|
expect(text).toContain('browse-test-upload.txt');
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('upload with @ref works', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm);
|
|
const tempFile = '/tmp/browse-test-upload2.txt';
|
|
fs.writeFileSync(tempFile, 'ref upload test');
|
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
|
// Find the file input ref (it won't appear as "file input" in aria — use CSS selector instead)
|
|
const result = await handleWriteCommand('upload', ['#file-input', tempFile], bm);
|
|
expect(result).toContain('Uploaded');
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('upload nonexistent file throws', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm);
|
|
try {
|
|
await handleWriteCommand('upload', ['#file-input', '/tmp/nonexistent-file-12345.txt'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('File not found');
|
|
}
|
|
});
|
|
|
|
test('upload missing args throws', async () => {
|
|
try {
|
|
await handleWriteCommand('upload', ['#file-input'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Eval command ───────────────────────────────────────────────
|
|
|
|
describe('Eval', () => {
|
|
test('eval runs JS file', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const tempFile = '/tmp/browse-test-eval.js';
|
|
fs.writeFileSync(tempFile, 'document.title + " — evaluated"');
|
|
const result = await handleReadCommand('eval', [tempFile], bm);
|
|
expect(result).toBe('Test Page - Basic — evaluated');
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('eval returns object as JSON', async () => {
|
|
const tempFile = '/tmp/browse-test-eval-obj.js';
|
|
fs.writeFileSync(tempFile, '({title: document.title, keys: Object.keys(document.body.dataset)})');
|
|
const result = await handleReadCommand('eval', [tempFile], bm);
|
|
const obj = JSON.parse(result);
|
|
expect(obj.title).toBe('Test Page - Basic');
|
|
expect(Array.isArray(obj.keys)).toBe(true);
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('eval file not found throws', async () => {
|
|
try {
|
|
await handleReadCommand('eval', ['/tmp/nonexistent-eval.js'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('File not found');
|
|
}
|
|
});
|
|
|
|
test('eval no arg throws', async () => {
|
|
try {
|
|
await handleReadCommand('eval', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Press command ──────────────────────────────────────────────
|
|
|
|
describe('Press', () => {
|
|
test('press Tab moves focus', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
await handleWriteCommand('click', ['#email'], bm);
|
|
const result = await handleWriteCommand('press', ['Tab'], bm);
|
|
expect(result).toContain('Pressed Tab');
|
|
});
|
|
|
|
test('press no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('press', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Cookie command ─────────────────────────────────────────────
|
|
|
|
describe('Cookie command', () => {
|
|
test('cookie sets value', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('cookie', ['testcookie=testvalue'], bm);
|
|
expect(result).toContain('Cookie set');
|
|
|
|
const cookies = await handleReadCommand('cookies', [], bm);
|
|
expect(cookies).toContain('testcookie');
|
|
expect(cookies).toContain('testvalue');
|
|
});
|
|
|
|
test('cookie no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('cookie', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('cookie no = throws', async () => {
|
|
try {
|
|
await handleWriteCommand('cookie', ['invalid'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Header command ─────────────────────────────────────────────
|
|
|
|
describe('Header command', () => {
|
|
test('header sets value and is sent', async () => {
|
|
const result = await handleWriteCommand('header', ['X-Test:test-value'], bm);
|
|
expect(result).toContain('Header set');
|
|
|
|
await handleWriteCommand('goto', [baseUrl + '/echo'], bm);
|
|
const echoText = await handleReadCommand('text', [], bm);
|
|
expect(echoText).toContain('x-test');
|
|
expect(echoText).toContain('test-value');
|
|
});
|
|
|
|
test('header no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('header', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('header no colon throws', async () => {
|
|
try {
|
|
await handleWriteCommand('header', ['invalid'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── PDF command ────────────────────────────────────────────────
|
|
|
|
describe('PDF', () => {
|
|
test('pdf saves file with size', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const pdfPath = '/tmp/browse-test.pdf';
|
|
const result = await handleMetaCommand('pdf', [pdfPath], bm, async () => {});
|
|
expect(result).toContain('PDF saved');
|
|
expect(fs.existsSync(pdfPath)).toBe(true);
|
|
const stat = fs.statSync(pdfPath);
|
|
expect(stat.size).toBeGreaterThan(100);
|
|
fs.unlinkSync(pdfPath);
|
|
});
|
|
});
|
|
|
|
// ─── Empty page edge cases ──────────────────────────────────────
|
|
|
|
describe('Empty page', () => {
|
|
test('text returns empty on empty page', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/empty.html'], bm);
|
|
const result = await handleReadCommand('text', [], bm);
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
test('links returns empty on empty page', async () => {
|
|
const result = await handleReadCommand('links', [], bm);
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
test('forms returns empty array on empty page', async () => {
|
|
const result = await handleReadCommand('forms', [], bm);
|
|
expect(JSON.parse(result)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── Error paths ────────────────────────────────────────────────
|
|
|
|
describe('Errors', () => {
|
|
// Write command errors
|
|
test('goto with no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('goto', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('click with no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('click', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('fill with no value throws', async () => {
|
|
try {
|
|
await handleWriteCommand('fill', ['#input'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('select with no value throws', async () => {
|
|
try {
|
|
await handleWriteCommand('select', ['#sel'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('hover with no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('hover', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('type with no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('type', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('wait with no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('wait', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('viewport with bad format throws', async () => {
|
|
try {
|
|
await handleWriteCommand('viewport', ['badformat'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('useragent with no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('useragent', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
// Read command errors
|
|
test('js with no expression throws', async () => {
|
|
try {
|
|
await handleReadCommand('js', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('css with missing property throws', async () => {
|
|
try {
|
|
await handleReadCommand('css', ['h1'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('attrs with no selector throws', async () => {
|
|
try {
|
|
await handleReadCommand('attrs', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
// Meta command errors
|
|
test('tab with non-numeric id throws', async () => {
|
|
try {
|
|
await handleMetaCommand('tab', ['abc'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('diff with missing urls throws', async () => {
|
|
try {
|
|
await handleMetaCommand('diff', [baseUrl + '/basic.html'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('chain with invalid JSON falls back to pipe format', async () => {
|
|
// Non-JSON input is now treated as pipe-delimited format
|
|
// 'not json' → [["not", "json"]] → "not" is unknown command → error in result
|
|
const result = await chainMeta(bm, ['not json']);
|
|
expect(result).toContain('ERROR');
|
|
expect(result).toContain('Unknown command: not');
|
|
});
|
|
|
|
test('chain with no arg throws', async () => {
|
|
try {
|
|
await chainMeta(bm, []);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('unknown read command throws', async () => {
|
|
try {
|
|
await handleReadCommand('bogus' as any, [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Unknown');
|
|
}
|
|
});
|
|
|
|
test('unknown write command throws', async () => {
|
|
try {
|
|
await handleWriteCommand('bogus' as any, [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Unknown');
|
|
}
|
|
});
|
|
|
|
test('unknown meta command throws', async () => {
|
|
try {
|
|
await handleMetaCommand('bogus' as any, [], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Unknown');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Workflow: Navigation + Snapshot + Interaction ───────────────
|
|
|
|
describe('Workflows', () => {
|
|
test('navigation → snapshot → click @ref → verify URL', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
|
// Find a link ref
|
|
const linkLine = snap.split('\n').find(l => l.includes('[link]'));
|
|
expect(linkLine).toBeDefined();
|
|
const refMatch = linkLine!.match(/@(e\d+)/);
|
|
expect(refMatch).toBeDefined();
|
|
// Click the link
|
|
await handleWriteCommand('click', [`@${refMatch![1]}`], bm);
|
|
// URL should have changed
|
|
const url = await handleMetaCommand('url', [], bm, async () => {});
|
|
expect(url).toBeTruthy();
|
|
});
|
|
|
|
test('form: goto → snapshot → fill @ref → click @ref', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
|
// Find textbox and button
|
|
const textboxLine = snap.split('\n').find(l => l.includes('[textbox]'));
|
|
const buttonLine = snap.split('\n').find(l => l.includes('[button]') && l.includes('"Submit"'));
|
|
if (textboxLine && buttonLine) {
|
|
const textRef = textboxLine.match(/@(e\d+)/)![1];
|
|
const btnRef = buttonLine.match(/@(e\d+)/)![1];
|
|
await handleWriteCommand('fill', [`@${textRef}`, 'testuser'], bm);
|
|
await handleWriteCommand('click', [`@${btnRef}`], bm);
|
|
}
|
|
});
|
|
|
|
test('tabs: newtab → goto → switch → verify isolation', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const tabsBefore = bm.getTabCount();
|
|
await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, async () => {});
|
|
expect(bm.getTabCount()).toBe(tabsBefore + 1);
|
|
|
|
const url = await handleMetaCommand('url', [], bm, async () => {});
|
|
expect(url).toContain('/forms.html');
|
|
|
|
// Switch back to previous tab
|
|
const tabs = await bm.getTabListWithTitles();
|
|
const prevTab = tabs.find(t => t.url.includes('/basic.html'));
|
|
if (prevTab) {
|
|
bm.switchTab(prevTab.id);
|
|
const url2 = await handleMetaCommand('url', [], bm, async () => {});
|
|
expect(url2).toContain('/basic.html');
|
|
}
|
|
|
|
// Clean up extra tab
|
|
const allTabs = await bm.getTabListWithTitles();
|
|
const formTab = allTabs.find(t => t.url.includes('/forms.html'));
|
|
if (formTab) await bm.closeTab(formTab.id);
|
|
});
|
|
|
|
test('cookies: set → read → reload → verify persistence', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
await handleWriteCommand('cookie', ['workflow-test=persisted'], bm);
|
|
await handleWriteCommand('reload', [], bm);
|
|
const cookies = await handleReadCommand('cookies', [], bm);
|
|
expect(cookies).toContain('workflow-test');
|
|
expect(cookies).toContain('persisted');
|
|
});
|
|
});
|
|
|
|
// ─── Wait load states ──────────────────────────────────────────
|
|
|
|
describe('Wait load states', () => {
|
|
test('wait --networkidle succeeds after page load', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('wait', ['--networkidle'], bm);
|
|
expect(result).toBe('Network idle');
|
|
});
|
|
|
|
test('wait --load succeeds', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('wait', ['--load'], bm);
|
|
expect(result).toBe('Page loaded');
|
|
});
|
|
|
|
test('wait --domcontentloaded succeeds', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('wait', ['--domcontentloaded'], bm);
|
|
expect(result).toBe('DOM content loaded');
|
|
});
|
|
|
|
test('wait --networkidle with custom timeout', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('wait', ['--networkidle', '5000'], bm);
|
|
expect(result).toBe('Network idle');
|
|
});
|
|
|
|
test('wait with selector still works', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('wait', ['#title'], bm);
|
|
expect(result).toContain('appeared');
|
|
});
|
|
});
|
|
|
|
// ─── Console --errors ──────────────────────────────────────────
|
|
|
|
describe('Console --errors', () => {
|
|
test('console --errors filters to error and warning only', async () => {
|
|
// Clear existing entries
|
|
await handleReadCommand('console', ['--clear'], bm);
|
|
|
|
// Add mixed entries
|
|
addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'info message' });
|
|
addConsoleEntry({ timestamp: Date.now(), level: 'warning', text: 'warn message' });
|
|
addConsoleEntry({ timestamp: Date.now(), level: 'error', text: 'error message' });
|
|
|
|
const result = await handleReadCommand('console', ['--errors'], bm);
|
|
expect(result).toContain('warn message');
|
|
expect(result).toContain('error message');
|
|
expect(result).not.toContain('info message');
|
|
|
|
// Cleanup
|
|
consoleBuffer.clear();
|
|
});
|
|
|
|
test('console --errors returns empty message when no errors', async () => {
|
|
consoleBuffer.clear();
|
|
addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'just a log' });
|
|
|
|
const result = await handleReadCommand('console', ['--errors'], bm);
|
|
expect(result).toBe('(no console errors)');
|
|
|
|
consoleBuffer.clear();
|
|
});
|
|
|
|
test('console --errors on empty buffer', async () => {
|
|
consoleBuffer.clear();
|
|
const result = await handleReadCommand('console', ['--errors'], bm);
|
|
expect(result).toBe('(no console errors)');
|
|
});
|
|
|
|
test('console without flag still returns all messages', async () => {
|
|
consoleBuffer.clear();
|
|
addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'all messages test' });
|
|
|
|
const result = await handleReadCommand('console', [], bm);
|
|
expect(result).toContain('all messages test');
|
|
|
|
consoleBuffer.clear();
|
|
});
|
|
});
|
|
|
|
// ─── Cookie Import ─────────────────────────────────────────────
|
|
|
|
describe('Cookie import', () => {
|
|
test('cookie-import loads valid JSON cookies', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const tempFile = '/tmp/browse-test-cookies.json';
|
|
const cookies = [
|
|
{ name: 'test-cookie', value: 'test-value' },
|
|
{ name: 'another', value: '123' },
|
|
];
|
|
fs.writeFileSync(tempFile, JSON.stringify(cookies));
|
|
|
|
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
|
expect(result).toBe('Loaded 2 cookies from /tmp/browse-test-cookies.json');
|
|
|
|
// Verify cookies were set
|
|
const cookieList = await handleReadCommand('cookies', [], bm);
|
|
expect(cookieList).toContain('test-cookie');
|
|
expect(cookieList).toContain('test-value');
|
|
expect(cookieList).toContain('another');
|
|
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('cookie-import auto-fills domain from page URL', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const tempFile = '/tmp/browse-test-cookies-nodomain.json';
|
|
// Cookies without domain — should auto-fill from page URL
|
|
const cookies = [{ name: 'autofill-test', value: 'works' }];
|
|
fs.writeFileSync(tempFile, JSON.stringify(cookies));
|
|
|
|
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
|
expect(result).toContain('Loaded 1');
|
|
|
|
const cookieList = await handleReadCommand('cookies', [], bm);
|
|
expect(cookieList).toContain('autofill-test');
|
|
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('cookie-import preserves explicit domain', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const tempFile = '/tmp/browse-test-cookies-domain.json';
|
|
// Domain must match page hostname (127.0.0.1) — cross-domain cookies are now rejected
|
|
const cookies = [{ name: 'explicit', value: 'domain', domain: '127.0.0.1', path: '/foo' }];
|
|
fs.writeFileSync(tempFile, JSON.stringify(cookies));
|
|
|
|
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
|
expect(result).toContain('Loaded 1');
|
|
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('cookie-import with empty array succeeds', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const tempFile = '/tmp/browse-test-cookies-empty.json';
|
|
fs.writeFileSync(tempFile, '[]');
|
|
|
|
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
|
expect(result).toBe('Loaded 0 cookies from /tmp/browse-test-cookies-empty.json');
|
|
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('cookie-import throws on file not found', async () => {
|
|
try {
|
|
await handleWriteCommand('cookie-import', ['/tmp/nonexistent-cookies.json'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('File not found');
|
|
}
|
|
});
|
|
|
|
test('cookie-import throws on invalid JSON', async () => {
|
|
const tempFile = '/tmp/browse-test-cookies-bad.json';
|
|
fs.writeFileSync(tempFile, 'not json {{{');
|
|
|
|
try {
|
|
await handleWriteCommand('cookie-import', [tempFile], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Invalid JSON');
|
|
}
|
|
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('cookie-import throws on non-array JSON', async () => {
|
|
const tempFile = '/tmp/browse-test-cookies-obj.json';
|
|
fs.writeFileSync(tempFile, '{"name": "not-an-array"}');
|
|
|
|
try {
|
|
await handleWriteCommand('cookie-import', [tempFile], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('JSON array');
|
|
}
|
|
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('cookie-import throws on cookie missing name', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const tempFile = '/tmp/browse-test-cookies-noname.json';
|
|
fs.writeFileSync(tempFile, JSON.stringify([{ value: 'no-name' }]));
|
|
|
|
try {
|
|
await handleWriteCommand('cookie-import', [tempFile], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('name');
|
|
}
|
|
|
|
fs.unlinkSync(tempFile);
|
|
});
|
|
|
|
test('cookie-import no arg throws', async () => {
|
|
try {
|
|
await handleWriteCommand('cookie-import', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Security: Redact sensitive values (PR #21) ─────────────────
|
|
|
|
describe('Sensitive value redaction', () => {
|
|
test('type command does not echo typed text', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('type', ['my-secret-password'], bm);
|
|
expect(result).not.toContain('my-secret-password');
|
|
expect(result).toContain('18 characters');
|
|
});
|
|
|
|
test('cookie command redacts value', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleWriteCommand('cookie', ['session=secret123'], bm);
|
|
expect(result).toContain('session');
|
|
expect(result).toContain('****');
|
|
expect(result).not.toContain('secret123');
|
|
});
|
|
|
|
test('header command redacts Authorization value', async () => {
|
|
const result = await handleWriteCommand('header', ['Authorization:Bearer token-xyz'], bm);
|
|
expect(result).toContain('Authorization');
|
|
expect(result).toContain('****');
|
|
expect(result).not.toContain('token-xyz');
|
|
});
|
|
|
|
test('header command shows non-sensitive values', async () => {
|
|
const result = await handleWriteCommand('header', ['Content-Type:application/json'], bm);
|
|
expect(result).toContain('Content-Type');
|
|
expect(result).toContain('application/json');
|
|
expect(result).not.toContain('****');
|
|
});
|
|
|
|
test('header command redacts X-API-Key', async () => {
|
|
const result = await handleWriteCommand('header', ['X-API-Key:sk-12345'], bm);
|
|
expect(result).toContain('X-API-Key');
|
|
expect(result).toContain('****');
|
|
expect(result).not.toContain('sk-12345');
|
|
});
|
|
|
|
test('storage set does not echo value', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleReadCommand('storage', ['set', 'apiKey', 'secret-api-key-value'], bm);
|
|
expect(result).toContain('apiKey');
|
|
expect(result).not.toContain('secret-api-key-value');
|
|
});
|
|
|
|
test('forms redacts password field values', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
const formsResult = await handleReadCommand('forms', [], bm);
|
|
const forms = JSON.parse(formsResult);
|
|
// Find password fields and verify they're redacted
|
|
for (const form of forms) {
|
|
for (const field of form.fields) {
|
|
if (field.type === 'password') {
|
|
expect(field.value === undefined || field.value === '[redacted]').toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Security: Path traversal prevention (PR #26) ───────────────
|
|
|
|
describe('Path traversal prevention', () => {
|
|
test('screenshot rejects path outside safe dirs', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['/etc/evil.png'], bm, () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
|
|
test('screenshot allows /tmp path', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleMetaCommand('screenshot', ['/tmp/test-safe.png'], bm, () => {});
|
|
expect(result).toContain('Screenshot saved');
|
|
try { fs.unlinkSync('/tmp/test-safe.png'); } catch {}
|
|
});
|
|
|
|
test('pdf rejects path outside safe dirs', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('pdf', ['/home/evil.pdf'], bm, () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
|
|
test('responsive rejects path outside safe dirs', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('responsive', ['/var/evil'], bm, () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
|
|
test('eval rejects path traversal with ..', async () => {
|
|
try {
|
|
await handleReadCommand('eval', ['../../etc/passwd'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
|
|
test('eval rejects absolute path outside safe dirs', async () => {
|
|
try {
|
|
await handleReadCommand('eval', ['/etc/passwd'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
|
|
test('eval allows /tmp path', async () => {
|
|
const tmpFile = '/tmp/test-eval-safe.js';
|
|
fs.writeFileSync(tmpFile, 'document.title');
|
|
try {
|
|
const result = await handleReadCommand('eval', [tmpFile], bm);
|
|
expect(typeof result).toBe('string');
|
|
} finally {
|
|
try { fs.unlinkSync(tmpFile); } catch {}
|
|
}
|
|
});
|
|
|
|
test('screenshot rejects /tmpevil prefix collision', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['/tmpevil/steal.png'], bm, () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
|
|
test('cookie-import rejects path traversal', async () => {
|
|
try {
|
|
await handleWriteCommand('cookie-import', ['../../etc/shadow'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
// Traversal blocked by safe-directory check (#707) or explicit .. check
|
|
expect(err.message).toMatch(/Path must be within|Path traversal/);
|
|
}
|
|
});
|
|
|
|
test('cookie-import rejects absolute path outside safe dirs', async () => {
|
|
try {
|
|
await handleWriteCommand('cookie-import', ['/etc/passwd'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
|
|
test('snapshot -a -o rejects path outside safe dirs', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
// First get a snapshot so refs exist
|
|
await handleMetaCommand('snapshot', ['-i'], bm, () => {});
|
|
try {
|
|
await handleMetaCommand('snapshot', ['-a', '-o', '/etc/evil.png'], bm, () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Path must be within');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Chain command: cookie-import in chain ──────────────────────
|
|
|
|
describe('Chain with cookie-import', () => {
|
|
test('cookie-import works inside chain', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const tmpCookies = '/tmp/test-chain-cookies.json';
|
|
fs.writeFileSync(tmpCookies, JSON.stringify([
|
|
{ name: 'chain_test', value: 'chain_value', domain: '127.0.0.1', path: '/' }
|
|
]));
|
|
try {
|
|
const commands = JSON.stringify([
|
|
['cookie-import', tmpCookies],
|
|
]);
|
|
const result = await chainMeta(bm, [commands]);
|
|
expect(result).toContain('[cookie-import]');
|
|
expect(result).toContain('Loaded 1 cookie');
|
|
} finally {
|
|
try { fs.unlinkSync(tmpCookies); } catch {}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Network Idle Detection ─────────────────────────────────────
|
|
|
|
describe('Network idle', () => {
|
|
test('click on fetch button waits for XHR to complete', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/network-idle.html'], bm);
|
|
// Click the button that triggers a fetch → networkidle waits for it
|
|
await handleWriteCommand('click', ['#fetch-btn'], bm);
|
|
// The DOM should be updated by the time click returns
|
|
const result = await handleReadCommand('js', ['document.getElementById("result").textContent'], bm);
|
|
expect(result).toContain('Data loaded');
|
|
});
|
|
|
|
test('click on static button has no latency penalty', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/network-idle.html'], bm);
|
|
const start = Date.now();
|
|
await handleWriteCommand('click', ['#static-btn'], bm);
|
|
const elapsed = Date.now() - start;
|
|
// Static click should complete well under 2s (the networkidle timeout)
|
|
// networkidle resolves immediately when no requests are in flight
|
|
expect(elapsed).toBeLessThan(1500);
|
|
const result = await handleReadCommand('js', ['document.getElementById("static-result").textContent'], bm);
|
|
expect(result).toBe('Static action done');
|
|
});
|
|
|
|
test('fill triggers networkidle wait', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
// fill should complete without error (networkidle resolves immediately on static page)
|
|
const result = await handleWriteCommand('fill', ['#email', 'idle@test.com'], bm);
|
|
expect(result).toContain('Filled');
|
|
});
|
|
});
|
|
|
|
// ─── Chain Pipe Format ──────────────────────────────────────────
|
|
|
|
describe('Chain pipe format', () => {
|
|
test('pipe-delimited commands work', async () => {
|
|
const result = await chainMeta(bm, [`goto ${baseUrl}/basic.html | js document.title`]);
|
|
expect(result).toContain('[goto]');
|
|
expect(result).toContain('[js]');
|
|
expect(result).toContain('Test Page - Basic');
|
|
});
|
|
|
|
test('pipe format with quoted args', async () => {
|
|
const result = await chainMeta(bm, [`goto ${baseUrl}/forms.html | fill #email "pipe@test.com"`]);
|
|
expect(result).toContain('[fill]');
|
|
expect(result).toContain('Filled');
|
|
// Verify the fill actually worked
|
|
const val = await handleReadCommand('js', ['document.querySelector("#email").value'], bm);
|
|
expect(val).toBe('pipe@test.com');
|
|
});
|
|
|
|
test('JSON format still works', async () => {
|
|
const commands = JSON.stringify([
|
|
['goto', baseUrl + '/basic.html'],
|
|
['js', 'document.title'],
|
|
]);
|
|
const result = await chainMeta(bm, [commands]);
|
|
expect(result).toContain('[goto]');
|
|
expect(result).toContain('Test Page - Basic');
|
|
});
|
|
|
|
test('pipe format with unknown command includes error', async () => {
|
|
const result = await chainMeta(bm, ['bogus command']);
|
|
expect(result).toContain('ERROR');
|
|
expect(result).toContain('Unknown command: bogus');
|
|
});
|
|
});
|
|
|
|
// ─── State Persistence ──────────────────────────────────────────
|
|
|
|
describe('State persistence', () => {
|
|
test('state save and load round-trip', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
// Set a cookie so we can verify it persists
|
|
await handleWriteCommand('cookie', ['state_test=hello'], bm);
|
|
|
|
// Save state
|
|
const saveResult = await handleMetaCommand('state', ['save', 'test-roundtrip'], bm, async () => {});
|
|
expect(saveResult).toContain('State saved');
|
|
expect(saveResult).toContain('Cookies stored in plaintext');
|
|
|
|
// Navigate away
|
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
|
|
|
// Load state — should restore to basic.html with cookie
|
|
const loadResult = await handleMetaCommand('state', ['load', 'test-roundtrip'], bm, async () => {});
|
|
expect(loadResult).toContain('State loaded');
|
|
|
|
// Verify we're back on basic.html
|
|
const url = await handleReadCommand('js', ['location.pathname'], bm);
|
|
expect(url).toContain('basic.html');
|
|
|
|
// Clean up
|
|
try {
|
|
const { resolveConfig } = await import('../src/config');
|
|
const config = resolveConfig();
|
|
fs.unlinkSync(`${config.stateDir}/browse-states/test-roundtrip.json`);
|
|
} catch {}
|
|
});
|
|
|
|
test('state save rejects invalid names', async () => {
|
|
try {
|
|
await handleMetaCommand('state', ['save', '../../evil'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('alphanumeric');
|
|
}
|
|
});
|
|
|
|
test('state save accepts valid names', async () => {
|
|
const result = await handleMetaCommand('state', ['save', 'my-state_1'], bm, async () => {});
|
|
expect(result).toContain('State saved');
|
|
// Clean up
|
|
try {
|
|
const { resolveConfig } = await import('../src/config');
|
|
const config = resolveConfig();
|
|
fs.unlinkSync(`${config.stateDir}/browse-states/my-state_1.json`);
|
|
} catch {}
|
|
});
|
|
|
|
test('state load rejects missing state', async () => {
|
|
try {
|
|
await handleMetaCommand('state', ['load', 'nonexistent-state-xyz'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('State not found');
|
|
}
|
|
});
|
|
|
|
test('state requires action and name', async () => {
|
|
try {
|
|
await handleMetaCommand('state', [], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Frame (Iframe Support) ─────────────────────────────────────
|
|
|
|
describe('Frame', () => {
|
|
test('frame switch to iframe and back', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
|
|
|
|
// Verify we're on the main page
|
|
const mainTitle = await handleReadCommand('js', ['document.getElementById("main-title").textContent'], bm);
|
|
expect(mainTitle).toBe('Main Page');
|
|
|
|
// Switch to iframe by CSS selector
|
|
const switchResult = await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
|
|
expect(switchResult).toContain('Switched to frame');
|
|
|
|
// Verify we can read iframe content
|
|
const frameTitle = await handleReadCommand('js', ['document.getElementById("frame-title").textContent'], bm);
|
|
expect(frameTitle).toBe('Inside Frame');
|
|
|
|
// Switch back to main
|
|
const mainResult = await handleMetaCommand('frame', ['main'], bm, async () => {});
|
|
expect(mainResult).toBe('Switched to main frame');
|
|
|
|
// Verify we're back on the main page
|
|
const mainTitleAgain = await handleReadCommand('js', ['document.getElementById("main-title").textContent'], bm);
|
|
expect(mainTitleAgain).toBe('Main Page');
|
|
});
|
|
|
|
test('snapshot shows frame context header', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
|
|
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
|
|
|
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
|
expect(snap).toContain('[Context: iframe');
|
|
|
|
// Clean up — return to main
|
|
await handleMetaCommand('frame', ['main'], bm, async () => {});
|
|
});
|
|
|
|
test('goto throws error when in frame context', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
|
|
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
|
|
|
|
try {
|
|
await handleWriteCommand('goto', ['https://example.com'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Cannot use goto inside a frame');
|
|
}
|
|
|
|
await handleMetaCommand('frame', ['main'], bm, async () => {});
|
|
});
|
|
|
|
test('frame requires argument', async () => {
|
|
try {
|
|
await handleMetaCommand('frame', [], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toContain('Usage');
|
|
}
|
|
});
|
|
|
|
test('fill works inside iframe', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
|
|
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
|
|
|
|
const result = await handleWriteCommand('fill', ['#frame-input', 'hello from frame'], bm);
|
|
expect(result).toContain('Filled');
|
|
|
|
const value = await handleReadCommand('js', ['document.getElementById("frame-input").value'], bm);
|
|
expect(value).toBe('hello from frame');
|
|
|
|
await handleMetaCommand('frame', ['main'], bm, async () => {});
|
|
});
|
|
});
|
|
|
|
// ─── load-html ─────────────────────────────────────────────────
|
|
|
|
describe('load-html', () => {
|
|
const tmpDir = '/tmp';
|
|
const fixturePath = path.join(tmpDir, `browse-test-loadhtml-${Date.now()}.html`);
|
|
const fragmentPath = path.join(tmpDir, `browse-test-fragment-${Date.now()}.html`);
|
|
|
|
beforeAll(() => {
|
|
fs.writeFileSync(fixturePath, '<html><body><h1 id="loaded">loaded by load-html</h1></body></html>');
|
|
fs.writeFileSync(fragmentPath, '<div class="fragment" style="width:100px;height:50px">fragment</div>');
|
|
});
|
|
|
|
afterAll(() => {
|
|
try { fs.unlinkSync(fixturePath); } catch {}
|
|
try { fs.unlinkSync(fragmentPath); } catch {}
|
|
});
|
|
|
|
test('load-html loads HTML file into page', async () => {
|
|
const result = await handleWriteCommand('load-html', [fixturePath], bm);
|
|
expect(result).toContain('Loaded HTML:');
|
|
expect(result).toContain(fixturePath);
|
|
const text = await handleReadCommand('text', [], bm);
|
|
expect(text).toContain('loaded by load-html');
|
|
});
|
|
|
|
test('load-html accepts bare HTML fragments (no doctype)', async () => {
|
|
const result = await handleWriteCommand('load-html', [fragmentPath], bm);
|
|
expect(result).toContain('Loaded HTML:');
|
|
const html = await handleReadCommand('html', [], bm);
|
|
expect(html).toContain('fragment');
|
|
});
|
|
|
|
test('load-html rejects missing file arg', async () => {
|
|
try {
|
|
await handleWriteCommand('load-html', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/Usage: browse load-html/);
|
|
}
|
|
});
|
|
|
|
test('load-html rejects non-.html extension', async () => {
|
|
const txtPath = path.join(tmpDir, `load-html-test-${Date.now()}.txt`);
|
|
fs.writeFileSync(txtPath, '<html></html>');
|
|
try {
|
|
await handleWriteCommand('load-html', [txtPath], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/does not appear to be HTML/);
|
|
} finally {
|
|
try { fs.unlinkSync(txtPath); } catch {}
|
|
}
|
|
});
|
|
|
|
test('load-html rejects .svg files', async () => {
|
|
const svgPath = path.join(tmpDir, `load-html-test-${Date.now()}.svg`);
|
|
fs.writeFileSync(svgPath, '<svg xmlns="http://www.w3.org/2000/svg"><text>hi</text></svg>');
|
|
try {
|
|
await handleWriteCommand('load-html', [svgPath], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/does not appear to be HTML/);
|
|
} finally {
|
|
try { fs.unlinkSync(svgPath); } catch {}
|
|
}
|
|
});
|
|
|
|
test('load-html rejects file outside safe dirs', async () => {
|
|
try {
|
|
await handleWriteCommand('load-html', ['/etc/passwd.html'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/must be under|not found|security policy/);
|
|
}
|
|
});
|
|
|
|
test('load-html rejects missing file with actionable error', async () => {
|
|
try {
|
|
await handleWriteCommand('load-html', [path.join(tmpDir, 'does-not-exist.html')], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/not found|security policy/);
|
|
}
|
|
});
|
|
|
|
test('load-html rejects directory target', async () => {
|
|
try {
|
|
await handleWriteCommand('load-html', [path.join(tmpDir, 'browse-test-notafile.html') + '/'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
// Either "not found" or "is a directory" — both valid rejections
|
|
expect(err.message).toMatch(/not found|directory|not a regular file|security policy/);
|
|
}
|
|
});
|
|
|
|
test('load-html rejects binary content disguised as .html', async () => {
|
|
const binPath = path.join(tmpDir, `load-html-binary-${Date.now()}.html`);
|
|
// PNG magic bytes: 0x89 0x50 0x4E 0x47
|
|
fs.writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]));
|
|
try {
|
|
await handleWriteCommand('load-html', [binPath], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/does not look like HTML/);
|
|
} finally {
|
|
try { fs.unlinkSync(binPath); } catch {}
|
|
}
|
|
});
|
|
|
|
test('load-html strips UTF-8 BOM before magic-byte check', async () => {
|
|
const bomPath = path.join(tmpDir, `load-html-bom-${Date.now()}.html`);
|
|
const bomBytes = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
fs.writeFileSync(bomPath, Buffer.concat([bomBytes, Buffer.from('<html><body>bom ok</body></html>')]));
|
|
try {
|
|
const result = await handleWriteCommand('load-html', [bomPath], bm);
|
|
expect(result).toContain('Loaded HTML:');
|
|
} finally {
|
|
try { fs.unlinkSync(bomPath); } catch {}
|
|
}
|
|
});
|
|
|
|
test('load-html --wait-until networkidle exercises non-default branch', async () => {
|
|
const result = await handleWriteCommand('load-html', [fixturePath, '--wait-until', 'networkidle'], bm);
|
|
expect(result).toContain('Loaded HTML:');
|
|
});
|
|
|
|
test('load-html rejects invalid --wait-until value', async () => {
|
|
try {
|
|
await handleWriteCommand('load-html', [fixturePath, '--wait-until', 'bogus'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/Invalid --wait-until/);
|
|
}
|
|
});
|
|
|
|
test('load-html rejects unknown flag', async () => {
|
|
try {
|
|
await handleWriteCommand('load-html', [fixturePath, '--bogus'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/Unknown flag/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── screenshot --selector ─────────────────────────────────────
|
|
|
|
describe('screenshot --selector', () => {
|
|
test('--selector flag with output path captures element', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const p = `/tmp/browse-test-selector-${Date.now()}.png`;
|
|
const result = await handleMetaCommand('screenshot', ['--selector', '#title', p], bm, async () => {});
|
|
expect(result).toContain('Screenshot saved (element)');
|
|
expect(fs.existsSync(p)).toBe(true);
|
|
fs.unlinkSync(p);
|
|
});
|
|
|
|
test('--selector conflicts with positional selector', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['--selector', '#title', '.other'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/conflicts with positional selector/);
|
|
}
|
|
});
|
|
|
|
test('--selector conflicts with --clip', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['--selector', '#title', '--clip', '0,0,100,100'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/Cannot use --clip with a selector/);
|
|
}
|
|
});
|
|
|
|
test('--selector with --base64 returns element base64', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
const result = await handleMetaCommand('screenshot', ['--selector', '#title', '--base64'], bm, async () => {});
|
|
expect(result).toMatch(/^data:image\/png;base64,/);
|
|
});
|
|
|
|
test('--selector missing value throws', async () => {
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
try {
|
|
await handleMetaCommand('screenshot', ['--selector'], bm, async () => {});
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/Usage: screenshot --selector/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── viewport --scale ───────────────────────────────────────────
|
|
|
|
describe('viewport --scale', () => {
|
|
test('viewport WxH --scale 2 produces 2x dimension screenshot', async () => {
|
|
const tmpFix = path.join('/tmp', `scale-${Date.now()}.html`);
|
|
fs.writeFileSync(tmpFix, '<div id="box" style="width:100px;height:50px;background:#f00"></div>');
|
|
try {
|
|
await handleWriteCommand('viewport', ['200x200', '--scale', '2'], bm);
|
|
await handleWriteCommand('load-html', [tmpFix], bm);
|
|
const p = `/tmp/scale-${Date.now()}.png`;
|
|
await handleMetaCommand('screenshot', ['--selector', '#box', p], bm, async () => {});
|
|
// Parse PNG IHDR (bytes 16-23 are width/height big-endian u32)
|
|
const buf = fs.readFileSync(p);
|
|
const w = buf.readUInt32BE(16);
|
|
const h = buf.readUInt32BE(20);
|
|
// Box is 100x50 at 2x = 200x100
|
|
expect(w).toBe(200);
|
|
expect(h).toBe(100);
|
|
fs.unlinkSync(p);
|
|
// Reset scale for other tests
|
|
await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm);
|
|
} finally {
|
|
try { fs.unlinkSync(tmpFix); } catch {}
|
|
}
|
|
});
|
|
|
|
test('viewport --scale without WxH keeps current size', async () => {
|
|
await handleWriteCommand('viewport', ['800x600'], bm);
|
|
const result = await handleWriteCommand('viewport', ['--scale', '2'], bm);
|
|
expect(result).toContain('800x600');
|
|
expect(result).toContain('2x');
|
|
expect(bm.getDeviceScaleFactor()).toBe(2);
|
|
await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm);
|
|
});
|
|
|
|
test('--scale non-finite (NaN) throws', async () => {
|
|
try {
|
|
await handleWriteCommand('viewport', ['100x100', '--scale', 'abc'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/not a finite number/);
|
|
}
|
|
});
|
|
|
|
test('--scale out of range throws', async () => {
|
|
try {
|
|
await handleWriteCommand('viewport', ['100x100', '--scale', '4'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/between 1 and 3/);
|
|
}
|
|
try {
|
|
await handleWriteCommand('viewport', ['100x100', '--scale', '0.5'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/between 1 and 3/);
|
|
}
|
|
});
|
|
|
|
test('--scale missing value throws', async () => {
|
|
try {
|
|
await handleWriteCommand('viewport', ['--scale'], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/missing value/);
|
|
}
|
|
});
|
|
|
|
test('viewport with neither arg nor flag throws usage', async () => {
|
|
try {
|
|
await handleWriteCommand('viewport', [], bm);
|
|
expect(true).toBe(false);
|
|
} catch (err: any) {
|
|
expect(err.message).toMatch(/Usage: browse viewport/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── setContent replay across context recreation ────────────────
|
|
|
|
describe('setContent replay (load-html survives viewport --scale)', () => {
|
|
const tmpDir = '/tmp';
|
|
|
|
test('load-html → viewport --scale 2 → content survives', async () => {
|
|
const fix = path.join(tmpDir, `replay-${Date.now()}.html`);
|
|
fs.writeFileSync(fix, '<h1 id="marker">replay-test-marker</h1>');
|
|
try {
|
|
await handleWriteCommand('load-html', [fix], bm);
|
|
await handleWriteCommand('viewport', ['400x300', '--scale', '2'], bm);
|
|
const text = await handleReadCommand('text', [], bm);
|
|
expect(text).toContain('replay-test-marker');
|
|
await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm);
|
|
} finally {
|
|
try { fs.unlinkSync(fix); } catch {}
|
|
}
|
|
});
|
|
|
|
test('double scale cycle: 2x → 1.5x, content still survives', async () => {
|
|
const fix = path.join(tmpDir, `replay2-${Date.now()}.html`);
|
|
fs.writeFileSync(fix, '<h2 id="m">double-cycle-marker</h2>');
|
|
try {
|
|
await handleWriteCommand('load-html', [fix], bm);
|
|
await handleWriteCommand('viewport', ['400x300', '--scale', '2'], bm);
|
|
await handleWriteCommand('viewport', ['400x300', '--scale', '1.5'], bm);
|
|
const text = await handleReadCommand('text', [], bm);
|
|
expect(text).toContain('double-cycle-marker');
|
|
await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm);
|
|
} finally {
|
|
try { fs.unlinkSync(fix); } catch {}
|
|
}
|
|
});
|
|
|
|
test('goto clears loadedHtml — subsequent viewport --scale does NOT resurrect old HTML', async () => {
|
|
const fix = path.join(tmpDir, `clear-${Date.now()}.html`);
|
|
fs.writeFileSync(fix, '<div id="stale">stale-content</div>');
|
|
try {
|
|
await handleWriteCommand('load-html', [fix], bm);
|
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
|
await handleWriteCommand('viewport', ['400x300', '--scale', '2'], bm);
|
|
const text = await handleReadCommand('text', [], bm);
|
|
// Should see basic.html content, NOT the stale load-html content
|
|
expect(text).not.toContain('stale-content');
|
|
await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm);
|
|
} finally {
|
|
try { fs.unlinkSync(fix); } catch {}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Alias routing ─────────────────────────────────────────────
|
|
|
|
describe('Command aliases', () => {
|
|
const tmpDir = '/tmp';
|
|
const aliasFix = path.join(tmpDir, `alias-${Date.now()}.html`);
|
|
|
|
beforeAll(() => {
|
|
fs.writeFileSync(aliasFix, '<p id="alias">alias routing ok</p>');
|
|
});
|
|
afterAll(() => {
|
|
try { fs.unlinkSync(aliasFix); } catch {}
|
|
});
|
|
|
|
test('setcontent alias routes to load-html via chain', async () => {
|
|
// Chain canonicalizes aliases end-to-end; verifies the dispatch path
|
|
const result = await chainMeta(bm, [JSON.stringify([['setcontent', aliasFix]])]);
|
|
expect(result).toContain('Loaded HTML:');
|
|
const text = await handleReadCommand('text', [], bm);
|
|
expect(text).toContain('alias routing ok');
|
|
});
|
|
|
|
test('set-content (hyphenated) alias also routes', async () => {
|
|
const result = await chainMeta(bm, [JSON.stringify([['set-content', aliasFix]])]);
|
|
expect(result).toContain('Loaded HTML:');
|
|
});
|
|
});
|