mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-31 10:20:42 +02:00
* fix(ci): free-tests lane actually runs the make-pdf e2e gates The 9 make-pdf/test/e2e gate tests probe make-pdf/dist/pdf, browse/dist/browse, and the diagram-render bundle, then self-skip when absent. The required free-tests lane never built any of them, so the gates silently skipped on Linux for their entire life (verified: 9 of 14 skip, exit 0). make-pdf-gate.yml's justification for deleting its Linux leg claimed the free lane covered this — it didn't. - new build:gates script: exactly the three artifacts the gates probe (full bun run build compiles five binaries; ~60-90s tax on the only required check is not warranted) - free-tests.yml: build:gates step + poppler-utils + fonts-noto-color-emoji (fonts must precede the first browse daemon launch — Chromium snapshots fontconfig at startup; verified live: a warm daemon renders tofu, a fresh one embeds NotoColorEmoji) - make-pdf/test/e2e/ci-prereqs.test.ts: GSTACK_EXPECT_BINARIES=1 (set by the workflow) inverts the skip polarity in CI — dropping the build step or poppler fails the lane instead of re-opening the silent-skip hole Pre-flight: all 9 gates green on Linux locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): kill the three zero-test eval jobs (hollow green) - delete the vestigial e2e-codex / e2e-gemini matrix rows: both files are whole-file periodic-tier, so with no row tier: they ran ZERO tests and reported green on every PR (~2 min of runner each, pure false confidence; the periodic lane owns those suites) - e2e-pty-plan-smoke gains tier: gate — its two files are whole-file describeE2ETier('gate'), so the job burned ~7 min of container setup then skipped every describe - KNOWN_TIER_UNSET burned down to empty; the ratchet stays armed so a future row/file tier mismatch fails the suite instead of shipping hollow green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): least-privilege permissions + fork-safe concurrency keys - evals.yml / evals-periodic.yml evals jobs: explicit contents:read + packages:read (container-image pull) and persist-credentials:false — the jobs that execute PR-authored code with three provider API keys ran on the repo-default token grant with the token written into .git/config - permissions blocks for the 4 workflows that had none (skill-docs, make-pdf-gate, windows-free-tests, windows-setup-e2e) - fork-safe concurrency keys: actionlint, skill-docs, make-pdf-gate, windows-setup-e2e switch from head_ref to PR-number keying — a bare branch name carries no fork prefix, so same-name branches from two forks shared one group and cancelled each other's runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): one bun version everywhere + drift tripwire Lanes disagreed four ways: 1.3.13 (free-tests, windows, Dockerfile.ci), latest (quality-gate, make-pdf-gate), unpinned (skill-docs, version-gate — setup-bun installs latest), 1.3.10 (.gitlab-ci.yml). Different Bun versions change the runner output shapes the strict classifiers regex-match, spawn semantics, and shell parsing — a lane on a different Bun tests a different product; Dockerfile.ci's own comment records this class biting once already (silent 1.3.13/1.3.14 drift). All surfaces pinned to 1.3.13; test/bun-version-drift.test.ts scans every workflow setup-bun stanza + Dockerfile.ci + .gitlab-ci.yml and fails on any mismatch or unpinned stanza. skill-docs also gains --frozen-lockfile (was bare bun install). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ci): bind the three-way image-tag hashFiles() expressions evals.yml, evals-periodic.yml, and ci-image.yml each compute the CI image tag from hashFiles('.github/docker/Dockerfile.ci', 'bun.lock', 'patches/**') — synced by comment only (TODOS.md 'CI three-way image-tag drift'). If one input list drifts, that workflow computes a different tag for the same content: eval lanes silently rebuild the image every run, or ci-image prebuilds a tag nobody looks up. The test extracts each tag-computation site and fails on any mismatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): ci-image stops rebuilding the identical image every ship - package.json out of the trigger paths: the tag hash deliberately excludes it (version bumps every ship), so every merge rebuilt and re-pushed the IDENTICAL tag (~2m26s for zero content change); patches/** added (it IS a tag input) - manifest existence check (mirrors evals.yml): tag already exists → skip the build - concurrency group: two rapid main pushes raced pushing the same :latest/:buildcache tags - cron staggered 06:00→04:00 Monday: it shared the exact minute with evals-periodic, which could race a half-pushed tag or duplicate the build - timeout-minutes: 30 (was unbounded → 360-min default for a hung docker build) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): quality-gate drops the 74s full-history checkout fetch-depth:0 cost 74 of the job's 92 seconds; the three gates it feeds take ~12s combined. Shallow checkout + exact-SHA fetches for the diff's base/head (an exact-SHA fetch, not a guessed depth — long-lived branches and merge queues still resolve), with a --deepen fallback for push events whose 'before' is unusable. timeout right-sized 20→10 min. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): small-lane batch — timeouts, right-sizing, windows cache warm-start - timeout-minutes on the 6 remaining unbounded jobs (actionlint 5, skill-docs 10, version-gate 10, make-pdf-gate 15, pr-title-sync 5, evals build-image 15) — a hung step sat on GitHub's 360-min default - right-size measured-over-long timeouts: dependency-review 10→5, windows-setup-e2e 15→10 - dependency-review: 2-core runner (28s API call on an 8-core box) and drop .github/workflows/** from its trigger paths (workflow edits have no dependencies to review) - windows caches gain restore-keys: a lockfile bump paid the 26s/43s restore for a guaranteed cold miss Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): scope GSTACK_HOME to each file's execution window Five files assigned process.env.GSTACK_HOME at module scope. Shard processes evaluate sibling modules before running their tests, so the assignment leaked into every other file in the shard — the damage was already visible in defensive workarounds (relink.test.ts:28 'fresh install test saw a neighbor's skill_prefix'; cdp-e2e's own comment documents a sibling's temp dir baked into artifacts). Pattern: save original, assign in beforeAll, restore in afterAll (cdp-e2e already restored but still assigned at load — its window now matches the others). GSTACK_TELEMETRY_OFF and GSTACK_PROJECT_SLUG get the same treatment where they rode along. Victim files' defenses stay in place (cheap insurance). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: tripwire against module-scope GSTACK_HOME assignments Column-0 assignment of GSTACK_HOME / GSTACK_STATE_ROOT in any tracked *.test.ts fails with the file:line and the fix (beforeAll + afterAll restore). Kills the cross-file env-leak class the previous commit swept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): e2e-harness-audit derives its skill census from disk The hand-maintained 39-name SKILL_GLOBS list had drifted to 39 of 54 SKILL.md.tmpl on disk. No live gap today (none of the 15 unlisted skills is interactive), but the next interactive skill would have landed unguarded with zero signal. The audit now walks top-level dirs for SKILL.md.tmpl (statSync so symlinked dirs like connect-chrome count), so new skills are in scope the commit they appear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(evals): judges honor the eval-model resolution chain + real 429 backoff callJudge inlined GSTACK_EVAL_MODEL_JUDGE || sonnet, silently ignoring the global GSTACK_EVAL_MODEL override every other eval call site honors via lib/eval-model.ts. New 'judge' kind in DEFAULTS (sonnet — the D1a pin-on-regressors calibration stands; model CHOICE unchanged) and callJudge resolves through it: explicit arg > GSTACK_EVAL_MODEL_JUDGE > GSTACK_EVAL_MODEL > default. 429 handling upgraded from one fixed 1s retry (reliably lost races at CI concurrency) to three jittered exponential retries (~1s/4s/16s), honoring the server's retry-after when present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): the two expect(true) paid stubs become test.todo skill-e2e-spec-execute (600s budget) and skill-llm-eval-spec (300s) reported PASS on every periodic run while asserting nothing. Deleting them would remove the periodic-tier selector surface they exist to register (diff-based selection for spec/ changes), so they become test.todo — reported as todo/skip, never pass — with the v1.1 implementation specs kept in-file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): reactivate 5 quarantined browse tests (2 security) extension-sender-auth's two privileged-message denial tests (content script + missing sender.url — the extension's security boundary) and snapshot's three skips were quarantined 'pre-existing' failures. Root cause: machine-local state on the quarantining dev machines — the test and gate code are byte-identical between the quarantining commit (410b4928) and HEAD, and all five pass deterministically on a clean checkout (68/68 across both files, multiple runs). No assertions weakened, no product changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(evals): activate the 4 paid test files that could never run anywhere carve-section-loading, codex-e2e-plan-format, codex-e2e-recommendation-substance, and llm-judge-recommendation gated on EVALS/tier (free suite loads them as describe.skip) but their names fell outside PAID_TEST_GLOBS, so no paid lane ever selected them — net execution zero, forever. The existing matrix tripwire filtered on isPaidTestFile() first, so it was blind to exactly this class (the same bug that hid the pre-split monolith's gate tests for ~8 releases). - PAID_TEST_GLOBS: codex-e2e* + skill-llm-eval* wildcards (replacing exact names) + llm-judge-recommendation + carve-section-loading; package.json's six test-script glob lists mirrored - codex-e2e-plan-format gains the explicit periodic tier gate its siblings carry (external-service rule) — without it the sharded runner's no-guard default would spawn Codex in the gate tier per PR - eval:bg:periodic --timeout 32400→37800: the census growth pushed the periodic worst case to 35910s; the old value had 270s of headroom BEFORE this change and would now kill healthy runs mid-flight - new test/paid-orphan-tripwire.test.ts: any EVALS/tier-gated test file outside the globs fails the free suite (reasoned SCANNER_EXEMPT for the gate helpers + meta-tests) — the class-killer - paid-shards pins updated: the four orphans now assert INSIDE the census Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): restrictDirectoryPermissions warns and skips symlinked dirs Closes the Windows Free Tests red: recent lane failures showed a platform-unguarded POSIX mode-bit assertion ('Expected: 493' — a symlink-skip test) from PR-branch variants; the KNOWN_WINDOWS_SAFE force-include reason ('mode-bitmask hits are POSIX-branch only') did not hold for that shape, and main had neither the guard nor the behavior. - product: lstat first; a symlinked dir gets a warning and a skip on both platforms — chmod AND icacls dereference the link, so restricting through a symlink hardens an unvetted target (and /inheritance:r could lock out its real owner). All callers already treat hardening as best-effort (try/catch). - test: the symlink regression test, platform-aware — symlinkSync in the house try/catch skip pattern (Windows runners without Developer Mode can't create symlinks), mode-bit assertion guarded off win32, behavior assertions (no throw, warning text, target readable) everywhere; POSIX still proves the skip (0o755 unchanged, not 0o700) - KNOWN_WINDOWS_SAFE reason updated to the now-true premise 20/20 pass on Linux. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): unique tmp dirs for plan artifacts + audited live-repo cwd sites Six paid PTY tests wrote their expected plan artifact to a FIXED shared /tmp path ('/tmp/gstack-test-plan-<mode>.md') and rmSync'd it in finally — under --retry 1, EVALS_JOBS>1, or two concurrent worktrees, a sibling's cleanup deletes this run's artifact and the D19 'agent did not produce expected plan file' assertion fires spuriously. Each test now mkdtemps its own dir, interpolates the unique path into the agent prompt (fixture-sourced prompts get a replaceAll + drift guard that throws if the fixture's literal ever moves), and cleans up its own dir. The 18 cwd:-into-the-live-repo sites were audited: all deliberate (skill registry + hermetic pre-trusted dir, in-repo gen renders, git history reads, slug resolution) — each now carries a '// LIVE-REPO CWD: <reason>' comment so the next audit can tell deliberate from accidental. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): trim the seven over-wall 1700s timeouts to the 1500s physical ceiling 1,700,000ms (28.3 min) exceeded every wall these tests run inside: the 25-min CI job timeout and the 1800s sharded-runner wall (which also leaves --retry 1 zero room for a second attempt). Budget above the wall is fiction, not headroom — a test that actually used it produced a job-level kill (no bun summary, no artifact) instead of a clean per-test timeout. No recorded p95 exists for this family (they are being retiered to periodic in the re-platform wave); the trim stops at the physical ceiling rather than guessing lower. Final policy lands in the Wave-2 eval-budgets constants module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(gen): main() guard — importing gen-skill-docs no longer regenerates the tree The generator's whole body executed at module load, so any import of it (test/gen-skill-docs.test.ts pulls assertSinglePreamble via require(); test/catalog-trim.test.ts imports helpers) regenerated all 71 SKILL.md in place — the root cause of half the TREE_MUTATING serial-shard entries (hazard class #2532). The body now lives in an exported main(): number behind if (import.meta.main). Semantics preserved exactly: failure exits are immediate (matching the old top-level process.exit), success leaves the event loop to drain so the llms.txt fire-and-forget IIFE finishes its write, and the module stays synchronous/require()-able. Proofs: byte-identical --host all output (git status clean), --dry-run stale-tree still exits 1 (the skill-docs freshness lane depends on it), and the new test/gen-skill-docs-import-purity.test.ts pins load-time purity via a subprocess probe (mtime-based, so a dirty worktree can't false-fail). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(gen): --out-dir renders every host, outputs-only --out-dir was Claude-host-only (gen-skill-docs.ts:842), which forced the codex/factory-regenerating tests (gen-skill-docs, skill-validation, host-config) to mutate the live tree — the reason they sit in the TREE_MUTATING serial shard. The flag now mirrors ALL outputs into the out-dir: external-host trees (.agents/.factory/... via processExternalHost), external section files, openclaw docs, and gstack/llms.txt (a catalog-mode render must never rewrite the tracked index). OUTPUTS ONLY — inputs (templates, sections/, host configs) are always read from ROOT, so an empty out-dir can never feed the render. rewriteSectionBase stays Claude-only (external hosts have their own path grammar). Proofs: in-place --host all is byte-identical (tree clean); --host all --out-dir <mkdtemp> renders the full multi-host tree with ROOT untouched; gen-skill-docs-out-dir tests + 415/415 gen-skill-docs.test.ts green (bin/dev-setup's claude rendering byte-compat). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(evals): every E2E key's dep list names its own declaring test file 129-of-177 keys omitted their own test file, so editing only a test's prompt or assertions selected NOTHING — the changed test never ran on the change that changed it. 135 keys self-registered (110 E2E + 25 LLM-judge), resolved by strict declaration evidence (testName:/ testIfSelected/judge call sites), with skill-name false positives excluded. e2e-tier-alignment's warn-only branch for unregistered files is now a hard failure with a 4-entry KNOWN_UNREGISTERED ratchet (template- literal testNames, fail-open-safe) + a burn-down test so the set only shrinks. Selection sanity: a one-file diff on skill-e2e-qa-workflow now selects its 4 tests (was 0); skill-llm-eval 0 → 25. Known follow-ups (filed): 15 E2E + 2 judge PHANTOM keys select tests that exist nowhere; codex-e2e-plan-format's testIfSelected names have no map keys (run-all only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(evals): ratchet the 8 newly-visible gate-matrix gaps The self-registration sweep made these eight files' gate-tier keys visible to the census for the first time — their gate tests run in NO CI lane today (pre-existing hole, newly measurable). Ratcheted into KNOWN_MATRIX_GAPS with the burn-down note: the paid-lane re-platform runs every gate file by construction and retires this ratchet class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): duration-aware LPT shard packing for the free suite Hash sharding balances file COUNTS (1.15x spread) but not cost — the Playwright-launching files landed 4/3/4/1/2/1 across 6 shards, giving a measured 28s–97s shard spread and ~40s of idle tail on every run. Full-suite mode now packs by recorded per-file durations (longest-processing-time-first) when the committed seed scripts/free-test-durations.json exists. - ONE store, no overlay: the seed is refreshed occasionally via the new --record-durations mode (each file timed in its own child — exact, and immune to bun's stream buffering, where silent passers print no header to timestamp); GSTACK_FREE_TEST_DURATIONS overrides the path for experiments; CI never records - seed is a hint: missing → silent hash-shard fallback; corrupt (bad merge) → one warning + fallback; unknown files → 75th-percentile pessimism so a surprise long-runner can't recreate the tail - packed shards get duration-aware walls (max(base, predicted x 3)) — LPT decouples count from cost BY DESIGN, so the 5s/file heuristic would undersize a shard holding few expensive files - one log line per shard (files + predicted seconds) so packing regressions are diagnosable from any run log - the --shard CI-matrix path is untouched: stable hash indices are its contract - successor note in-code: bun >=1.3.14 ships native --timings/--shard LPT — swap this packer when the repo unpins 1.3.13 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): decouple slop:diff from bun run test; quality-gate runs it per PR 'bun run test' silently appended up to two 120s npx slop-scan runs plus a git worktree add/remove after the suite (2>/dev/null || true) — invisible in the documented '~90-100s' timing and pure friction in the pre-commit loop. Decoupling is not coverage removal: quality-gate.yml now runs slop:diff on every PR (advisory, matching its in-repo 'never blocking' contract), and /review already invokes it explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): eval-budgets timeout tiers + fit/ceiling policy test Five named tiers (JUDGE 120s / CAPTURE 300s / CAPTURE_LONG 600s / PTY 900s / PTY_LONG 1200s) replace hand-ratcheted sprawl (46x300s, 46x120s, 44x360s, 44x180s, 27x240s, 19x150s, 13x420s, 12x600s...), much of it inflated to paper over the old 40-way in-shard concurrency that the sharded runner's 1-file-per-shard model kills. Policy test pins: every tier fits the shard wall minus 120s overhead (the structural fix for budgets-above-the-wall fiction), tiers stay ordered, and no paid literal exceeds PTY_LONG x1.25 — oversized tests get split, not budgeted past the wall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): shared runBin helper for bin-script unit tests ~36 free test files each carry a near-identical local run() (spawnSync + utf-8 + {status, stdout, stderr}) differing only in env composition, cwd, and timeout. runBin absorbs the invariant core; options carry the variance (gstackHome sets BOTH GSTACK_HOME and GSTACK_STATE_DIR — the config-precedence trap several locals rediscovered independently; home for $HOME-anchored bins; input/trim/timeout/maxBuffer). Free-test-only by design so it never becomes a de facto global touchfile. Migration of the 36 call sites lands separately (mechanical batches). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): runBin trim assertion — trim shapes stream ends, not interior Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): mechanical sweep — 298 paid-test timeouts onto eval-budget tiers 69 files, both shapes (trailing bun-test budgets and runner timeout/timeoutMs options), ROUND-UP ONLY so nothing that passed can start failing: 75 → JUDGE_MS, 137 → CAPTURE_MS, 74 → CAPTURE_LONG_MS, 9 → PTY_MS, 3 → PTY_LONG_MS. Raw >=60s literal count in the paid scope: 395 → 97, of which 51 are non-timeout noise (fixture dates, run IDs) and 46 are enumerated justified holds (comment-carrying calibrated budgets, poll-loop constants, utility spawn waits, and the seven physical-ceiling 1_500_000 sites). The eval-budgets policy ratchet keeps the residue from regrowing. Known collapse: where an inner runner budget and its enclosing test budget now share a tier, the old stagger is gone — an overrun surfaces as a bun test timeout instead of a graceful runner timeout (diagnosability trade, not a correctness one). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: coverage fill — 95 tests for six zero-coverage surfaces - eval CLI family (eval-list/compare/summary + eval-select smoke): the primary interface to eval results had no tests; isolation via a fake gstack-slug under a mkdtemp HOME (the scripts' real resolution path — they do NOT honor GSTACK_EVAL_DIR; only EvalCollector does). Pinned current behavior: eval-list does NOT exclude _partial runs (documented improvement candidate) - slop-diff (runs on every /review + quality-gate): fixture git repo + first-on-PATH npx stub (never downloads real slop-scan); no-diff early exit, missing-scanner fallback, fingerprint line-insensitivity, merge-base worktree scan - bin/gstack-code-intelligence CLI arg surface (lib was covered, the 284-line CLI wasn't): select/consent/suggest/index/search gating; pinned: --help routes to usage failure exit 1 (no handler) - browse media-extract: the page.evaluate callback exercised in-process against a mock DOM (no exports added) — lazy-src fallback chain, HLS/DASH detection, bg-image url() parsing, 500-element cap - browse session-cookie-store: factory contract (cookieName/ttlMs/ maxSessions eviction, cross-store isolation, mint→validate round-trip); store is in-memory — no fs cases exist - lib/version-source direct unit tests (gstack-version-bump.test.ts spawns the bin, never imports the lib): parse/format/cmp/bump coercion, npm 4→3 translation, #2501 mangled-JSON regression class All hermetic (mkdtemp homes, runBin child isolation); windows curation correctly partitions the six. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): first runBin migration batch (3 of ~36 run() duplicates) explain-level-config, benchmark-cli, evidence move onto the shared helper; each file's remaining special-case spawnSync sites (raw-buffer probes, env-scrub probes) stay put deliberately. 55/55 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(evals): paid shards spool to disk + shared runShardChild lifecycle - runPaidShard no longer buffers whole 30-min stream-json streams in RAM (x concurrent jobs): every byte tees to a per-shard log file (slug-named, path printed at START for mid-run inspection and on the FAILED terminal line); failures print a 64KiB tail read back from disk; passing shards stay quiet (the file is the record) — the free runner's proven contract. Classification unchanged: the strict classifier still sees every byte first. - the ~35 duplicated spawn/group-kill/wall-timer/finally-reap lines move into runShardChild in test-strict-output.ts (detached-per- platform spawn, signal forwarding, SIGKILL group kill at the wall, drain-before-verdict); designed so the free runner can migrate later - expectedFiles drift fixed toward ENFORCEMENT: the injected-command exemption is gone — a fake command exiting 0 without bun's terminal summary now reads FAILED (pinned: silent-pass → failed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(evals): parent-computed selection propagates to shard children The sharded runner computed diff selection once, then each of its 48-73 children recomputed it at module load — including, on touchfiles-diff branches, a per-child bun subprocess evaluating the old data file (20s timeout each). The parent now serializes {version, selected, reason} as EVALS_SELECTION_JSON into the shard env; e2e-helpers adopts it at load. Fail-open preserved: any parse/shape violation → ONE stderr warning + local recompute; absent env → silent local compute (non-sharded entrypoints unchanged). Drift test pins parent→child round-trip to identical selection decisions plus the malformed/absent cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): kill the four worst fixed sleeps (300s/30s/30s/20s) - watchdog.test: the 20s blind wait for one production parent-watchdog tick becomes BROWSE_PARENT_WATCHDOG_INTERVAL_MS=250 (new env knob in server.ts, NaN-safe, production default unchanged) + polls for the boot line and the tick's stay-alive log — strictly stronger (the old form never proved a tick observed the parent death). 24s → 3.6s. - stop-dead-daemon / terminal-agent-owner-watchdog: the 300s/30s stand-in child lifetimes become stdin-EOF-bound — the child can never self-exit mid-test on a slow runner (spurious-failure class) and self-reaps instantly if the test dies (no 300s orphans). Node-compat stdin APIs (owner-watchdog runs on the Windows lane). - browser-skill-commands: the sleeper fixture's 30s self-time becomes 8s (no stdin pipe exists in runToFiles) — far above the 1s product timeout it must outlive, below the test ceiling, so a timeout-kill regression fails on clean assertions instead of an opaque bun timeout; added: stdout must NOT contain 'done'. 45/45 green across the four files + server tripwires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gen-skill-docs + catalog-trim leave the serial mutator shard gen-skill-docs.test.ts's 15 in-place generator spawns now render into mkdtemp out-dirs (gitignored-artifact reads repointed; the handshake scan's silent console.warn degrade became a hard assertion); its tracked-tree reads (freshness dry-run, SKILL.md content pins) stay reads. catalog-trim needed no change beyond the earlier main() guard — its import is now side-effect-free (pinned by the import-purity test). Both TREE_MUTATING entries deleted in this commit, per the transition rule: an entry leaves in the same commit as the file's last in-place write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): skill-validation renders codex host into an out-dir Its 3 in-place --host codex regeneration sites collapse into one module-level --out-dir render; assertions untouched. TREE_MUTATING entry deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): host-config self-provisions goldens (ordering dependency severed) Its goldens were 'produced by gen-skill-docs.test.ts' with a when-missing beforeAll fallback that wrote the live tree — an inter-test ordering dependency the serial shard hid. It now renders codex+factory UNCONDITIONALLY into its own out-dir and reads goldens only from there (the Claude golden deliberately keeps reading tracked ship/SKILL.md — a read; out-dir claude renders repoint section-base paths by design). TREE_MUTATING entry deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gbrain-detection-override drops mutate-then-git-restore regenAndSnapshot renders --host claude --out-dir <mkdtemp> (+ --respect-detection) and snapshots probes from the out-dir. The git-restore machinery is deleted outright — it restored only PROBE_FILES of the 71 files each call wrote, so a stale tree kept the other 68 dirty (the partial-restore bug), and its 'no output-path arg' comment had been false since --out-dir landed. TREE_MUTATING entry deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): catalog-mode-full renders to out-dir; restore machinery deleted The full-catalog smoke no longer rewrites all 71 SKILL.md then regenerates to restore (with its 'CRITICAL: failed to restore' prayer path) — it renders into a mkdtemp and additionally asserts tracked ship/SKILL.md is byte-unchanged. TREE_MUTATING entry deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): idempotency proof strengthens to two-out-dir recursive diff Two renders into two separate out-dirs, EVERY file diffed byte-for-byte (claude-only and --host all; normalization only for each dir's own sanctioned section-base repoint; presence-sanity lists guard against a vacuous empty-dir pass) — strictly stronger than the old in-place double-regen that sampled 5 files. TREE_MUTATING entry deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): spec-template-sync compares an out-dir render, not an in-place one TREE_MUTATING entry deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): the serial tree-mutating shard dissolves — TREE_MUTATING is empty Zero mutators remain (all eight render into out-dirs now), so the four ratchet READERS (parity caps, size budgets, carve parity/ordering) get a quiet tree by construction in any shard and rejoin the parallel phase. The ~35-40s serial tail on every full-suite run is gone. The mechanism stays: a future test that genuinely must write shared artifacts in place earns an entry with a reason and is serialized again; the census pin still fails on renamed keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(gen): out-dir byte-identity + tree-clean pins for external hosts codex render: porcelain unchanged AND out-dir gstack-ship/SKILL.md byte-identical to a fresh in-place render (+openai.yaml presence); --host all render: exit 0, porcelain unchanged, claude + .agents + .factory + llms.txt + openclaw docs all present in the out-dir. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): commit the initial free-test durations seed (496 files) Recorded via --record-durations on a quiescent tree: 479s serial total, p50 92ms / p90 1.8s / max 31.4s — the top-heavy cost shape LPT packing exists for. A hint, not a contract: refresh opportunistically with bun run test:free --record-durations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(evals): planner/executor/report modes — the CI re-platform surface One PLANNER computes diff selection + the slice plan ONCE and writes a manifest (--emit-plan <path> --slices K); K executors consume it (--plan <path> --slice i), never self-selecting, and write slice-result artifacts; a REPORT reconciles results against the manifest (--report <dir>) fail-closed: a slice whose artifact never landed is a FAILURE, a planned shard nobody reported fails, wrong-slice/duplicate/cross-tier results fail. Kills per-slice selector divergence and hollow-lane aggregation at the root. - hollow-shard guard: under EVALS_ALL, exit 0 with ZERO executed tests (bun's 'Ran N tests' now captured by the classifier — additive) is 'passed-empty' and fails the run; selective runs keep it 'passed' with one warning (in-file diff/tier self-skips are legitimate there); unknown counts are never guessed hollow - retry parity: --retry 1 default + RETRY_OVERRIDES literals for the three files whose old matrix rows earned retries: 2 (stale entries pinned against disk) - live smoke: gate plan = 48 shards across 6 slices; report mode exits 1 on a fabricated missing slice, 0 when complete Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): sliced paid lane (planner -> 6 executors -> fail-closed report) The parity-phase re-platform: evals.yml gains a second, sliced lane driven by scripts/test-paid-shards.ts — the SAME engine local eval:bg:gate uses, so CI and local share one selection engine. - plan-slices: ONE planner (fetch-depth 0 — the only job needing history) emits the manifest; selection fails open to run-all, never per-slice (the divergence class is structurally dead) - eval-slices: 6-way matrix consuming the manifest; PTY seed + skill-registration steps run unconditionally (idempotent — a sliced lane cannot key them on suite names); aggregate spawn budget 6 x EVALS_JOBS=2 x EVALS_CONCURRENCY=2 = 24 lane-wide (the matrix's 40-way per row queued session startup behind 39 siblings — the timeout-flake family root); slice results + spooled shard logs uploaded as artifacts - slices-report: reconciles slice artifacts against the manifest FAIL-CLOSED via --report — a slice whose artifact never landed, or a planned shard nobody reported, is a failure, not an absence - sequenced needs: evals so provider concurrency never doubles while both lanes coexist; the matrix + its ratchets are deleted after demonstrated parity (intersection + expected-additions comparison) - workflow_dispatch gains evals_all (default true) for parity runs and post-merge smokes — a dispatch can never silently select zero Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): weekly periodic lane runs EVERY periodic test + gate census backstop evals-periodic.yml re-platforms onto the sharded runner: planner manifest → 6 executor slices → FAIL-CLOSED report. This IS the coverage contract: all ~70 periodic-tier files weekly (EVALS_ALL=1), killing the silent-rot class where a hard-coded 9-file matrix left ~57 files running NOWHERE (the autoplan E2E rotted invisibly for months). - test/helpers/periodic-exclude-data.ts: reasoned exclusions in their OWN literals file (deliberately not touchfiles-data — map-diff evaluates old versions of that file standalone). Every entry carries reason + tracking with a re-entry condition; the runner surfaces each exclusion per run; policy test pins real-file + non-empty fields. Initial: ship-idempotency + brain-privacy-gate (documented-red, never green) and skill-e2e-ios (manual hardware). The TODOS 'sidebar E2E trio' turned out already deleted — only tombstone tests remain. - gate-census job: weekly EVALS_ALL gate-tier run — PR lanes are diff-billed, so without this the full gate census might never execute anywhere; with the hollow-shard guard it is a census-health check (exit 0 + zero executed tests fails), not just a test run. - failure notification is a concrete gh issue UPSERT (one tracking issue, commented per red week — never issue-per-week spam), with issues:write scoped to the report job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: TESTING_INTERNALS covers the 2026-08 runner overhaul LPT-packed free suite + --record-durations, the emptied TREE_MUTATING mechanism, the sharded paid runner as the single selection engine, CI planner/executor/report with the fail-closed report and hollow-shard guard, the weekly coverage contract + exclusions policy, and the eval-budgets timeout tiers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(CLAUDE.md): testing prose matches the overhauled runners - bun run test: duration-packed shards + --record-durations; the trailing serial tree-mutating shard no longer exists - two-tier system: the sliced CI lanes (one engine local+CI), the weekly all-periodic coverage contract + exclusions, the gate census - periodic detach timeout 32400 → 37800 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(TODOS): close the absorbed test-infra items, file the overhaul follow-ups Closed with receipts: the periodic coverage contract (implemented as full weekly coverage + exclusions), the eval-harness observability P1 (verified already landed: heartbeat, incremental _partial persistence, live stderr + eval-watch), and the sidebar trio (already deleted — tombstones remain). Filed: matrix deletion after parity, the required-check maintainer decision, browse /tmp-namespace hardening, PTY boot-readiness waits, the single typed test registry, bun-native LPT swap, runBin/free-runner migrations, eval-list partial exclusion, phantom key cleanup, duration-weighted slicing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v1.73.0.0: test/CI overhaul — green means green, suites restructured for speed Version + release notes for the audit-and-overhaul branch: every silently-skipping or never-running test class fixed and tripwired, the free suite duration-packed with the serial mutator shard dissolved, the paid lane re-platformed onto the sharded runner (planner/slices/ fail-closed report, parity phase), the weekly all-periodic coverage contract, eval-budget timeout tiers, and 95 new coverage tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): first-live-run fixes — executor history + two environment-blind assertions The sliced lane's first run (PR #2721) did its job: the planner and report worked, the manifest governed, and every failure had a name. Three were fixable on the spot: - executor + gate-census checkouts get fetch-depth: 0 — files with SELF-derived selection (the LLM-judge map, routing) walk git at module load, and selection is deliberately fail-closed on git errors, so the shallow checkout crashed those shards ('ambiguous argument main...HEAD'). The manifest still governs WHICH shards run. - landscape --toc gate: the exact toBe(3) landscape-page count was font-metric-dependent (3 on Amazon Linux, 2 on ubuntu CI — the same disease the file's own page-index comment warns about). Now a comparative invariant: --toc must not CHANGE the landscape count vs a baseline render. - paid-run-manifest parse test builds its manifest under EVALS_ALL so it never walks git (proven with GIT_DIR=/nonexistent). Remaining first-run failures are newly-exposed rot in gate files that had never executed in CI (skillify D1 refusal, session-intelligence context-restore, one tpa-apple-ban retry flake) — being probed separately; they are the lane WORKING, not the lane failing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(TODOS): file the three first-execution findings from the sliced lane's live run Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v1.74.0.0: queue-advance — #2722 claims the v1.73.0.0 slot The version gate caught a live queue collision (its whole job); same MINOR bump level, next free slot per bin/gstack-next-version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): per-shard CHROMIUM_PROFILE — the collision class duration packing exposed Nine test files launch in-process persistent contexts or daemons that default to the SHARED ~/.gstack/chromium-profile. Two concurrent shard processes on one profile dir kill each other's browser — observed live on CI once duration packing recomposed shards: handoff's launchPersistentContext died 'Target page, context or browser has been closed' (--user-data-dir=~/.gstack/chromium-profile in the call log) while a sibling shard's daemon logged 'Chromium process crashed'. Hash sharding had masked the collision by chance placement; handoff passes standalone everywhere. Fix at the runner, not per file: each shard child gets CHROMIUM_PROFILE=<shard-state>/chromium-profile (the documented env knob, same isolation idea as the existing per-shard TMPDIR). Files within a shard run serially, so sharing the per-shard profile is safe; config.test's resolution-order tests save/restore the env around their assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): landscape --toc gate asserts promotion PRESENCE, not counts Two rounds of CI receipts: the exact toBe(3) was font-metric-coupled (3 on Amazon Linux, 2 on ubuntu), and the baseline-comparison repair then failed 2-vs-3 across renders SECONDS apart in one CI job while the sibling no-toc test saw 3 — per-render image-promotion timing makes any count assertion here a coin flip. The sibling test owns exact promotion counts; this test's actual invariant is that --toc does not break the promotion machinery: >=1 landscape page + the TOC rendered. Also drops the second render (halves the test's runtime). Flaky per-render image promotion itself is worth its own look — noted in TODOS with these receipts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(TODOS): file the per-render image-promotion nondeterminism (receipts from PR #2721) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): per-FILE Chromium profiles for the nine in-process launcher files Completes the profile-isolation work: the per-shard CHROMIUM_PROFILE stopped cross-shard kills; these nine files launch in-process persistent contexts and could still collide with a lingering daemon a sibling file spawned on the SAME shard profile. Each now scopes a mkdtemp profile via beforeAll/afterAll (the module-scope-tripwire-safe pattern), cleaned up per file. All nine green solo and in combined runs, except the pre-existing commands+snapshot pairing — proven identical WITH and WITHOUT these edits (baseline receipts) — which is the daemon-lifecycle follow-up now extended in TODOS with this session's receipts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): Chromium-crash exit is daemon-only — embedded launches never kill their host handleChromiumDisconnect unconditionally process.exit()ed. Correct for the standalone daemon (its supervisor/user must notice); suicidal when a TEST launches BrowserManager in-process: a mid-suite Chromium death exited the whole bun shard with no terminal summary — the exact truncation class the strict runner flags (observed live: CI shard 1 oneb233299died at cache-concurrent-refresh right after a daemon-spawning gate test; with this fix the same pairing runs to completion and REPORTS instead of dying). The standalone entrypoint opts in via markDaemonProcess() under server.ts's import.meta.main gate — the same embedder contract its signal handlers already use (gbrowser phoenix keeps its own handlers). Embedded contexts now get the disconnect log line and continue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): context-restore assertion is evidence-based, not prose-matching The test failed twice per run in TWO CI cycles while passing locally 4/4: the prompt said 'present the content' and the check grepped the FINAL message for exact phrases — local runs quoted the file, CI runs paraphrased ('the most recent context is from branch-b...') and the substring check lost the coin flip. - prompt now demands machine-checkable output: the newest file's '## Working on:' heading VERBATIM + a literal 'RESTORED: <filename>' marker (the mtime-scramble and cross-branch subject matter untouched) - assertion ordered strongest-first: RESTORED marker → legacy content phrases → tool-call corroboration (Read/Bash input naming the newer file, credited ONLY when the older file was never read — a both-files run must still present the right one) - the older-file negative got STRONGER: an explicit RESTORED marker naming the older file fails even if wintermute words appear elsewhere - sibling scan: context-recovery-artifacts got the additive prompt-side treatment only (quote the matched literals verbatim); its lenient 1-of-6 assertion deliberately unchanged 3/3 consecutive local green with all evidence classes firing (marker=true, content=true, toolNewer=true, toolOlder=false). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): skillify family — HOME==cwd broke project-skill registration Root cause (forensically pinned from stream-json init events + a kill-after-init probe): with HOME set EQUAL to the child's cwd, claude resolves <cwd>/.claude/skills as the PERSONAL skills directory and the seeded project-tier skills never register — the Skill tool returned 'Unknown skill'. The provenance-refusal test then improvised a refusal whose wording missed the regex (the deterministic CI+local red); the happy-path and approval-reject siblings passed only because their agents self-recovered by Reading SKILL.md manually — silently not exercising the Skill-tool path at all. All three tests now use HOME=<workDir>/home (a fresh subdir keeps the override's intent: child ~/.gstack writes land in the assertable sandbox, without the cwd collision). Refusal test additionally: a 'not registered/unknown skill' tripwire (a not-loaded skill can never pass as a refusal) and the refusal regex now matches assistant text only — the skill BODY echoed into the transcript contains the exact refusal message, so the old full-surface match could pass vacuously once the skill loaded. Sibling disk assertions sweep both $HOME/.gstack and cwd .gstack roots (positives and negatives). Verified paid: refusal 2x consecutive green with the skill's EXACT message rendered ('Launching skill: skillify' in-transcript), then the full file 5/5 green (~$1.35) with both siblings driving real Skill calls (25-27 turns each). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(TODOS): two of three first-execution findings fixed (skillify family, context-restore) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): context-restore gets a private home — the REAL root cause was fixture sharing The evidence-based assertion fix was treating a symptom. The slice artifact's embedded transcript showed the CI agent restoring 20260829-context-save-skill-test.md — the checkpoint the SIBLING context-save test wrote into the SHARED gstackHome checkpoints dir, which by filename-prefix ordering genuinely IS the newest. The agent behaved CORRECTLY; the test's fixture set was open to concurrent sibling writes, and bun --concurrent ordering differs between CI (save finished first) and local (restore listed first) — the entire local-green/CI-red split explained. The restore test now uses its own .gstack-restore-home (the whole home moves, not just the handed path — an agent deriving the dir from GSTACK_HOME/projects/<slug> must land in the closed set too). Full file 4/4 paid green with all evidence flags firing. Also: the on-failure shard-log artifact glob uploaded nothing — the Fix-bun-temp step points TMPDIR at /home/runner/.cache, so the spool lands there, not /tmp. Both eval workflows now glob both locations (this gap is why diagnosing THIS failure required digging transcripts out of the slice-results artifact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(evidence): carry the real index mtime onto gstack-wtree's temp copy The stat-cache seed (cp of the real index) stamped the temp index "now", which defeats git's racy-git protection: an entry is only re-hashed when its cached mtime is not older than the index file itself, so a same-size rewrite landing in the same second as the last real index write looked non-racy, kept its stale stat-cache entry, and vanished from the fingerprint — evidence stayed FRESH after a source change. This is the CI flake in test/evidence.test.ts "allow-paths carve-out" (sub-second alignment on fast runners: expected STALE exit 1, got FRESH exit 0). touch -r restores the original index timestamp, reinstating the exact racy window git itself uses. Deterministic regression pin in test/review-log.test.ts reproduces the miss with pinned zero-nsec timestamps (fails on the old script, passes now); receipts: manual probe shows the fresh-stamped copy returning the clean tree for a same-size 'hello'→'howdy' rewrite while the mtime-carried copy detects it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): landscape gate bounds the promotion count instead of pinning 3 The alt-hinted image promotion rides the per-render measurement race already filed in TODOS (2-vs-3 landscape pages on renders seconds apart — CI receipts from PR #2721, now reproduced locally). Pin the two deterministic promotions as the floor and the three promotable blocks as the ceiling (anything above 3 means the veto leaked); the veto/portrait assertions remain exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Test <test@test.com>
2638 lines
100 KiB
TypeScript
2638 lines
100 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 * as os from 'os';
|
|
import { afterAll, beforeAll, describe, expect, test } 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';
|
|
|
|
// Per-FILE Chromium profile: this file launches an in-process persistent
|
|
// context (BrowserManager.launch()), and sharing a profile dir with the
|
|
// long-lived browse daemon a sibling file may have spawned kills one side's
|
|
// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never
|
|
// module scope (see test/gstack-home-module-scope.test.ts's rationale).
|
|
const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE;
|
|
let CHROMIUM_PROFILE_DIR: string | undefined;
|
|
beforeAll(() => {
|
|
CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-'));
|
|
process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR;
|
|
});
|
|
afterAll(() => {
|
|
if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE;
|
|
else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE;
|
|
if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} }
|
|
});
|
|
|
|
|
|
// 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(true); } catch {} // force-close keep-alives — a lingering Chromium connection otherwise blocks stop() forever
|
|
// 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:');
|
|
});
|
|
});
|