Compare commits

...

51 Commits

Author SHA1 Message Date
BenStullsBets 972ad8832b spec(design): scrub-driven altitude transitions (knob position drives morph + audio)
Approved design for the next increment: continuous knob position scrubs the morph
video + crossfades audio (hold-partway, reversible, re-roll-on-fresh-approach,
wheel auto-scrubs one altitude). Includes morph all-intra re-bake for smooth
seeking. To be planned + built in a separate session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:19:32 -07:00
BenStullsBets 9b4884b466 feat(sim): sweep the Altitude needle in sync with the morph (no pre-move)
The dial needle now sweeps one detent per crossed altitude driven by the morph
video's playback progress, landing exactly when the morph ends and the clip
locks. Drops the live finger-follow during drag (which moved the needle to the
target ahead of the zoom and caused a snap-back on commit) so the needle moves
ONLY with the transition. Verified in Chromium + WebKit, both directions, wheel
and drag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:47:19 -07:00
BenStullsBets d7e9fea1d3 feat(media): graduate used experience media into git via LFS (coast_birdrock transcoded)
The curated clip bases, per-scale audio, and 154 baked altitude morphs are the
actual experience content, so they're committed via git-LFS (self-hosted Gitea
LFS store). coast_birdrock/base.mp4 was re-encoded 68MB->22MB (25Mbps->8Mbps,
1080p30 unchanged) to fit the git.benstull.org front-end nginx upload limit; its
16 morphs were re-baked. Stale right_variants stay gitignored. Adds .gitattributes
(LFS) and negates the used media back into git in .gitignore. Also records the
LFS media-graduation decision in the plan doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:21:55 -07:00
BenStullsBets e8cd783258 test(e2e): Playwright tier asserts altitude lock + matching morph (in & out)
Task 8. New simulator/e2e Playwright tier (§9 browser tier). altitude-lock.spec
proves: zooming plays the morph matching the landed clip (forward + .rev), and
the clip is LOCKED after landing (no change while parked). advance() records
played morph paths on window.__hefMorphs (diagnostic seam; reliable under the
blob cache).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:33:33 -07:00
BenStullsBets 5382995931 feat(sim): play chosen morph, lock clip per altitude, drop secondary swap
Task 7. advance() threads the current clip as from_clip_id, plays the
server-resolved chained morphs (each already lands on its destination clip),
then locks activeClipId until the next altitude change — no fade-to-black
secondary swap, no re-roll while parked. Builds a (from->to) morph lookup
from the ring; preloads only the morphs reachable from the locked clip,
refreshed per landing (154 morphs is too many to preload up front). Drops
reverseFile + fadeThroughBlack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:15:03 -07:00
BenStullsBets 7855718f74 feat(ring,api): pick destination clip then resolve matching chained morph
Tasks 3-6. Transition is now a directed clip-pair morph; ScaleRing.morph_for
looks up (from,to)->file. advance_ring returns structural steps (no file);
resolve_move picks each crossed altitude's clip THEN its morph, chained, and
reports the locked target. Fast spin keeps the whole chain (blended) instead
of collapsing. /api/ring/advance takes from_clip_id, draws picks, returns the
resolved chain. Dropped ring_move_to_dict + _rev_file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:12:25 -07:00
BenStullsBets cc469b5298 feat(pipeline): per-clip-pair morph manifest + member-pair baking (154 clips)
Tasks 1-2 of the altitude clip-pair morph plan. Manifest emits a morph
entry for every video pair in adjacent altitudes, both directions
(<src>__<dst>.mp4 zoom-in + .rev zoom-out); generate_media() bakes them
all via the existing xfade=zoomin recipe keyed by clip member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:04:29 -07:00
BenStullsBets 3b3d30e8e0 plan(0024): add zoom-out E2E case; resolve review-gate open items
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:17:42 -07:00
BenStullsBets 441645ae7a plan(0024): altitude clip-pair morph + lock per altitude
Choose the destination clip before transitioning, play the matching
member->member morph (154 clips, all adjacent video pairs, both
directions), chain through each crossed altitude, and lock the clip
until the altitude is changed (no secondary swap, no re-roll).

Session 0024 (writing-plans). Stops at the review gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:09:06 -07:00
BenStullsBets 6427ab4a49 chore(audio): tuck audio diagnostics (status + native player) under Dev Mode
The 'no sound' debugging is resolved (stale-server resilience). Move the audio
status readout + native test player into the Dev Mode panel — kept for future
'can't hear' debugging, out of the installation UI. 7 E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:58:22 -07:00
BenStullsBets 56d08d5c60 fix(video): client tolerates a stale server (old /api/alteration shape + payload)
The operator's video wouldn't turn on: their stale uvicorn (old Python) requires a
7-way 'content' and 422s the {visual,audio} payload, and returns {content:{video}}
not {render:{video:{shown}}}. So the alteration POST failed and video never updated
(audio works because it's client-driven). Fix: controls() also sends a back-compat
'content' field (current server ignores it; old one ignores visual/audio), and
update() reads video-shown from either response shape. Verified end-to-end against a
simulated fully-stale server: video shows + audio plays, zero errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:46:49 -07:00
BenStullsBets 97d2ddd573 fix(audio): client soundtrack fallback when /api/ring lacks audio (stale server)
Root cause of 'no sound', found via the live readout: the operator's /api/ring
returned the ring WITHOUT the per-scale audio field (a server process predating the
audio code) → soundtrackUrl() was null → the element never got a src (readyState 0,
no error). The native test player proved the file itself plays. Fix: client falls
back to a scale-id→soundtrack map (mirrors build_pool_manifest.SCALE_AUDIO) so audio
works without a server restart. Verified: stale-ring sim now plays; audio-first-then-
video leaves both on. +2 E2E regression tests (7 total). Chromium + WebKit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:42:12 -07:00
BenStullsBets 56e352fe65 diag(audio): status shows resolved url + ring source + scale (pinpoints null soundtrack url)
The operator's 'PAUSED readyState 0' (no error) means soundtrackUrl() returned null
— the running app's /api/ring has no audio for the scale, the signature of a STALE
uvicorn server (Python predates the audio code; static reload doesn't restart it).
Status now shows url=<…|NONE>, ring=server|SYNTH, scale id so it's unambiguous.
Fresh server verified: ▶ playing rs=4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:34:16 -07:00
BenStullsBets 2564168ac3 Merge origin/main (0022 transcript) into 0023 diagnostics 2026-06-26 13:28:37 -07:00
BenStullsBets deb7c34df9 Merge session-0023: audio diagnostics (status readout + native test player) 2026-06-26 13:28:19 -07:00
BenStullsBets 625d32c0f3 diag(audio): live audio-status readout + native test player; stop swallowing play errors
Three blind fixes failed because headless browsers relax autoplay (can't reproduce)
and play() errors were swallowed. Instrument instead:
- #audio-status: live readout (playing/paused/blocked/media-error) under the toggle
- surface the play() rejection reason + element error (was .catch(()=>{}))
- 'Audio check' fieldset with a native <audio controls> on the soundtrack file, to
  test the file directly with zero app code in the path
Diagnostic only — no behavior change. 37 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:28:17 -07:00
BenStullsBets 58a6ee4644 add sessions/0022/SESSION-0022.0-TRANSCRIPT-2026-06-26T08-40--2026-06-26T08-50.md + replace placeholder/variant SESSION-0022.0-TRANSCRIPT-2026-06-26T08-40--INPROGRESS.md 2026-06-26 13:20:59 -07:00
BenStullsBets fd1da791bf claim human-experience-filter-art session 0022 (placeholder) + sessions.json entry 2026-06-26 13:20:20 -07:00
BenStullsBets 4ac39c9034 Merge session-0022: Loading Universe splash + Safari-safe single-element audio + video/audio coupling
Removes the tap-to-start wall (Loading Universe splash instead), fixes audio still
silent on real Safari (single <audio> played synchronously in the toggle gesture),
both toggles default off, first Video-on couples Audio. Verified Chromium + WebKit.
290 tests + 5 Playwright E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:19:39 -07:00
BenStullsBets 30ec0c9c26 fix(audio): Loading Universe splash; single-element in-gesture audio (Safari); first-video-on couples audio
Operator follow-ups:
- Remove the tap-to-start wall; show a 'Loading Universe…' splash until all media
  preloaded (main() awaits preloadAllMedia + a progress bar), then reveal the app.
- Both Video + Audio toggles default OFF (black + silent at start).
- FIX audio still silent on real Safari: the A/B-crossfade-after-gesture didn't
  unlock on Safari (priming srcless elements doesn't unlock them). Use ONE <audio>
  element played SYNCHRONOUSLY inside the toggle's click — the reliable Safari
  unlock; later altitude swaps reuse the unlocked element.
- First time Video turns on, also turn Audio on (one flip = full experience), set+
  played in the same gesture; Audio-on-first stays audio-only.
- Soundtrack follows Altitude via the single element (brief fade swap).
- Verified in headless Chromium AND WebKit: coupling works, soundtrack plays,
  video-off blanks, zero JS errors. 290 tests + 5 Playwright E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:19:15 -07:00
BenStullsBets 3d46b96ecb add sessions/0021/SESSION-0021.0-TRANSCRIPT-2026-06-26T08-12--2026-06-26T08-20.md + replace placeholder/variant SESSION-0021.0-TRANSCRIPT-2026-06-26T08-12--INPROGRESS.md 2026-06-26 12:57:38 -07:00
BenStullsBets 3b19d3eb92 claim human-experience-filter-art session 0021 (placeholder) + sessions.json entry 2026-06-26 12:56:57 -07:00
BenStullsBets fddb6d65d4 Merge session-0021: off/on Audio+Video toggles; Safari autoplay + GPU video-off fixes
Operator-driven revision of the audio feature: Dev-Mode-style Video/Audio toggles,
white-noise deferred (Audio = off/soundtrack), and fixes for two real-browser bugs
(video-off not blanking GPU-composited layers; no soundtrack on Safari/iOS).
Verified in headless Chromium + WebKit. 288 tests + 3 Playwright E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:56:20 -07:00
BenStullsBets 2722949805 fix(audio): off/on Audio + Video toggles; fix Safari autoplay + GPU video-off
Operator-driven live-UI revision:
- Video + Audio are now Dev-Mode-style on/off toggles (Audio on = soundtrack);
  white-noise deferred (removed from the live control; pure machinery kept).
- FIX video-off not blanking: the <video>/<canvas> are GPU-composited and could
  paint above the auto-z #black on a real GPU — give #black z-index + its own
  layer AND hide the video layers (opacity 0) on video-off.
- FIX no soundtrack on Safari/iOS: Safari unlocks <audio> only when play() runs
  INSIDE the user gesture; the start gesture now primes both A/B elements
  synchronously, so the async crossfades are allowed to play.
- Toggles wired on 'change' (matches the Dev Mode switch).
- player/audio.py AUDIO_SOURCES -> {off, soundtrack}; tests + E2E updated.
- Verified in headless Chromium AND WebKit: audio plays, video-off blanks both
  layers, zero JS errors. 288 tests + 3 Playwright E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:56:07 -07:00
BenStullsBets d446238b79 add sessions/0020/SESSION-0020.0-TRANSCRIPT-2026-06-26T06-47--2026-06-26T07-58.md + replace placeholder/variant SESSION-0020.0-TRANSCRIPT-2026-06-26T06-47--INPROGRESS.md 2026-06-26 08:06:49 -07:00
BenStullsBets c51fdc422a update sessions/0020/SESSION-0020.0-TRANSCRIPT-2026-06-26T06-47--INPROGRESS.md 2026-06-26 08:06:25 -07:00
BenStullsBets aa76cd1c9f Merge session-0020: Audio + separated Visual/Audio controls
Splits bundled Content into orthogonal Visual×Audio; per-altitude soundtracks +
synthesized pink white-noise; server-resolved render.audio; client A/B crossfade
layer; control-surface spec reframed. 288 tests + 3 Playwright E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:02:45 -07:00
BenStullsBets c07067492b test(audio): Playwright E2E (3 tests, skip-if-no-browser) + client robustness
E2E asserts in a real headless Chromium: white_noise loads the noise asset,
soundtrack loads the current scale's asset, and Visual=off fades video while
audio keeps playing. Driving it out surfaced + fixed three client issues:
- fadeVolume uses setInterval (rAF stalls when the tab is backgrounded)
- start gesture drops srcless priming (a click grants Chrome autoplay
  document-wide; priming empty <audio> only hung the handler / polluted state)
- applyAudio doesn't await play() (awaiting a srcless/buffering play can hang)
pyproject: [e2e] optional dep (pytest-playwright). Full suite 288 passed, 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:00:44 -07:00
BenStullsBets fd0aad6610 docs(audio): reframe control-surface spec content->visual+audio; record §12 decisions + roadmap
- networked-control-surface §3/§4/§5/§6: Content -> Visual + Audio; transitions.content
  -> transitions.visual; render.content -> render.video + render.audio; SSE notes
- audio spec §12: resolved (audio=live ~0.6s; noise=pink; tap-to-start overlay);
  status -> in implementation
- ROADMAP: session-0020 audio increment; music layer deferred

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:50:34 -07:00
BenStullsBets 68f9d7342a feat(audio): client — split Content into Visual+Audio controls + audio crossfade layer
- index.html: Content <select> -> Visual toggle + Audio source selector; A/B <audio>
- app.js: controls() emits visual+audio; update() posts altitude_index and reads
  render.video/render.audio; A/B gain-crossfade applyAudio(); one-time tap-to-start
  gesture (autoplay policy); mediaUrl() passes absolute /media urls through
- style.css: #start-gesture overlay
- smoke: page serves controls + audio elements; soundtrack url couples to altitude;
  noise + soundtrack media serve 200

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:48:03 -07:00
BenStullsBets 29c6d822bd feat(audio): Visual×Audio Python contract — resolver, controls, state, API
- player/audio.py: pure resolve_visual / resolve_audio_url / resolve_audio (§4/§6)
- retire player/content.py (7-way table) + its test
- player/controls.py: serial contract content -> visual + audio
- player/state.py: Playback carries video:bool + audio_source:str
- simulator/app.py: /api/alteration validates visual+audio, returns render.video
  + server-resolved render.audio.url from altitude_index; NOISE_URL constant
- tests reframed; full suite 285 passed, 2 skipped

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:46:22 -07:00
BenStullsBets 1f9b2a0bec feat(audio): Scale.audio field + per-scale soundtrack wiring through the manifest
Scale gains audio:str='' (back-compat). clips.py reads/serializes it; ring_to_dict
exposes it; build_pool_manifest emits SCALE_AUDIO per scale. Manifest regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:42:40 -07:00
BenStullsBets 39fb9108db feat(audio): ffmpeg production pass — loop/loudnorm builders, runner, build script
Pure arg builders (audio_ops) + thin runner (audio_run) + build_audio_media.py.
Produces the 5 normalized soundtrack loops + a synthesized pink-noise bed under
the gitignored sample_media/audio tree. 6 tests pass (white-noise integration
runs against bundled imageio_ffmpeg).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:41:34 -07:00
BenStullsBets 062a283de4 docs(plan): audio+video separated Visual/Audio controls implementation plan
Session 0020. Anchored on the audio-video-separated-controls Solution Design.
Scope: build into the current single-page simulator (simulator-first directive);
reframe the unbuilt control-surface spec on paper. 10 tasks, all testing tiers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:01:06 -07:00
BenStullsBets adc39fe648 claim human-experience-filter-art session 0020 (placeholder) + sessions.json entry 2026-06-26 06:48:09 -07:00
BenStullsBets 99a751cb46 Merge origin/main (session 0018/0019 transcripts) into audio-controls merge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:39:28 -07:00
BenStullsBets 6f2d1c4f7b Merge feat/audio-video-controls: audio soundtracks + separated audio/visual controls spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:38:25 -07:00
BenStullsBets a3ec09af7e feat(audio): per-altitude soundtracks + separated audio/visual controls spec
Sound for the experience, plus the design to control it.

Soundtrack layer (ambience), all license-clean (PD/CC0), recorded in
docs/audio-candidate-pool.md (media gitignored; re-sourceable):
- cosmos: Pillars of Creation sonification (NASA, PD)
- orbit:  station room-tone hum (CC0)
- coast:  sea waves + terns (CC0)
- reef:   6-layer NOAA soundscape + CC0 submerged bed (PD + CC0)
- abyss:  blue-whale moan (NOAA, PD)

Solution Design (proposed): split the bundled Content selector into two
orthogonal dials, Visual (on/off) x Audio (off/soundtrack/white-noise),
unlocking white-noise+video. Soundtrack follows the single Altitude dial;
white noise is altitude-independent. Reframes the networked-control-surface
spec SS3/SS5 (still proposed/unbuilt -> clean pre-impl edit). Music layer deferred.

Dev panel: links to the clip + audio review galleries; sticky stage/panel layout.
gitignore: audio media, review_audio.html, .DS_Store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:37:56 -07:00
BenStullsBets e0b29c032c add sessions/0018/SESSION-0018.0-TRANSCRIPT-2026-06-25T09-42--2026-06-26T05-56.md + replace placeholder/variant SESSION-0018.0-TRANSCRIPT-2026-06-25T09-42--INPROGRESS.md 2026-06-26 05:57:52 -07:00
BenStullsBets 1b96e7065b add sessions/0019/SESSION-0019.0-TRANSCRIPT-2026-06-26T04-47--2026-06-26T05-47.md + replace placeholder/variant SESSION-0019.0-TRANSCRIPT-2026-06-26T04-47--INPROGRESS.md 2026-06-26 05:48:00 -07:00
BenStullsBets f28a06cc7f Merge feat/networked-control-surface: networked control surface spec (session 0019) 2026-06-26 05:46:25 -07:00
benstull 229af60f1f Merge pull request 'feat(content): add cosmos_miri pool member (Carina mid-infrared pan)' (#25) from feat/cosmos-miri-pool-member into main
feat(content): add cosmos_miri pool member (Carina mid-infrared pan) (#25)
2026-06-26 12:39:34 +00:00
BenStullsBets c389e2a834 docs(spec): networked control surface (renderer/controller split) — session 0019
v1 hardware step: split the simulator into a laptop RENDERER and a separate
touch CONTROLLER over wifi/LAN. Server-authoritative SessionState + SSE push;
one /api/control contract any "control source" speaks (iPad web twin now,
Arduino later). Live knobs (Left/Right/Mood) vs transitional controls
(Altitude, Content) with per-knob transition state + renderer settled-ack.

Reframes ROADMAP sub-project 4 and absorbs sub-project 3's serial-input slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:39:12 -07:00
BenStullsBets 32e74ca446 fix(sim): make sim-local use the venv interpreter
`make sim-local` called bare `python`, which isn't on this box's PATH (only
python3 / .venv/bin/python exist) — so it failed instantly and "couldn't connect"
to the server. Resolve PYTHON to the venv interpreter, falling back to
python3 then python.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:28:02 -07:00
BenStullsBets e3a9b7cff6 feat(content): add cosmos_miri pool member (Carina mid-infrared pan)
Operator wanted more Cosmic Cliffs footage and a different-color rendering.
Keeps the NIRCam flythrough as the cosmos primary and adds ESA/Webb weic2205c
("Pan of the Carina Nebula, combined NIRCam + MIRI") as a new cosmos pool
member — the same Carina towers in the muted gray/teal/gold MIRI mid-infrared
palette, distinct from the iconic orange/blue flythrough.

- POOLS["cosmos"] grows 4 -> 5 (cosmos, cosmos_miri, galaxies, hudf, xdf);
  primary unchanged.
- META + LABELS authored (nebula/star/distance, salience-gated); affect inherits
  the cosmos scale vocabulary.
- manifest.json regenerated (19 -> 20 clips).
- Source: 4K master, text-free/human-free, trim 4-26s (clears the fade-from-
  black), 1 s crossfade-loop (~21 s) -> 1080p base + 4K master (gitignored).
- docs/content-candidate-pool.md updated (cosmos pool of 5 + sourcing note).

271 passed, 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:07:13 -07:00
BenStullsBets 7a2f37af7e claim human-experience-filter-art session 0019 (placeholder) + sessions.json entry 2026-06-26 04:47:47 -07:00
benstull d0ad9a9ac9 Merge pull request 'feat(sim): emotions are a right-brain output (Right knob alone)' (#24) from feat/affect-right-brain-only into main 2026-06-26 11:29:35 +00:00
BenStullsBets c3aa419c53 feat(sim): emotions are a right-brain output (Right knob alone)
The affect channel previously gated on BOTH knobs (strength = min(left, right)),
so the analytical Left brain co-controlled the emotion words. Per operator
direction, emotions are now driven by the Right (dreamlike) knob ALONE — Left no
longer gates them. This sharpens the split: Left = measurement (detections +
measurements), Right = the dream AND the feeling it evokes.

- player/alteration.py: `_affect_strength(right) = right` (was min(left,right));
  `_affect_intensity` keyed off Right. AffectOverlay + is_identity docs updated
  (is_identity still holds: right>0 ⇒ dream.strength>0).
- simulator/static/app.js: renderAffect comments (gating is Right via `strength`);
  the client logic already keyed min_level/tier off the passed strength + right.
- Affect design spec: revision note + every min(left,right) → right.
- Tests rewritten: affect is Right-driven (full Right + Left 0 surfaces affect;
  Right 0 + Left 4 surfaces none).

271 passed, 2 skipped. Live API verified: left=0/right=4 → strength 4; left=4/
right=0 → strength 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:29:21 -07:00
benstull 5bb762ae53 Merge pull request 'fix(sim): content-hash media URLs + fade pool-landing clip swap' (#23) from fix/media-versions-and-pool-landing into main 2026-06-25 20:04:15 +00:00
BenStullsBets 2eb752b5bc fix(sim): content-hash media URLs + fade pool-landing clip swap
Two fixes to the simulator's media handling, both surfaced by the cosmos clip swap.

1. Permanent cache-bust via content hash. New `GET /api/media-versions` returns a
   short sha1 token per served file (clip bases + ring transitions + reverses),
   cached by (mtime, size) so a re-bake is picked up without a restart. The client
   fetches it at boot and appends `?v=<hash>` to every /media URL, so a clip
   re-baked under the same path (e.g. a re-sourced cosmos base) gets a fresh URL
   the browser can't serve stale — fixing the recurring stale-clip problem at the
   root (supersedes the `cache: "reload"` belt-and-suspenders, which stays).

2. Fade the pool-landing swap. The baked ring transition zooms into the scale
   PRIMARY, but the rotating pool picks a random member on landing — so a
   non-primary pick hard-cut from the primary to the chosen clip (e.g. coast
   birdrock -> surfgrass, the artifact the operator saw). On landing, when the pick
   differs from the primary, mask the swap behind a short fade through the existing
   #black overlay (CSS opacity transition); same-clip landings still do a plain
   (re)load. General across all pooled scales.

271 passed, 2 skipped (+2: media-version content-hash + absent-file omission).
app.js syntax-checked; endpoint verified live (29 files; token matches a manual
sha1 of the bytes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:03:56 -07:00
benstull aa56dfe145 Merge pull request 'fix(sim): bust HTTP cache on media preload so re-baked clips show' (#22) from fix/preload-cache-bust into main 2026-06-25 19:40:50 +00:00
419 changed files with 360337 additions and 495 deletions
+7
View File
@@ -0,0 +1,7 @@
# Graduated, used experience media is versioned via git-LFS (large binaries).
# Only the media the manifest actually references is tracked here — the curated
# clip bases, the per-scale audio, and the baked altitude morphs. Stale/unused
# media (e.g. the parked right_variants) is NOT tracked and stays gitignored.
simulator/sample_media/**/base.mp4 filter=lfs diff=lfs merge=lfs -text
simulator/sample_media/transitions/*.mp4 filter=lfs diff=lfs merge=lfs -text
simulator/sample_media/**/*.mp3 filter=lfs diff=lfs merge=lfs -text
+19 -3
View File
@@ -1,12 +1,28 @@
__pycache__/
*.pyc
.DS_Store
.pytest_cache/
.venv/
media/
.superpowers/
*.egg-info/
# Simulator sample media (look-tuning only; populate via setup_sample_media.py
# / build_pool_manifest.py). All scale + transition binaries are gitignored.
# Simulator media. Throwaway / look-tuning binaries stay out of git; the media
# that has GRADUATED into the actual experience the curated clip bases, the
# per-scale audio, and the baked altitude morphs the manifest references — is
# committed via git-LFS (see .gitattributes), negated back in below. Stale/unused
# media (e.g. the parked right_variants) stays ignored.
simulator/sample_media/**/*.mp4
# Local-only candidate review gallery (re-buildable; not shipped content).
simulator/sample_media/**/*.wav
simulator/sample_media/**/*.mp3
simulator/sample_media/**/*.ogg
simulator/sample_media/**/*.m4a
# ...EXCEPT the graduated, used experience media (versioned via git-LFS):
!simulator/sample_media/**/base.mp4
!simulator/sample_media/transitions/*.mp4
!simulator/sample_media/**/*.mp3
# Local-only candidate review galleries (re-buildable; not shipped content).
simulator/static/review.html
simulator/static/review_audio.html
# Editor annotation/comment-thread metadata (not project content)
.threads/
+5 -1
View File
@@ -1,7 +1,11 @@
.PHONY: sim sim-local
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
# fall back to python3, then python.
PYTHON ?= $(firstword $(wildcard .venv/bin/python) python3 python)
sim:
docker compose -f simulator/docker-compose.yml up --build
sim-local:
python -m uvicorn simulator.app:app --reload --port 8000
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
+16 -1
View File
@@ -196,8 +196,23 @@ every transition, so fast navigation stays responsive on placeholder media.
browser. Real strict-PD bases for all five scales sourced per
[`docs/content-sourcing.md`](./content-sourcing.md).
- **Audio + separated Visual/Audio controls (session 0020) ✅ Done.** The
simulator now has **sound**: per-altitude soundtracks that follow the Altitude
dial + a synthesized pink **white-noise** bed, behind two orthogonal controls —
**Visual** (on/off) × **Audio** (off / soundtrack / white_noise) — replacing
the bundled 7-way Content dial. `tools/pipeline/audio_ops` + `audio_run` +
`simulator/build_audio_media.py` (ffmpeg loop/loudnorm/noise, gitignored
assets), `Scale.audio` in the manifest, a pure `player/audio.py` resolver
(retiring `player/content.py`), server-resolved `render.audio.url`, and a client
A/B `<audio>` crossfade layer. This is the "Content step" of the future control
surface (it reframes the proposed networked-control-surface spec §3/§5). Spec:
[`2026-06-26-audio-video-separated-controls-design.md`](./superpowers/specs/2026-06-26-audio-video-separated-controls-design.md);
plan:
[`2026-06-26-audio-video-separated-controls.md`](./superpowers/plans/2026-06-26-audio-video-separated-controls.md).
- **Still deferred:** real **i2v ring transitions** (need a generative-video
model + adjacent real bases); Pi renderer + serial/firmware.
model + adjacent real bases); Pi renderer + serial/firmware; the **music**
audio layer (reserved dial position, no assets yet).
- **Catalog model changes** — audio *source* + "neutral base" vs "altered
variant" flag (sub-project 2 territory, design §13).
+93
View File
@@ -0,0 +1,93 @@
# Audio candidate pool — rotating pools per altitude (2026-06-26)
Candidate soundtrack/ambience for the **"audio + video" setting**. Following operator
feedback, each altitude now has a **rotating pool** (mirrors the video `Scale.pool`
model — random pick per landing, or pairable per-clip).
## Two audio layers per altitude (operator, 2026-06-26)
The "audio + video" setting layers **two** kinds of audio per altitude:
1. **Ambience / audio track** — the soundscape (waves, reef crackle, chorus, etc.).
**This is what we're sourcing now.**
2. **Music track** — a separate composed musical layer. **Deferred** until the
ambience tracks are settled across all altitudes.
Pools below are the **ambience** layer unless marked `music`. **Distinction (operator):**
the ambience/soundtrack layer is an *environmental, real-world soundscape* (waves, reef,
whale, room-tone hum) — **not composed music**. A musical pad/track, however nice, belongs
in the music layer, even for "atmospheric" altitudes like cosmos/orbit.
**Media is gitignored** (`simulator/sample_media/**/*.{wav,mp3,…}`); this file is the
re-sourceable record. Files: `simulator/sample_media/audio/<altitude>/<name>.<ext>`,
served at `/media/audio/<altitude>/<name>.<ext>`. Review gallery (gitignored):
`simulator/static/review_audio.html``/review_audio.html` with the sim running.
## Round-2 feedback applied (operator, session 2026-06-26)
- **abyss** ✓ kept (blue-whale moan) · **coast** ✓ kept (waves + terns)
- **cosmos** ✗ plasma static "hurt my ears" → replaced with 3 NASA **sonifications**
- **orbit** ✗ whistlers "sound like lasers" → replaced with gentle Earth **chorus** (+ wind)
- **reef** ✗ "shouldn't have whales (too deep for a reef)" → whale removed; now crackle + diver
- Operator: *"try all three… rotation of audio like the video, or per-video audio track."*
## Design arc
Descending the dial is one journey: **cosmos & abyss** = vast/dark/sparse siblings;
**coast** = warm human middle; **orbit & reef** = transitional membranes. The science
recordings (NASA/NOAA) keep the "machines reshaping perception" thesis literal.
## Pools
| altitude | id | clip | source | dur | license |
|---|---|---|---|---|---|
| 🌌 cosmos | `cosmos/pillars` | **✓ KEEP** — Pillars of Creation (M16), twinkly sweep | NASA/CXC/SAO — https://chandra.si.edu/sound/m16.html (`sounds/m16_all.mp4`) | 30s | PD (NASA, credit) |
| 🌌 cosmos | `cosmos/music` | 🎵 **music track — TBD** (deferred). Black-hole drone DROPPED (too creepy, not awe-inspiring); Galactic Center dropped earlier | — | — | — |
| 🛰 orbit | `orbit/spaceamb` | **✓ KEEP** — station room-tone hum ("inside the orbiting craft") | Sonicfreak — https://freesound.org/s/174450/ (`cdn.freesound.org/previews/174/174450_746632-hq.mp3`) | 2:31 | CC0 |
| 🛰 (music) | _deferred_ | 🎵 "Frozen Star" warm pad — moved to MUSIC layer (it's music, not ambience) | Kevin MacLeod / incompetech — https://incompetech.com/music/royalty-free/mp3-royaltyfree/Frozen%20Star.mp3 | 3:41 | CC-BY |
| 🐠 reef | `reef/soundscape` | **✓** richer reef (round 4) — 6-layer bake: submerged underwater bed (bubbles/sloosh) + shrimp crackle + fish chorus + toadfish + grouper + damselfish | NOAA SanctSound (FK+GR `*_snappingshrimp_*`, `FK02_01_fishchorus`, `GR01_01_toadfish`, `FK03_01_redgrouper`, `PM05_01_damselfish`) + DCSFX "Underwater [Loop] AMB" https://freesound.org/s/366159/ (`cdn.freesound.org/previews/366/366159_6725579-hq.mp3`); loop-to-60 + amix + dynaudnorm | 60s | PD (NOAA) + CC0 (underwater) |
| 🏖 coast | `coast/waves` | Sea waves + tern calls | BigSoundBank #0267 — https://bigsoundbank.com/sea-waves-and-seagulls-s0267.html (`/UPLOAD/mp3/0267.mp3`) | 57s | CC0 |
| 🕳 abyss | `abyss/whale` | NE Pacific blue-whale "AB call" | NOAA PMEL — https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav | 13s | PD (NOAA) |
## Sourcing rule (learned 2026-06-26)
**Source only from a browsable page the operator can open and whose license is stated.**
The Mixkit clips were pulled by raw CDN asset id (`assets.mixkit.co/.../<id>-preview.mp3`)
with no findable catalog page — operator couldn't see them, so they were removed. Good
hubs: NASA SVS / Chandra, NOAA PMEL/SanctSound (PD), BigSoundBank (CC0), Pixabay
(visible license). Avoid asset-id grabs and ND/NC licenses.
## Open items / next pass
- **orbit** — ✅ SETTLED: `orbit/spaceamb` (Sonicfreak station room-tone hum, CC0). Rejected
en route: laser whistlers, Earth chorus, plain wind, "Frozen Star" pad (music). A real NASA
ISS recording (Hadfield "Space Station Noise", SoundCloud) remains the authentic upgrade if
ever wanted — needs a harder fetch (no yt-dlp; brew install denied).
- **ambience layer COMPLETE** — all 5 picked: cosmos/pillars · orbit/spaceamb · coast/waves ·
reef/soundscape · abyss/whale. Next is the production pass + wiring (below) and the music layer.
- **reef** — ✅ round-4 6-layer bake (`reef/soundscape`): NOAA PD biological layers + a CC0
submerged underwater "you're under" bed (DCSFX freesound #366159, hq preview). License clean.
SanctSound clips: `files/SanctSound_*.mp4` at `https://sanctsound.ioos.us/files/` (extract audio).
- **music layer** — separate composed music track per altitude, deferred (cosmos slot reserved).
- **Production (after picks):** loop seams, length normalization, cross-altitude crossfade
(mirror video); cosmos/abyss clips are short → loop.
## Re-download / re-bake
```
A=simulator/sample_media/audio; FF=$(python -c "import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())")
mkdir -p $A/cosmos $A/orbit $A/reef $A/coast $A/abyss
# NASA sonifications (mp4 -> mp3)
for u in perseus_sonification:blackhole m16_all:pillars galactic_all:galaxy; do
f=${u%%:*}; o=${u##*:}; curl -fsSL "https://chandra.si.edu/sound/sounds/$f.mp4" -o /tmp/$o.mp4
"$FF" -y -i /tmp/$o.mp4 -vn -q:a 4 $A/cosmos/$o.mp3; done
curl -fsSL "https://svs.gsfc.nasa.gov/vis/a010000/a011000/a011073/Earthsong-540-MASTER_high.mp4" -o /tmp/es.mp4
"$FF" -y -i /tmp/es.mp4 -vn -q:a 4 $A/orbit/earthsong.mp3
curl -fsSL "https://assets.mixkit.co/active_storage/sfx/1162/1162-preview.mp3" -o $A/orbit/wind.mp3
curl -fsSL "https://www.nhm.ac.uk/content/dam/nhm-www/discover/audio/sound-of-coral-reef.mp3" -o $A/reef/crackle.mp3
curl -fsSL "https://assets.mixkit.co/active_storage/sfx/1242/1242-preview.mp3" -o $A/reef/scuba.mp3
curl -fsSL "https://bigsoundbank.com/UPLOAD/mp3/0267.mp3" -o $A/coast/waves.mp3
curl -fsSL "https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav" -o $A/abyss/whale.wav
# layered reef
"$FF" -y -i $A/reef/crackle.mp3 -i $A/reef/scuba.mp3 \
-filter_complex "[1:a]volume=0.55[s];[0:a][s]amix=inputs=2:duration=first,dynaudnorm" -q:a 4 $A/reef/layered.mp3
```
+13 -1
View File
@@ -23,11 +23,12 @@ a credit line** (our overlays + dream are derivatives, which these allow);
**CCBYND is NOT usable** (no derivatives); **CCBYNC only if the installation
stays noncommercial**.
## 🌌 cosmos (pool of 4)
## 🌌 cosmos (pool of 5)
| id | clip | source | window (s) | license / credit |
|----|------|--------|-----------|------------------|
| `cosmos` | Cosmic Cliffs — Carina Nebula flythrough | NASA SVS 31348 — https://svs.gsfc.nasa.gov/31348/ (`Clifs-3d-STScI.mp4`, *Exploring the Cosmic Cliffs in 3D*, Webb NIRCam, **4K**) | 1034 | CCBYclass — credit **NASA, ESA, CSA, STScI** |
| `cosmos_miri` | Cosmic Cliffs — Carina Nebula, **mid-infrared** (same towers, muted gray/teal/gold dust palette) | ESA/Webb weic2205c — https://esawebb.org/videos/weic2205c/ (*Pan of the Carina Nebula, combined NIRCam + MIRI*, **4K** `weic2205c.mp4`) | 426 | CCBYclass — credit **NASA, ESA, CSA, STScI** |
> ️ **Cosmos primary swapped to the Webb "Cosmic Cliffs" flythrough (2026-06-25, session 0018).**
> History: the original primary was NASA/JPL's *"Orion: Dust and Death"* (captions
@@ -43,6 +44,17 @@ stays noncommercial**.
| `cosmos_hudf` | Hubble Ultra Deep Field zoom | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030687/hudf-b-1920x1080p30.mov | 2044 | CCBYclass — credit **NASA, ESA, STScI** |
| `cosmos_xdf` | eXtreme Deep Field flythrough | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030681/hxdf_fly-b-1920x1080p30.mov | 226 | CCBYclass — credit **NASA, ESA, STScI** |
> **`cosmos_miri` added (2026-06-26).** Operator wanted more *Cosmic Cliffs*
> footage and a different-color rendering. The keep-the-flythrough-primary +
> add-the-mid-infrared-pan pair: ESA/Webb's **weic2205c** is the *same* Carina
> towers shot with NIRCam **+ MIRI**, so it renders in a muted gray/teal/gold dust
> palette instead of the iconic orange/blue — a distinct look from the same scene.
> Sourced text-free/human-free from the **4K** master, trim 426s (clears the
> opening fade-from-black, ends before any tail fade) → 1080p base + 4K master,
> 1 s crossfade-loop (~21 s). ESA/Webb's narration-free *"Diving Into the Cosmic
> Cliffs"* pan (`carina_revisit_pan`, NIRCam palette) was the runner-up — left
> out for now since it repeats the existing palette; easy to add if wanted.
> ⚠️ **DISCREPANCY to fix next session.** Operator selected cosmos **#1, #3
> (Orion), #4, #5**. During processing, **#6 Galaxy Traverse was run by mistake
> instead of #3 Orion**. The cosmos primary now lives at
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,939 @@
# Altitude Clip-Pair Morph + Lock Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When the viewer changes altitude, choose the destination clip *first*, play the morph footage that goes from the currently-shown clip to that chosen clip, then lock the clip in place so it never changes again until the altitude changes.
**Architecture:** Today the ring carries **5** per-edge morphs (each `scale_a → scale_b`, primary→primary), the server picks a random pool member only for the *landed* scale, and `advance()` plays the edge morph (which ends on the scale's *primary*) then does a *second*, jarring `fadeThroughBlack` swap to the randomly-chosen member. We replace the per-edge model with a **per-clip-pair** model: every pair of clips in adjacent altitudes gets its own morph, in both directions (**154** clips total). The server picks the destination member(s) *before* selecting the morph, threads the currently-shown clip in as the source, and chains morphs through each crossed altitude on a multi-detent jump. The renderer plays those exact morphs and locks on the final clip — no secondary swap, no re-roll while parked.
**Tech Stack:** Python 3.13 / FastAPI (`simulator/app.py`), pure ring logic (`player/ring.py`), pure manifest/schema (`simulator/clips.py`), media baking via ffmpeg `xfade=zoomin` (`simulator/build_pool_manifest.py`), vanilla JS renderer (`simulator/static/app.js`), pytest, and a new Playwright E2E tier.
## Global Constraints
- **Morph file naming:** `transitions/<src_clip_id>__<dst_clip_id>.mp4` (double-underscore separator — clip ids themselves use single `_`, e.g. `cosmos_miri`). The zoom-out companion is `<src>__<dst>.rev.mp4` and represents the **reverse** direction (`dst → src`).
- **Direction convention:** a morph is *baked* descending (higher altitude → lower altitude = zoom IN). The `.rev.mp4` companion is the ascending (zoom OUT) playback. The manifest lists **both** directions as explicit, separately-keyed entries so the renderer needs no reverse logic at play time.
- **Ring order (descending, wraps):** `["cosmos", "orbit", "coast", "reef", "abyss"]`; `abyss → cosmos` is the wrap edge.
- **Pool sizes (current):** cosmos 5, orbit 3, coast 4, reef 5, abyss 3 → **77** adjacent ordered-pair morphs descending + **77** reverse = **154** total transition clips.
- **Lock invariant:** the displayed base clip changes **only** inside `advance()` (a deliberate altitude change) and the one initial `landScale()`. Nothing else may reload base media. The Dev-Mode re-roll button stays as an explicit dev affordance.
- **Randomness stays injected:** `player/ring.py` is a pure module; the impure `random.random()` draw happens once at the API boundary (`simulator/app.py`). Pure functions take injected `float` picks in `[0,1)`.
- **Back-compat:** a manifest with no `pool` (pool of one) and the synthesized single-clip fallback ring must still work. A pool-of-one scale needs no morphs to itself.
---
### Task 1: Manifest emits clip-pair transition entries
Re-key the manifest builder from 5 per-edge entries to 154 per-clip-pair entries (both directions), each carrying `from`/`to` clip ids. Pure dict assembly — no ffmpeg.
**Files:**
- Modify: `simulator/build_pool_manifest.py:374-379` (`build_manifest()` transitions block)
- Test: `tests/test_pipeline_manifest.py`
**Interfaces:**
- Consumes: module constants `RING_ORDER: list[str]`, `POOLS: dict[str, list[str]]`.
- Produces: each transition dict shaped `{"from": <src_id>, "to": <dst_id>, "file": "transitions/<src>__<dst>.mp4", "model": "placeholder-zoom"}`. Forward entries are descending (`H→L`, `transitions/H__L.mp4`); reverse entries are ascending (`L→H`, `transitions/H__L.rev.mp4`).
- [ ] **Step 1: Write the failing test**
```python
# tests/test_pipeline_manifest.py (add)
def test_transitions_are_per_clip_pair_both_directions():
import simulator.build_pool_manifest as b
m = b.build_manifest()
trans = m["ring"]["transitions"]
order = b.RING_ORDER
n = len(order)
# Every adjacent (higher H, lower L) edge, every member pair, BOTH directions.
expected = set()
for i in range(n):
H, L = order[i], order[(i + 1) % n]
for h in b.POOLS[H]:
for l in b.POOLS[L]:
expected.add((h, l, f"transitions/{h}__{l}.mp4")) # zoom in
expected.add((l, h, f"transitions/{h}__{l}.rev.mp4")) # zoom out
got = {(t["from"], t["to"], t["file"]) for t in trans}
assert got == expected
assert len(trans) == len(expected) == 154
assert all(t["model"] == "placeholder-zoom" for t in trans)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v`
Expected: FAIL — current builder emits 5 `scale-scale` entries with no `from`/`to`.
- [ ] **Step 3: Implement the per-clip-pair transitions block**
```python
# simulator/build_pool_manifest.py — replace the transitions loop in build_manifest()
transitions = []
n = len(RING_ORDER)
for i in range(n):
H, L = RING_ORDER[i], RING_ORDER[(i + 1) % n] # H higher altitude, L lower
for h in POOLS[H]:
for l in POOLS[L]:
fwd = f"transitions/{h}__{l}.mp4" # zoom IN (descend H->L)
transitions.append({"from": h, "to": l, "file": fwd,
"model": "placeholder-zoom"})
transitions.append({"from": l, "to": h, "file": f"transitions/{h}__{l}.rev.mp4",
"model": "placeholder-zoom"}) # zoom OUT (ascend L->H)
return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py
git commit -m "feat(pipeline): manifest emits per-clip-pair morph transitions (both directions)"
```
---
### Task 2: Bake the member-pair morph clips
Generalize the ffmpeg morph recipe from scale-primary→scale-primary to any member→member, and have `generate_media()` bake every adjacent member pair forward + reverse. Tested with a stubbed ffmpeg runner so the test is fast and deterministic (the real bake runs as a media step at execution time).
**Files:**
- Modify: `simulator/build_pool_manifest.py:391-431` (`_make_transition`, `generate_media`)
- Test: `tests/test_pipeline_manifest.py`
**Interfaces:**
- Consumes: `RING_ORDER`, `POOLS`, `MEDIA` (Path), member base footage at `MEDIA/<clip_id>/base.mp4`.
- Produces: `_make_transition(ff, src_clip, dst_clip) -> Path` writing `transitions/<src>__<dst>.mp4` from the two members' `base.mp4`; `_make_reverse(ff, forward)` unchanged (writes `.rev.mp4`). `generate_media()` loops every adjacent ordered member pair.
- [ ] **Step 1: Write the failing test (stubbed ffmpeg)**
```python
# tests/test_pipeline_manifest.py (add)
def test_generate_media_bakes_every_member_pair(monkeypatch, tmp_path):
import simulator.build_pool_manifest as b
calls = []
def fake_run(args, **kw):
# record the output path (last positional arg of each ffmpeg call)
calls.append(args[-1])
from pathlib import Path
Path(args[-1]).parent.mkdir(parents=True, exist_ok=True)
Path(args[-1]).write_bytes(b"x")
class R: returncode = 0
return R()
monkeypatch.setattr(b, "MEDIA", tmp_path)
monkeypatch.setattr(b.subprocess, "run", fake_run)
monkeypatch.setattr(b, "_ffmpeg", lambda: "ffmpeg")
b.generate_media()
outs = {str(p).split("transitions/")[-1] for p in calls}
# one forward + one reverse per adjacent member pair
fwd = {f"{h}__{l}.mp4" for H, L in b._adjacent_edges()
for h in b.POOLS[H] for l in b.POOLS[L]}
rev = {f"{h}__{l}.rev.mp4" for H, L in b._adjacent_edges()
for h in b.POOLS[H] for l in b.POOLS[L]}
assert fwd <= outs and rev <= outs
assert len(fwd) + len(rev) == 154
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v`
Expected: FAIL — `_adjacent_edges` undefined and `generate_media` loops scales, not members.
- [ ] **Step 3: Implement member-pair baking**
```python
# simulator/build_pool_manifest.py
def _adjacent_edges() -> list[tuple[str, str]]:
"""Ordered (higher, lower) altitude edges around the ring, including the wrap."""
n = len(RING_ORDER)
return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)]
def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
"""A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked
orbit-coast recipe), keyed by clip id so every adjacent member pair gets its
own morph. Forward = zoom IN (descend src->dst)."""
a = MEDIA / src_clip / "base.mp4"
b = MEDIA / dst_clip / "base.mp4"
out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4"
out.parent.mkdir(parents=True, exist_ok=True)
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
subprocess.run([
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
f"[0:v]{norm}[a];[1:v]{norm}[b];"
"[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
"-map", "[v]", "-an", str(out),
], check=True, capture_output=True)
return out
def generate_media() -> None:
"""Bake EVERY adjacent member-pair morph from real bases — forward (zoom in,
descend) plus its reversed companion (zoom out, ascend)."""
ff = _ffmpeg()
for H, L in _adjacent_edges():
for h in POOLS[H]:
for l in POOLS[L]:
fwd = _make_transition(ff, h, l)
_make_reverse(ff, fwd)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py
git commit -m "feat(pipeline): bake member-pair zoom morphs for every adjacent video combination"
```
---
### Task 3: Clip-pair transition schema + morph lookup
Change the `Transition` dataclass and ring loader to carry `from_clip`/`to_clip`, build a `(from, to) → file` lookup on the ring, and update the structural invariant. Touches both `player/ring.py` (dataclass + ring) and `simulator/clips.py` (parse + serialize).
**Files:**
- Modify: `player/ring.py:75-113` (`Transition`, `ScaleRing.__post_init__`, add `morph_for`)
- Modify: `simulator/clips.py:88-131` (`load_ring` transition parse, `ring_to_dict` transitions block)
- Test: `tests/test_player_ring.py`, `tests/test_clips.py`
**Interfaces:**
- Produces: `Transition(from_clip: str, to_clip: str, file: str, model: str = "")`.
- Produces: `ScaleRing.morph_for(from_clip: str, to_clip: str) -> str | None` — the morph file for that directed pair, or `None` if absent.
- `ScaleRing.__post_init__`: drop the "N edges == N transitions" rule (no longer per-edge); keep "≥1 scale, single-scale ring has no transitions". Build the lookup dict in `__post_init__` (store on a private attr via `object.__setattr__`, since the dataclass is frozen).
- `ring_to_dict` transitions become `[{"from","to","file","model"}, ...]`.
- [ ] **Step 1: Write the failing tests**
```python
# tests/test_player_ring.py (add)
from player.ring import Scale, Transition, ScaleRing
def _two_member_ring():
scales = (Scale(id="cosmos", clip_id="c1", pool=("c1", "c2")),
Scale(id="abyss", clip_id="a1", pool=("a1",)))
# adjacency wraps: cosmos<->abyss; members c1,c2 x a1 -> 2 pairs x2 dirs = 4
trans = (Transition("c1", "a1", "transitions/c1__a1.mp4"),
Transition("a1", "c1", "transitions/c1__a1.rev.mp4"),
Transition("c2", "a1", "transitions/c2__a1.mp4"),
Transition("a1", "c2", "transitions/c2__a1.rev.mp4"))
return ScaleRing(scales=scales, transitions=trans)
def test_morph_for_returns_directed_file():
r = _two_member_ring()
assert r.morph_for("c1", "a1") == "transitions/c1__a1.mp4"
assert r.morph_for("a1", "c2") == "transitions/c2__a1.rev.mp4"
assert r.morph_for("c1", "c2") is None # not an adjacent-altitude pair
```
```python
# tests/test_clips.py (add)
def test_load_ring_parses_clip_pair_transitions(tmp_path):
import json
from simulator.clips import load_ring
m = {"ring": {"scales": [{"id": "x", "clip_id": "x1", "pool": ["x1"]},
{"id": "y", "clip_id": "y1", "pool": ["y1"]}],
"transitions": [{"from": "x1", "to": "y1", "file": "transitions/x1__y1.mp4"},
{"from": "y1", "to": "x1", "file": "transitions/x1__y1.rev.mp4"}]}}
p = tmp_path / "manifest.json"; p.write_text(json.dumps(m))
ring = load_ring(p)
assert ring.morph_for("x1", "y1") == "transitions/x1__y1.mp4"
assert ring.morph_for("y1", "x1") == "transitions/x1__y1.rev.mp4"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_player_ring.py::test_morph_for_returns_directed_file tests/test_clips.py::test_load_ring_parses_clip_pair_transitions -v`
Expected: FAIL — `Transition` has no `from_clip`/`to_clip`; no `morph_for`.
- [ ] **Step 3: Implement the schema + lookup**
```python
# player/ring.py — replace the Transition dataclass
@dataclass(frozen=True)
class Transition:
"""A pre-baked zoom/warp morph between TWO specific clips in adjacent
altitudes. `file` plays as-is (the manifest holds both directions as
separate, directed entries — forward zoom-in and `.rev` zoom-out)."""
from_clip: str
to_clip: str
file: str
model: str = ""
```
```python
# player/ring.py — ScaleRing: relax the invariant, build the lookup
@dataclass(frozen=True)
class ScaleRing:
scales: tuple[Scale, ...]
transitions: tuple[Transition, ...]
def __post_init__(self) -> None:
if len(self.scales) == 0:
raise RingError("a ScaleRing needs at least one scale")
lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions}
object.__setattr__(self, "_morphs", lookup)
def __len__(self) -> int:
return len(self.scales)
def morph_for(self, from_clip: str, to_clip: str) -> str | None:
"""The morph file for the directed clip pair, or None if none baked."""
return self._morphs.get((from_clip, to_clip))
```
```python
# simulator/clips.py — load_ring transition parse (replace lines 101-104)
transitions = tuple(
Transition(
from_clip=t.get("from", ""),
to_clip=t.get("to", ""),
file=t["file"],
model=t.get("model", ""),
)
for t in ring.get("transitions", [])
)
```
```python
# simulator/clips.py — ring_to_dict transitions block (replace line 130)
"transitions": [
{"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model}
for t in ring.transitions
],
```
- [ ] **Step 4: Run the tests + the existing ring/clips suites**
Run: `pytest tests/test_player_ring.py tests/test_clips.py -v`
Expected: PASS — fix any existing test that constructed `Transition(file=..., model=...)` positionally to the new 3-arg form (`Transition(from_clip, to_clip, file)`), and any test asserting the old `N == N` invariant.
- [ ] **Step 5: Commit**
```bash
git add player/ring.py simulator/clips.py tests/test_player_ring.py tests/test_clips.py
git commit -m "feat(ring): clip-pair Transition schema + morph_for lookup"
```
---
### Task 4: Resolve chained member→member morphs (pick before transition)
Add a pure function that, given a structural `RingMove`, the currently-shown clip, and one injected pick per step, walks the steps — choosing a destination member for each crossed altitude and resolving the morph from the *previous* clip to it — and reports the final locked clip. This is the heart of "choose the clip first, then play the matching morph," chained through each altitude.
**Files:**
- Modify: `player/ring.py` (add `MorphStep`, `ResolvedMove`, `resolve_move`)
- Test: `tests/test_player_ring.py`
**Interfaces:**
- Consumes: `RingMove` (from `advance_ring`), `pick_clip_id`, `scale_at`, `ScaleRing.morph_for`.
- Produces:
- `MorphStep(from_clip: str, to_clip: str, file: str | None, to_index: int, blended: bool)`
- `ResolvedMove(steps: tuple[MorphStep, ...], target_clip_id: str)`
- `resolve_move(ring: ScaleRing, move: RingMove, from_clip_id: str, picks: tuple[float, ...]) -> ResolvedMove``len(picks)` must equal `len(move.steps)`; each `picks[k]` chooses the member of the scale that step `k` lands on. `from_clip` of step 0 is `from_clip_id`; each subsequent step's `from_clip` is the prior step's `to_clip`. `target_clip_id` is the last step's `to_clip` (or `from_clip_id` for an empty move).
- [ ] **Step 1: Write the failing tests**
```python
# tests/test_player_ring.py (add)
from player.ring import advance_ring, resolve_move
def _chain_ring():
# 3 scales, pools of 2/1/2; descending edges cosmos->coast->abyss + wrap abyss->cosmos
scales = (Scale("cosmos", "c1", ("c1", "c2")),
Scale("coast", "o1", ("o1",)),
Scale("abyss", "a1", ("a1", "a2")))
t = []
def pair(h, l):
t.append(Transition(h, l, f"transitions/{h}__{l}.mp4"))
t.append(Transition(l, h, f"transitions/{h}__{l}.rev.mp4"))
for h in ("c1", "c2"):
pair(h, "o1") # cosmos<->coast
for h in ("o1",):
pair(h, "a1"); pair(h, "a2") # coast<->abyss
for h in ("a1", "a2"):
pair(h, "c1"); pair(h, "c2") # abyss<->cosmos (wrap)
return ScaleRing(scales=scales, transitions=tuple(t))
def test_resolve_single_step_zoom_in():
r = _chain_ring()
move = advance_ring(r, from_index=0, delta=1) # cosmos -> coast
res = resolve_move(r, move, from_clip_id="c2", picks=(0.0,))
assert len(res.steps) == 1
s = res.steps[0]
assert (s.from_clip, s.to_clip) == ("c2", "o1")
assert s.file == "transitions/c2__o1.mp4"
assert res.target_clip_id == "o1"
def test_resolve_chains_through_intermediate_altitude():
r = _chain_ring()
move = advance_ring(r, from_index=0, delta=2) # cosmos -> coast -> abyss
# pick coast member (only o1) then abyss member: 0.6*2 -> index 1 -> a2
res = resolve_move(r, move, from_clip_id="c1", picks=(0.0, 0.6))
files = [s.file for s in res.steps]
assert files == ["transitions/c1__o1.mp4", "transitions/o1__a2.mp4"]
assert res.target_clip_id == "a2"
def test_resolve_zoom_out_uses_reverse_file():
r = _chain_ring()
move = advance_ring(r, from_index=2, delta=-1) # abyss -> coast (ascend)
res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
assert res.steps[0].file == "transitions/o1__a1.rev.mp4"
assert res.target_clip_id == "o1"
def test_resolve_empty_move_keeps_source():
r = _chain_ring()
move = advance_ring(r, from_index=0, delta=0)
res = resolve_move(r, move, from_clip_id="c2", picks=())
assert res.steps == () and res.target_clip_id == "c2"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_player_ring.py -k resolve -v`
Expected: FAIL — `resolve_move` undefined.
- [ ] **Step 3: Implement `resolve_move`**
```python
# player/ring.py (add near the bottom)
@dataclass(frozen=True)
class MorphStep:
"""One morph to play while advancing: from the clip currently shown to the
chosen clip of the next altitude, the morph file, the index it lands on, and
whether it plays fast (a fast-spin pass)."""
from_clip: str
to_clip: str
file: str | None
to_index: int
blended: bool
@dataclass(frozen=True)
class ResolvedMove:
steps: tuple[MorphStep, ...]
target_clip_id: str
def resolve_move(
ring: ScaleRing,
move: RingMove,
from_clip_id: str,
picks: tuple[float, ...],
) -> ResolvedMove:
"""Choose the destination clip for each crossed altitude (injected `picks`,
one per step) and resolve the morph from the previous clip to it. The morph
is chosen AFTER the destination clip, so the footage matches what we land on.
`file` is None if no morph was baked for a pair (renderer falls back to a
plain cut); `target_clip_id` is the final locked clip."""
if len(picks) != len(move.steps):
raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}")
steps: list[MorphStep] = []
src = from_clip_id
for st, r in zip(move.steps, picks):
dst_scale = scale_at(ring, st.to_index)
dst = pick_clip_id(dst_scale, r)
steps.append(MorphStep(
from_clip=src,
to_clip=dst,
file=ring.morph_for(src, dst),
to_index=st.to_index,
blended=st.blended,
))
src = dst
return ResolvedMove(steps=tuple(steps), target_clip_id=src)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_player_ring.py -k resolve -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add player/ring.py tests/test_player_ring.py
git commit -m "feat(ring): resolve_move picks destination clip then matching morph, chained"
```
---
### Task 5: Fast spin plays the whole chain quickly (no collapse)
The operator chose "chain through each altitude," with fast spins playing that chain *quickly* rather than collapsing to a single arrival pass. Change `advance_ring`'s fast-spin branch to keep every step but mark each `blended=True` (so the renderer plays each morph at `FAST_BLEND_RATE`) and still set `RingMove.fast=True`.
**Files:**
- Modify: `player/ring.py:219-241` (fast-spin branch in `advance_ring`)
- Test: `tests/test_player_ring.py`
**Interfaces:**
- Unchanged signature. Behavior change: a fast spin returns **all** steps (each `blended=True`), not one collapsed step.
- [ ] **Step 1: Write the failing test (and update any existing fast-spin assertions)**
```python
# tests/test_player_ring.py (add)
def test_fast_spin_keeps_all_steps_blended():
r = _chain_ring() # 3-scale ring from Task 4
move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=3)
assert move.fast is True
assert len(move.steps) == 3 # NOT collapsed to 1
assert all(s.blended for s in move.steps)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_player_ring.py::test_fast_spin_keeps_all_steps_blended -v`
Expected: FAIL — current branch collapses to a single `(blended,)` step.
- [ ] **Step 3: Implement the chain-fast branch**
```python
# player/ring.py — replace the fast-spin collapse block (lines ~219-237)
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
fast_steps = tuple(
TransitionStep(edge=s.edge, reversed=s.reversed, file=s.file,
to_index=s.to_index, blended=True)
for s in steps
)
return RingMove(from_index=start, to_index=index, steps=fast_steps,
wrapped=wrapped, fast=True)
```
- [ ] **Step 4: Run the full ring suite (catch the old collapse test)**
Run: `pytest tests/test_player_ring.py -v`
Expected: PASS — update/replace any prior test that asserted fast spin yields exactly one step.
- [ ] **Step 5: Commit**
```bash
git add player/ring.py tests/test_player_ring.py
git commit -m "feat(ring): fast spin plays the full morph chain quickly instead of collapsing"
```
---
### Task 6: API picks before transitioning and serializes the resolved chain
`/api/ring/advance` accepts the currently-shown clip (`from_clip_id`), draws one random pick per step, resolves the chained morphs, and returns the resolved steps + locked `target_clip_id`. Also extend `media-versions` to cover every transition file.
**Files:**
- Modify: `simulator/app.py:119-121` (`RingAdvanceRequest`), `:237-252` (`api_ring_advance`), `:213-229` (`api_media_versions`)
- Modify: `simulator/clips.py:134-160` (`ring_move_to_dict` → resolved-move serializer)
- Test: `tests/test_simulator_api.py`
**Interfaces:**
- `RingAdvanceRequest`: add `from_clip_id: str = ""`.
- New serializer `resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict`:
```json
{"from_index": int, "to_index": int, "wrapped": bool, "fast": bool,
"target_clip_id": str,
"steps": [{"from_clip": str, "to_clip": str, "file": str|null,
"to_index": int, "blended": bool}, ...]}
```
- `media-versions`: iterate `t.file` for every transition (no `.rev` derivation — both directions are explicit entries now).
- [ ] **Step 1: Write the failing test**
```python
# tests/test_simulator_api.py (add)
def test_advance_picks_then_returns_matching_morph(client):
# client fixture serves the sample_media manifest
r = client.post("/api/ring/advance",
json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"})
assert r.status_code == 200
body = r.json()
assert body["to_index"] == 1
target = body["target_clip_id"]
step = body["steps"][-1]
assert step["from_clip"] == "cosmos" # threaded current clip
assert step["to_clip"] == target # morph lands on the locked clip
# file matches the directed pair (zoom-in descend, forward file)
assert step["file"] == f"transitions/cosmos__{target}.mp4"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_simulator_api.py::test_advance_picks_then_returns_matching_morph -v`
Expected: FAIL — response has no `from_clip`/`to_clip`, file is the old per-edge name.
- [ ] **Step 3: Implement request field, endpoint, serializer, media-versions**
```python
# simulator/app.py
class RingAdvanceRequest(BaseModel):
from_index: int = 0
delta: int
from_clip_id: str = ""
```
```python
# simulator/app.py — api_ring_advance
@app.post("/api/ring/advance")
def api_ring_advance(req: RingAdvanceRequest):
if app.state.ring is None:
raise HTTPException(status_code=404, detail="no scale ring in manifest")
move = advance_ring(app.state.ring, req.from_index, req.delta,
fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD)
# Choose the destination member of each crossed altitude FIRST (one random
# draw per step), then resolve the morph from the prior clip to it.
src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
picks = tuple(random.random() for _ in move.steps)
resolved = resolve_move(app.state.ring, move, src, picks)
return resolved_move_to_dict(move, resolved)
```
```python
# simulator/clips.py — replace ring_move_to_dict with resolved_move_to_dict
def resolved_move_to_dict(move, resolved) -> dict:
"""JSON form of a resolved encoder move: where it lands, the locked clip, and
the ordered chained morphs (each from the prior clip to its chosen
destination, with the matching morph file)."""
return {
"from_index": move.from_index,
"to_index": move.to_index,
"wrapped": move.wrapped,
"fast": move.fast,
"target_clip_id": resolved.target_clip_id,
"steps": [
{"from_clip": s.from_clip, "to_clip": s.to_clip, "file": s.file,
"to_index": s.to_index, "blended": s.blended}
for s in resolved.steps
],
}
```
```python
# simulator/app.py — api_media_versions: cover every transition file explicitly
files = {c.base_file for c in app.state.clips}
if app.state.ring is not None:
for t in app.state.ring.transitions:
files.add(t.file)
```
Update imports in `simulator/app.py` (`resolve_move`, `scale_at` already imported; add `resolve_move`) and `simulator/clips.py` (drop `ring_move_to_dict`, add `resolved_move_to_dict`; remove the now-unused `_rev_file` if nothing else uses it — grep first).
- [ ] **Step 4: Run the API suite**
Run: `pytest tests/test_simulator_api.py -v`
Expected: PASS — fix any test referencing the removed `ring_move_to_dict` / old response shape.
- [ ] **Step 5: Commit**
```bash
git add simulator/app.py simulator/clips.py tests/test_simulator_api.py
git commit -m "feat(api): /api/ring/advance picks clip then returns matching chained morphs"
```
---
### Task 7: Renderer plays the chosen morph and locks the clip (no secondary swap)
Rewire the frontend: send the currently-shown clip, play each resolved morph straight (the manifest already encodes direction, so drop the `reversed`/`reverseFile` logic), land + lock on `target_clip_id` with a plain (re)load, and **delete the secondary `fadeThroughBlack` swap**. Build a `(from,to)→file` lookup from the ring and preload only the morphs reachable from the current locked clip, refreshed on each landing (154 clips is too many to preload eagerly).
**Files:**
- Modify: `simulator/static/app.js` — `advance` (620-652), `playTransition` (568-595), `onload`/ring load (33-57), `preloadOrder`/`preloadList`/`reverseFile` (104-131, 566), remove `fadeThroughBlack` (600-618) call.
- Verified by: Task 8 (E2E) + manual run.
**Interfaces:**
- Consumes the Task 6 response: `move.steps[k] = {from_clip, to_clip, file, to_index, blended}`, `move.target_clip_id`.
- New module state: `morphByPair = {}` — `"from→to"` → file string, built from `ring.transitions`.
- [ ] **Step 1: Build the morph lookup when the ring loads**
```javascript
// simulator/static/app.js — in loadRing(), after `ring = ...` is set:
morphByPair = {};
if (ring && ring.transitions)
for (const t of ring.transitions) morphByPair[`${t.from}→${t.to}`] = t.file;
```
Add `let morphByPair = {};` near the other module state (around line 38). The synthesized single-clip fallback ring has no transitions → empty lookup → advance is a no-op (pool of one), which is correct.
- [ ] **Step 2: Simplify `playTransition` (no reverse logic)**
```javascript
// simulator/static/app.js — replace playTransition
function playTransition(file, blended) {
// Play one resolved morph start-to-finish with alteration layers hidden. The
// manifest already encodes direction (forward zoom-in vs `.rev` zoom-out), so
// the file is played as-is. A fast-spin step runs at FAST_BLEND_RATE. If no
// morph exists for the pair (file null), resolve immediately (plain cut).
return new Promise((resolve) => {
if (!file) return resolve();
overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0";
vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1";
vid.src = mediaUrl(file);
vid.playbackRate = blended ? FAST_BLEND_RATE : 1;
let done = false;
const finish = () => {
if (done) return; done = true;
vid.removeEventListener("ended", finish); vid.playbackRate = 1; resolve();
};
vid.addEventListener("ended", finish);
vid.play().catch(finish);
setTimeout(finish, blended ? 3000 : 6000);
});
}
```
- [ ] **Step 3: Rewrite `advance` — pick first (via server), play morphs, lock, no swap**
```javascript
// simulator/static/app.js — replace advance
async function advance(delta) {
if (busy || !ring || ring.scales.length < 2) return;
busy = true;
try {
const resp = await fetch("/api/ring/advance", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ from_index: ringIndex, delta, from_clip_id: activeClipId }),
});
if (!resp.ok) return;
const move = await resp.json();
for (const step of move.steps) {
await playTransition(step.file, step.blended);
}
ringIndex = move.to_index;
activeClipId = move.target_clip_id; // the locked clip — the morph ended on it
currentClipId = null; // force ensureClipMedia to (re)load + loop it
renderScaleReadout();
} finally {
busy = false;
update(); // ensureClipMedia loads the locked clip; nothing re-rolls
applyAudio();
refreshReachablePreload();
}
}
```
Then **delete** `fadeThroughBlack` (now unused) and `reverseFile` (the `.rev` companion is an explicit manifest entry, no longer derived). Grep to confirm no other callers before deleting.
- [ ] **Step 4: Preload only the reachable morphs, refreshed per landing**
```javascript
// simulator/static/app.js — replace the eager transition preload in preloadOrder()/preloadList()
// Reachable morphs from the CURRENT locked clip: to/from every member of the
// neighboring altitudes (the only clips one detent can reach). 154 total is too
// many to preload up front; we focus on what the next move can need.
function reachableMorphFiles() {
const files = new Set();
if (!ring || ring.scales.length < 2 || !activeClipId) return files;
const n = ring.scales.length;
for (const sign of [1, -1]) {
const nb = ring.scales[((ringIndex + sign) % n + n) % n];
const members = (nb.pool || [{ clip_id: nb.clip_id }]).map((m) => m.clip_id);
for (const m of members) {
const a = morphByPair[`${activeClipId}→${m}`];
const b = morphByPair[`${m}→${activeClipId}`];
if (a) files.add(a);
if (b) files.add(b);
}
}
return files;
}
function refreshReachablePreload() {
// Kick a background cache fill of the reachable morphs (bounded concurrency,
// same cacheClip helper used for bases). Idempotent — cached files are skipped.
for (const f of reachableMorphFiles()) cacheClip(f);
}
```
In `preloadOrder()` replace the `if (ring.transitions) for (const t ...) { add(t.file); add(reverseFile(t.file)); }` block with `for (const f of reachableMorphFiles()) add(f);`. Keep base-clip preloading (current + neighbor pools) as-is. (`cacheClip` = factor out the per-file fetch→blob step already inside the preload loop so `refreshReachablePreload` can reuse it.)
- [ ] **Step 5: Manual smoke + commit**
Run: `cd simulator && python -m uvicorn app:app --port 8011` then open `http://localhost:8011/`, turn the Altitude dial one detent each way and several detents; confirm: a zoom morph plays, it lands on a clip, and that clip **does not change** while parked (watch the Dev-Mode clip readout). No black-flash second swap.
```bash
git add simulator/static/app.js
git commit -m "feat(sim): play chosen morph then lock clip per altitude; drop secondary swap"
```
---
### Task 8: Playwright E2E — lock + matching morph (new tier)
Stand up a minimal Playwright tier (none exists) and add one spec that proves the two behaviors the operator asked for: after zooming, the displayed clip is stable (locked) until the next altitude change, and the morph that plays matches `from→to`. §9 requires an E2E browser tier for this UI change.
**Files:**
- Create: `simulator/e2e/package.json`, `simulator/e2e/playwright.config.ts`, `simulator/e2e/tests/altitude-lock.spec.ts`
- Create: `simulator/e2e/README.md` (how to run; how it boots uvicorn)
**Interfaces:**
- The spec drives the real app (uvicorn on a fixed port via Playwright `webServer`), reads the Dev-Mode clip readout (enable Dev Mode via localStorage before load), and intercepts `/media/transitions/*` requests to assert the morph file played.
- [ ] **Step 1: Scaffold the tier (failing because no test yet)**
```jsonc
// simulator/e2e/package.json
{ "name": "hef-e2e", "private": true,
"scripts": { "test": "playwright test" },
"devDependencies": { "@playwright/test": "^1.45.0" } }
```
```ts
// simulator/e2e/playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
webServer: {
command: "cd .. && python -m uvicorn app:app --port 8099",
url: "http://localhost:8099/api/clips",
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
use: { baseURL: "http://localhost:8099" },
});
```
- [ ] **Step 2: Write the spec**
```ts
// simulator/e2e/tests/altitude-lock.spec.ts
import { test, expect } from "@playwright/test";
test("zoom plays a morph, lands locked, and does not change while parked", async ({ page }) => {
const morphs: string[] = [];
page.on("request", (r) => {
const m = r.url().match(/\/media\/(transitions\/[^?]+)/);
if (m) morphs.push(m[1]);
});
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
await page.goto("/");
const readout = page.locator("#scale-name");
await expect(readout).toContainText("cosmos");
// Zoom inward one altitude (wheel down = +). A morph must play.
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
await page.waitForTimeout(2500);
expect(morphs.some((f) => f.startsWith("transitions/cosmos__"))).toBeTruthy();
// The landed clip is now locked: capture it, wait through several alteration
// ticks, and assert it never changes.
const landed = (await readout.textContent())!;
await page.waitForTimeout(2000);
expect(await readout.textContent()).toBe(landed);
});
test("zooming back OUT plays a reverse morph and lands locked", async ({ page }) => {
const morphs: string[] = [];
page.on("request", (r) => {
const m = r.url().match(/\/media\/(transitions\/[^?]+)/);
if (m) morphs.push(m[1]);
});
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
await page.goto("/");
const readout = page.locator("#scale-name");
await expect(readout).toContainText("cosmos");
// Zoom inward one altitude, then back OUT (wheel up = -). The outward move must
// play a `.rev` morph and land back on cosmos, locked.
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
await page.waitForTimeout(2500);
morphs.length = 0; // only watch the zoom-out move
await page.locator("#stage").dispatchEvent("wheel", { deltaY: -120 });
await page.waitForTimeout(2500);
expect(morphs.some((f) => /transitions\/cosmos__.*\.rev\.mp4/.test(f))).toBeTruthy();
await expect(readout).toContainText("cosmos");
// Locked after the outward move too.
const landed = (await readout.textContent())!;
await page.waitForTimeout(2000);
expect(await readout.textContent()).toBe(landed);
});
```
- [ ] **Step 3: Install + run**
Run: `cd simulator/e2e && npm install && npx playwright install chromium && npm test`
Expected: PASS (1 test). If the wheel handler needs a different target, adjust the selector to the element bound in `onWheel` setup.
- [ ] **Step 4: Document the tier**
`simulator/e2e/README.md`: prerequisites (`npm install`, `npx playwright install chromium`), how `webServer` boots uvicorn, and `npm test`.
- [ ] **Step 5: Commit**
```bash
git add simulator/e2e
git commit -m "test(e2e): Playwright tier asserts altitude lock + matching morph"
```
---
### Task 9: Regenerate sample media + manifest, full suite green
Bake the 154 morphs into `simulator/sample_media`, rebuild the manifest, and run the entire pytest suite + the E2E spec so the shipped sample media matches the new schema.
**Files:**
- Modify (generated): `simulator/sample_media/manifest.json`, `simulator/sample_media/transitions/*.mp4`
- [ ] **Step 1: Rebuild media + manifest**
Run: `cd simulator && python build_pool_manifest.py --media` (or the script's generate+manifest entrypoint — confirm its CLI flags) to bake morphs and rewrite `manifest.json`.
Expected: `sample_media/transitions/` contains 154 files; `manifest.json` ring has 154 transition entries.
- [ ] **Step 2: Verify every manifest transition file exists**
```python
# one-off check (or add as tests/test_catalog_integrity.py case)
import json, pathlib
m = json.load(open("simulator/sample_media/manifest.json"))
base = pathlib.Path("simulator/sample_media")
missing = [t["file"] for t in m["ring"]["transitions"] if not (base / t["file"]).exists()]
assert not missing, missing
```
- [ ] **Step 3: Run the full suite**
Run: `pytest -q`
Expected: PASS (all existing + new tests; ~270+).
- [ ] **Step 4: Run the E2E spec against the regenerated media**
Run: `cd simulator/e2e && npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add simulator/sample_media
git commit -m "chore(media): regenerate 154 clip-pair morphs + manifest"
```
---
## Self-Review
**Spec coverage (operator directives):**
- "Choose which clip plays in the next altitude *before* transitioning" → Task 4 (`resolve_move` picks dst then morph) + Task 6 (server draws picks before resolving).
- "Play the appropriate morph footage" → Task 7 (renderer plays the resolved `step.file`).
- "Morph footage for all transitions between all videos in adjacent altitudes" → Tasks 12 (manifest + bake 154, both directions) + Task 9 (regenerate).
- "Video must not change after we've zoomed to a new altitude" / "lock per altitude until the altitude is changed" → Task 7 (delete secondary swap; lock `activeClipId`; reload only on altitude change) + Task 8 (E2E asserts stability while parked).
- "Good transition for every video combination, in and out" → both directions baked (Task 1/2), reverse via explicit `.rev` entries (Task 3/4), E2E covers in + out (extend Task 8 spec with a zoom-out case if desired).
- Multi-altitude jump chains through each altitude → Task 4 (`resolve_move` threads src→dst per step) + Task 5 (fast spin plays the chain quickly).
**Placeholder scan:** No TBD/TODO/"handle edge cases" — every code step shows real code; tests show real assertions.
**Type consistency:** `Transition(from_clip, to_clip, file, model)` (T3) used by `morph_for` (T3), `load_ring` (T3), `resolve_move` (T4), `resolved_move_to_dict` (T6). `MorphStep`/`ResolvedMove` fields (T4) match the serializer (T6) and the renderer's `step.{from_clip,to_clip,file,to_index,blended}` + `move.target_clip_id` (T7). `morphByPair` key `"from→to"` consistent in build (T7 S1) and lookup (T7 S4).
**Open items (resolved at the review gate + during execution):**
1. **Storage/repo weight:** Corrected during execution — `sample_media` was entirely
gitignored (regenerated from the builder), NOT already committed as the plan first
assumed. Operator decision: the media that has **graduated** into the actual
experience (curated clip bases, per-scale audio, the 154 morphs — ~526 MB) is now
committed via **git-LFS**, pointing at the self-hosted Gitea LFS store
(`git.benstull.org`). Stale/unused media (parked `right_variants`, ~1.6 GB) stays
gitignored (not deleted from disk — that's a separate explicit cleanup).
2. **`build_pool_manifest.py` CLI:** ✅ `python simulator/build_pool_manifest.py --media`
rebuilds the manifest + bakes all morphs (confirmed at execution).
3. **Zoom-out E2E:** ✅ Task 8 covers zoom-in + lock **and** zoom-out (`.rev` morph) + lock.
Note: one wheel notch = 60 deltaY = one detent (`Math.trunc(deltaY/60)`).
@@ -1,27 +1,36 @@
# Affect channel — emotions in the Left-brain HUD
**Status:** approved (session 0013, 2026-06-22)
**Status:** approved (session 0013, 2026-06-22); **revised session 0018, 2026-06-26**
**Surface:** simulator preview (`simulator/`, `player/alteration.py`)
**Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel
pass (same session).
> **Revision (session 0018) — affect is a RIGHT-brain output.** The original design
> gated emotions on *both* knobs (`strength = min(left, right)`). Per operator
> direction, **emotions are now controlled by the Right (dreamlike) knob alone** —
> the analytical Left knob no longer gates them. This sharpens the left/right split:
> Left = measurement (detections + measurements), Right = the dream **and** the
> feeling it evokes. Every `min(left, right)` below now reads `right`. The data
> shape, rendering, and per-word `min_level` mechanic are unchanged — only the knob
> the strength is keyed off.
## Idea
A third HUD channel beside the cyan *detections* and amber *measurements*: soft,
glowing **emotion-words** that surface the feelings the experience may invoke.
They appear only when *both* the analytical (Left) and dreamlike (Right) knobs are
up — the dream leaking into the machine's reading. This is the uncanny edge of the
thesis: the machine trying to quantify not just what is there, but how you are
meant to feel.
They are a **right-brain output: driven by the dreamlike Right knob alone** (the
analytical Left knob does not gate them) — the dream surfacing as the machine's
felt reading. This is the uncanny edge of the thesis: the machine trying to
quantify not just what is there, but how you are meant to feel.
## Behaviour
- **Trigger / intensity.** Affect strength = `min(left, right)` (04). Either knob
at 0 → no emotions at all. Opacity scales with that strength × `overlay_gain`.
- **Escalation.** A **stable emotional palette per scene** — higher combined
strength reveals *more* words at higher opacity. Each word carries a `min_level`
(the same mechanic the detection channel uses), but compared against the
*combined* `min(left, right)` strength rather than Left alone.
- **Trigger / intensity.** Affect strength = `right` (04). Right at 0 → no
emotions at all, regardless of Left. Opacity scales with that strength ×
`overlay_gain`.
- **Escalation.** A **stable emotional palette per scene** — higher Right reveals
*more* words at higher opacity. Each word carries a `min_level` (the same
mechanic the detection channel uses), compared against the `right` strength.
- **Rendering.** Floating words at authored scene positions — **no boxes or
reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet**
register so they read as affective and clearly *softer* than the hard clinical
@@ -33,15 +42,15 @@ Consistent with the existing split (alteration math in Python, label-selection i
the client):
- `player/alteration.py` gains an `AffectOverlay(strength, intensity)` on the
`RenderPlan`. `strength = min(left, right)`; `intensity = clamp(overlay_gain *
min(left,right) / KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under
`"affect"`. Pure, unit-tested.
`RenderPlan`. `strength = right`; `intensity = clamp(overlay_gain * right /
KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under `"affect"`. Pure,
unit-tested.
- The simulator client picks *which* words to show by comparing each affect
entry's `min_level` to `plan.affect.strength`, and renders them at
`plan.affect.intensity` opacity.
`is_identity` is unaffected: `min(left,right) > 0` implies `left > 0`, which already
makes `overlay.level > 0`.
`is_identity` is unaffected: `affect.strength == right`, and `right > 0` already
makes `dream.strength > 0`.
## Data
@@ -0,0 +1,274 @@
# Audio + Video — separated Visual & Audio controls — design
**Status:** proposed → built (sessions 0020 build + 0021 revision)
**Date:** 2026-06-26
**Session:** 0020 (audio) · 0021 (live-UI revision)
> **v1 build revision (session 0021, operator-driven):** the live **Audio** control
> ships as a simple **off / soundtrack** toggle (styled like the Dev Mode switch),
> and **Video** is the matching on/off toggle. **White-noise is deferred** with music
> (the orthogonal Visual × Audio model and the synthesized-pink-noise machinery
> remain in the codebase for when it's wanted). Two real-browser bugs were fixed in
> this revision: video-off now blanks GPU-composited layers (z-index + explicit layer
> hide), and the soundtrack now plays on Safari/iOS (audio elements unlocked
> synchronously inside the start gesture). Sections below describe the fuller
> off/soundtrack/white-noise design; v1 live = off/soundtrack.
>
> **Startup revision (session 0022):** the tap-to-start wall is **removed** — the app
> opens behind a **"Loading Universe…"** splash (shown until all media is preloaded
> and the experience can run smoothly), then both toggles start **off** (black +
> silent). Autoplay is satisfied without a wall: a **single** `<audio>` element is
> played **synchronously inside the toggle's own click** (the reliable Safari unlock),
> replacing the A/B-crossfade-after-gesture approach that failed on real Safari. The
> first time **Video** is turned on, **Audio** turns on with it (one flip = the full
> experience); turning Audio on first stays audio-only. Altitude changes swap the
> single element's source with a brief fade.
**Extends / reframes:** [`2026-06-26-networked-control-surface-design.md`](./2026-06-26-networked-control-surface-design.md)
§3 (control inventory) and §5 (server contract) — its bundled **Content** selector
is split into two orthogonal controls. Everything else in that spec (server-of-truth
+ SSE, the two pages, the Arduino seam, transition lifecycle) stands unchanged.
**Asset record:** [`docs/audio-candidate-pool.md`](../../audio-candidate-pool.md).
---
## 1. Goal
Give the experience **sound**, and make audio and video **independently
controllable**. Today the control surface bundles audio+video into a single 7-way
**Content** selector (*video / audio+video / music+video / off / white noise /
music / audio track*). That bundle is both awkward to operate and **incomplete**
it has no "white noise **with** video," for instance.
Replace it with **two orthogonal dials****Visual** and **Audio** — so every
combination is reachable, including the ones the bundle omitted. Operator framing
(2026-06-26): *"separate these dials… it's ok to let people have e.g. white noise
with video."*
## 2. Core idea: orthogonalize Content into Visual × Audio
```
BEFORE (bundled) AFTER (orthogonal)
Content: 7-way selector Visual: on / off
video Audio: off / soundtrack / white noise
audio+video (· music — deferred)
music+video ──▶ ───────────────────────────────────────
off Any Visual × Any Audio is now reachable:
white noise video + soundtrack (was "audio+video")
music video + white noise (NEW — was missing)
audio track black + white noise (was "white noise")
black + soundtrack (was "audio track")
```
The 7 bundled modes were really points in a **2 × N grid** (visual on/off ×
audio source). Making the grid explicit removes the awkwardness and fills the gap.
The **Altitude** dial is unchanged and remains the single scale selector.
## 3. The controls (reframes control-surface §3)
| Control | Positions (v1) | Drives | Class (§4 of control-surface spec) |
|---|---|---|---|
| **Altitude** | endless dial, cosmos→abyss (wraps) | the visual **scale** **and**, when Audio=soundtrack, the matching ambience | transitional |
| **Visual** | on / off | video shown vs. black | transitional (video fade) |
| **Audio** | off / soundtrack / white noise (· *music* deferred) | the audio **source** | live + short crossfade (§7) |
| **Left** / **Right** / **Mood** | unchanged | the alteration engine | live |
So the panel grows from 5 controls to **6** (Content → Visual + Audio). The
physical form (later): Visual = a toggle/2-pos switch; Audio = a small rotary
selector (3-pos in v1, 4 with music).
## 4. Audio model (the coupling decision)
**Decision (operator):** the audio dial is a **source** selector; the per-altitude
soundtrack **follows the single Altitude dial**. There is **no** separate audio
altitude.
- **`soundtrack`** → plays the ambience of the **current Altitude scale**. Turning
Altitude to coast shows coast video *and* plays coast waves; the soundtrack
**crossfades** as the altitude transition settles (mirrors the video ring
crossfade). One dial, two coupled outputs.
- **`white noise`** → a single global bed, **altitude-independent**. Changing
Altitude does not change it. This is what makes "white noise + any video" work.
- **`off`** → silence. Video (if Visual=on) plays mute.
- **`music`** *(deferred)* → the composed per-altitude music layer. Reserved as a
future Audio position; **not in v1** (no assets yet — see
[`docs/audio-candidate-pool.md`](../../audio-candidate-pool.md) "two audio layers").
Independence summary: **Visual ⟂ Audio** (any pairing allowed); **Audio source ⟂
Altitude** for white noise/music, **coupled to Altitude** for soundtrack.
## 5. Assets & manifest
### 5.1 Per-scale soundtrack
The five ambiences are sourced and reviewed (PD/CC0; record in
[`docs/audio-candidate-pool.md`](../../audio-candidate-pool.md), local-only review
gallery `simulator/static/review_audio.html`):
| scale | soundtrack | license |
|---|---|---|
| cosmos | Pillars of Creation sonification | PD (NASA) |
| orbit | station room-tone hum | CC0 |
| coast | sea waves + terns | CC0 |
| reef | 6-layer reef soundscape | PD (NOAA) + CC0 |
| abyss | blue-whale moan | PD (NOAA) |
The manifest's `Scale` gains an **`audio`** field (path to the scale's soundtrack),
parallel to how clips are referenced; media stays gitignored and is served at
`/media/audio/<scale>/<file>`. Soundtracks are a **per-scale single** (not a
rotating pool) in v1 — one bed per altitude. (A rotating audio pool, like the clip
pool, is a possible later extension; out of v1.)
### 5.2 White noise
Altitude-independent global asset. **Synthesized, not sourced** — a calm
**pink/brown** noise (gentler than pure white) generated deterministically with
`ffmpeg -f lavfi -i anoisesrc=color=pink` → a clean ~60 s loop. Zero licensing
concerns. (Colloquially "white noise"; spec a calm colored noise.)
### 5.3 Production pass (assets)
Before ship the source clips get a light, deterministic pass (the existing
`tools/pipeline` ffmpeg approach): **seamless loop** (crossfade the loop point),
**normalize loudness** across the five so altitudes match, and a consistent format
(mp3/ogg). Several are short (abyss 13 s, cosmos 30 s) and must loop cleanly. The
6-layer reef bake is already loop-length (60 s).
## 6. Server contract (reframes control-surface §5)
The control-surface spec's authoritative `SessionState` + four endpoints are
unchanged in shape; only the **controls** payload changes: `content` is replaced
by `visual` + `audio`, and the derived `render.content` is split.
### 6.1 `GET /api/state` — updated `controls` + `render`
```json
{
"seq": 42,
"controls": { "visual": "on", "audio": "soundtrack", "left": 3, "right": 1, "mood": -2 },
"altitude": { "index": 2, "scale": "coast", "clip_id": "coast_07" },
"transitions": { "altitude": "idle", "visual": "idle" },
"render": {
"plan": { },
"video": { "shown": true },
"audio": { "source": "soundtrack", "url": "/media/audio/coast/waves.mp3", "altitude_coupled": true }
}
}
```
- `audio.url` is resolved **server-side** from `(audio source, altitude)`:
`soundtrack` → the current scale's `audio`; `white_noise` → the global noise
asset; `off``null`. The renderer just plays `audio.url` (or nothing).
- `transitions` tracks `visual` instead of `content` (Audio is not transitional —
§7).
### 6.2 `POST /api/control`
```json
{ "set": { "audio": "white_noise" } } // audio source change
{ "set": { "visual": "off" } } // visual fade to black
{ "set": {}, "altitude_delta": -1 } // altitude tick (also re-resolves audio.url when source=soundtrack)
```
Server applies, bumps `seq`, recomputes `render.audio.url`, and broadcasts.
Absolute `set` values keep retries idempotent (control-surface §5.6).
### 6.3 SSE events
- `state` — carries the new controls/render; emitted on Audio change and on any
live change (Audio source change rides the `state` event — §7).
- `ring` — altitude move; now **also** carries the new `audio.url` so the renderer
can crossfade the soundtrack as it plays the visual transition (when
source=soundtrack).
- `mode` — repurposed to the **Visual** fade (on/off). (Same renderer fade machinery
the bundled Content used.)
## 7. Live vs. transitional classification
- **Visual on/off → transitional.** It's a visible video fade (to/from black); it
uses the renderer's existing fade + `settled` ack, exactly as the bundled
Content's fade did. `transitions.visual` flips `transitioning``idle`.
- **Audio source → live, with a short renderer-side crossfade.** Swapping
soundtrack/white-noise/off is a ~0.51 s **gain crossfade** between two `<audio>`
elements — perceptible but **non-blocking** and not a renderer animation with
unknown timing. So it's classified **live** (no busy spinner, no `settled` ack),
like Left/Right/Mood. *Flagged decision:* if the operator wants the Audio knob to
feel "busy" during its fade, it can be promoted to transitional later; v1 keeps it
live for simplicity.
- **Altitude → transitional** (unchanged). When source=soundtrack, the renderer
crossfades the audio bed in step with the ring transition; the audio crossfade is
slaved to the visual transition and needs no separate ack.
## 8. Renderer behavior (extends control-surface §6.1)
The display page adds an **audio layer**:
- Two `<audio>` elements (A/B) for gapless gain crossfades; a tiny crossfade helper.
- On `state`: reconcile `audio.url` — if it changed, crossfade A↔B to the new url
(or fade to silence for `off`). White-noise and soundtrack are the same mechanism;
only the url differs.
- On `ring` (altitude move) with source=soundtrack: start the soundtrack crossfade
to the event's new `audio.url` so it lands with the visual scale.
- On `mode` (Visual): fade the **video** to/from black; audio is untouched (you can
hear soundtrack/white-noise over black).
- Each soundtrack loops (the production-pass clean loop); white-noise loops.
- Autoplay: the renderer is launched full-screen by the operator; a one-time user
gesture (or `--autoplay`/muted-then-unmute) satisfies browser autoplay policy.
*Flagged:* document the launch gesture so the projection starts audio reliably.
## 9. Testing (mandatory pipeline tiers)
- **Pure unit** — `render.audio.url` resolution from `(source, altitude)`:
soundtrack→scale asset, white_noise→global, off→null; altitude tick re-resolves
only when source=soundtrack; `seq` bump. No I/O.
- **API/contract** — `POST /api/control {visual}` flips `transitions.visual`;
`{audio}` rides a `state` event with the new url and **no** transition flag; bad
enum → 422; `GET /api/state` shape.
- **E2E (Playwright)** — renderer + `/remote`: set Audio=white_noise → assert the
renderer's audio element src is the noise asset; tick Altitude with
Audio=soundtrack → assert the soundtrack url crossfades to the new scale; set
Visual=off → assert the video fades and audio keeps playing. (Audio *playback*
asserted via element `src`/`paused`, not waveform.)
## 10. Scope
**In v1:**
- Split **Content****Visual** (on/off) + **Audio** (off / soundtrack / white
noise) across `SessionState`, `/api/state`, `/api/control`, SSE, and both pages.
- Per-scale `audio` in the manifest + the 5 sourced soundtracks (production-passed
loops); synthesized white-noise asset.
- Server-side `audio.url` resolution; renderer audio layer with crossfades;
altitude-coupled soundtrack crossfade.
**Explicitly NOT in v1 (deferred):**
- **Music layer** (`audio=music`) — reserved dial position; no assets yet.
- **Separate audio altitude** (hear reef while seeing coast) — rejected coupling
option; soundtrack follows the one Altitude dial.
- **Rotating audio pools** per scale (one bed per altitude in v1).
- Per-altitude white-noise variants (one global noise).
- Physical Visual/Audio switches (Arduino) — contract only, per control-surface §7.
## 11. Spec & roadmap impact
- **Reframes** control-surface §3/§5: `content``visual` + `audio`;
`transitions.content``transitions.visual`; `render.content``render.video`
+ `render.audio`. That spec is still *proposed* (unbuilt), so this is a clean
pre-implementation edit, not a migration. Both can be built together, or this
one folds into the control-surface build as its Content step.
- The **music layer** becomes a tracked follow-on (compose + source per-altitude
music; flip on the reserved Audio position).
- `ROADMAP.md` to note the audio capability at implementation time.
## 12. Open questions / flagged — RESOLVED (session 0020 build)
- **Audio knob feel** (§7) — **RESOLVED: live**, with a ~0.6 s renderer-side gain
crossfade (`XFADE_MS=600`), no `settled` ack. Promote to transitional later only
if the iPad makes it feel like it needs a "busy" state.
- **White-noise color** — **RESOLVED: pink** (`anoisesrc=color=pink`), a balanced
calm bed. Brown is a one-parameter alternative (`color=brown`) if a deeper rumble
is wanted; the build is parameterized for it.
- **Autoplay gesture** (§8) — **RESOLVED: a one-time tap-to-start overlay**
("▶ tap to start sound", `#start-gesture`). The first click unlocks both `<audio>`
elements within the user gesture; thereafter audio follows state. This is the
documented launch step for the full-screen projection.
@@ -0,0 +1,331 @@
# Networked Control Surface — design
**Status:** proposed
**Date:** 2026-06-26
**Session:** 0019
**Supersedes/reframes:** ROADMAP sub-project 4 (Arduino Firmware / control panel) and
the "serial input" slice of sub-project 3 (Player Runtime).
---
## 1. Goal
Split the existing simulator into a **renderer** (the laptop full-screen
projection — "the experience") and a separate **controller** (a touch remote),
talking over the local network, so the experience can be driven from a handheld
device the way the real installation's panel drives its screen.
This is **v1 hardware**, deliberately simulator-friendly:
- The **laptop/computer renders** (no Raspberry Pi yet).
- The controller is a **separate device** over **wifi** (LAN), not a USB-serial
link to a Pi.
- The controller is **prototyped as a web page** (an iPad/tablet/phone twin),
and a **physical Arduino remote** drops in later as just another control source
speaking the same contract.
Two goals, equally weighted (operator, session 0019): **decouple control from
display cleanly**, *and* get the **handheld "stand back and drive it" feel**.
## 2. Core idea: one server of truth, interchangeable control sources
```
┌──────────────────┐ POST /api/control ┌─────────────────────┐
│ CONTROL SOURCE │ ───── (knob change) ────▶ │ FastAPI server │
│ │ │ (laptop) │
│ • iPad web remote│ ◀──── GET /api/state ──── │ ┌───────────────┐ │
│ (v1) │ (sync on load) │ │ SessionState │ │
│ • Arduino (later)│ │ │ controls + │ │
└──────────────────┘ │ │ ring index + │ │
│ │ transitions + │ │
┌──────────────────┐ GET /api/events (SSE) │ │ seq (version) │ │
│ RENDERER │ ◀═══ state/ring/mode ════ │ └───────┬───────┘ │
│ laptop full- │ (live push) └──────────┼──────────┘
│ screen │ │
│ (the experience) │ ──── POST /api/render/event ────────▶│ (settled ack)
└──────────────────┘ recompute RenderPlan + ring move
```
- **Server** owns the single authoritative `SessionState` (current controls,
altitude/ring index, per-field transition state, a monotonic `seq`). Every
change recomputes the derived view and broadcasts it.
- **Control source** = anything that `POST`s a control change. v1 has exactly
one: the iPad web remote. A future Arduino is *another instance of the same
role* — no new server concept.
- **Renderer** = the laptop projection. It holds **no** input UI; it subscribes
to the SSE stream and renders whatever state arrives. It is today's
`simulator/static/index.html`, stripped of its sliders, given a `state`
listener plus one small upstream ack channel.
The property that matters: controller and renderer are now **separate web pages
off the same server**, decoupled exactly as a real installation's panel and
screen are. (Chosen architecture: server-authoritative session + **Server-Sent
Events** push. SSE fits because the directions are asymmetric — the renderer only
*receives*, the control source only *sends* — so no bidirectional WebSocket is
needed. Alternatives considered and rejected: renderer polling — laggy/wasteful;
peer-to-peer WebRTC — most plumbing and it breaks the clean single-contract seam
that makes the Arduino a drop-in.)
## 3. Control surface: the six controls (the faithful twin)
The iPad remote is a **faithful on-screen twin** of the intended physical panel —
what you like on the iPad is the spec for the Arduino panel. The control
inventory is exactly what the sim exposes today:
| Control | Sim today | Physical form (later) |
|---|---|---|
| **Visual** | toggle (show video on/off) | 2-position switch |
| **Audio** | on/off toggle (on = per-altitude soundtrack; ·white-noise & music deferred) | 2-position switch (rotary selector when more sources land) |
| **Altitude** | endless circular dial — walks the 5 scales (cosmos→abyss), wraps | rotary encoder (endless, detented) |
| **Left** (analytical) | slider 04 | knob/pot, 5 detents |
| **Right** (dreamlike) | slider 04 | knob/pot, 5 detents |
| **Mood** | slider 4…+4 (dark↔light) | center-detented knob |
(This is the original sub-project-4 inventory, *updated*: Dark+Light collapsed
into one **Mood** knob in session 0013, the **Altitude** encoder added in session
0018, and the bundled 7-way **Content** dial split into orthogonal **Visual** ×
**Audio** controls in session 0020 — see
[`2026-06-26-audio-video-separated-controls-design.md`](./2026-06-26-audio-video-separated-controls-design.md).)
## 4. Live vs. transitional controls (per-knob transition state)
The five controls split into two classes by how a **human** perceives the change:
- **Live controls — Left, Right, Mood, Audio.** The alteration engine treats
Left/Right/Mood as `LIVE_UPDATE` (the WebGL Kuwahara dream + HUD overlay + color
grade re-tune within a frame). **Audio** source changes are also live: a short
(~0.6 s) renderer-side gain crossfade between two `<audio>` elements —
perceptible but non-blocking, with no `settled` ack (audio spec §7). These are
*always idle*; the remote just shows the value.
- **Transitional controls — Altitude, Visual.** These trigger a renderer
animation that takes real, perceptible time: Altitude plays ring-transition
clips (zoom/warp between scales); **Visual** on/off does fade-to-black /
fade-from-black. During that window the control is **busy**, and the control
source must model that so its feedback is honest.
Per-transitional-control lifecycle:
```
idle ──(source sends)──▶ pending ──(renderer starts)──▶ transitioning ──(renderer settles)──▶ idle
```
- **pending** — set locally by the remote the instant the knob is turned
(immediate tactile feedback before the server replies).
- **transitioning** — the server flags the field once the renderer actually
begins the animation; broadcast to *all* control sources.
- **idle** — cleared when the **renderer acks completion** (the only party that
knows real playback timing), with a server-side max-duration timeout as a
safety net so a dropped renderer can't wedge a knob.
Hardware payoff: a physical Altitude encoder can light an LED while
`transitioning`; the iPad twin shows the matching spinner/disabled-detent.
`SessionState` therefore carries
`transitions: { altitude: idle|transitioning, visual: idle|transitioning }`
alongside the controls and `seq`. (Live controls — Left/Right/Mood/Audio — need
no entry.)
## 5. Server contract
Four endpoints carry the whole thing. The existing `/api/alteration`,
`/api/ring`, `/api/ring/advance` remain as **internal helpers the server calls**;
control sources never hit them directly.
### 5.1 `GET /api/state` — snapshot (sync-on-load / polling fallback)
```json
{
"seq": 42,
"controls": { "visual": "on", "audio": "soundtrack", "left": 3, "right": 1, "mood": -2 },
"altitude": { "index": 2, "scale": "coast", "clip_id": "coast_07" },
"transitions": { "altitude": "idle", "visual": "idle" },
"render": {
"plan": { },
"video": { "shown": true },
"audio": { "source": "soundtrack", "url": "/media/audio/coast/waves.loop.mp3", "altitude_coupled": true }
}
}
```
(The `render.video` + `render.audio` split and server-side `audio.url` resolution
are specified in the audio spec §6.1.)
A control source GETs this on load to draw its knobs in the right positions; the
renderer can use it to cold-start or resync after a reconnect.
### 5.2 `POST /api/control` — the one control-source contract
A partial patch — only the fields that changed:
```json
{ "set": { "left": 4 }, "altitude_delta": 0 } // live knob
{ "set": { "audio": "white_noise" }, "altitude_delta": 0 } // live (audio source)
{ "set": { "visual": "off" }, "altitude_delta": 0 } // transitional (video fade)
{ "set": {}, "altitude_delta": -1 } // one encoder tick down
```
Server applies it, bumps `seq`, recomputes, and:
- **live field** (Left/Right/Mood, **or an `audio` source change**) → broadcasts
a `state` event immediately (the renderer crossfades audio on its own, §7);
- **transitional field** (a `visual` change, or a non-zero `altitude_delta`) →
computes the ring move / video fade, marks that field `transitioning`,
broadcasts a `ring` (or `mode`) event for the renderer to animate.
Returns the new snapshot (an immediate authoritative echo for the poster).
Absolute `set` values make retries idempotent; `altitude_delta` is relative
(encoder ticks) and the server coalesces rapid ticks via the existing
`advance_ring` fast-spin logic.
### 5.3 `GET /api/events` — SSE stream (renderer subscribes)
Event types:
- `state` — full snapshot; sent on connect and on every live change (including an
**audio** source change, which the renderer crossfades — §7).
- `ring` — an altitude move: the transition clip(s) to play + the landed
scale/clip, **plus the new `audio.url`** so the renderer can crossfade the
soundtrack in step (when `audio=soundtrack`). Renderer plays them, then settles.
- `mode` — a **Visual** on/off change: the video fade the renderer should run.
- `ping` — keepalive.
The SSE event payloads reuse the existing serializers
(`ring_move_to_dict`, `render_plan_to_dict`, etc.).
### 5.4 `POST /api/render/event` — the renderer's one upstream ack
```json
{ "type": "settled", "seq": 42, "field": "altitude" }
```
Sent when the renderer finishes a transition. Server flips that field `idle` and
broadcasts a `state` so every remote clears its spinner. A server-side timeout
(max transition duration + margin) clears it anyway if the renderer never acks.
### 5.5 End-to-end flow for one altitude tick
```
iPad: turn dial → field=pending locally → POST /api/control {altitude_delta:-1}
server: apply, seq++, transitions.altitude="transitioning"
→ SSE "ring" to renderer → SSE "state" to all remotes (altitude busy)
renderer: play transition clip(s), settle on forest_07
→ POST /api/render/event {type:"settled", field:"altitude"}
server: transitions.altitude="idle" → SSE "state" to all → iPad clears spinner
```
### 5.6 Decisions flagged (not assumed silently)
- **Concurrency = last-write-wins.** Multiple control sources are allowed;
per-field last write wins, `seq` orders everything. No locking in v1.
- **No auth.** v1 trusts the LAN — any device that can reach the laptop can drive
it. A room-token is deferred until wanted.
## 6. The two pages
### 6.1 Renderer — `/` (display), refactored from today's `index.html`
- **Removes** the input controls (the Visual toggle, the Audio `<select>`, the
Altitude SVG, the three sliders) and their handlers.
- **Keeps** everything that draws the experience: the video element, the **A/B
`<audio>` crossfade layer**, the WebGL Kuwahara dream shader, the
HUD/annotation + affect overlay, the ring-transition playback, the media
preload pool, the crossfade-loop logic.
- **Adds** a thin `state` client: open `EventSource('/api/events')`; on `state`
apply controls→render live (incl. the audio crossfade); on `ring` play the
transition clip(s) — crossfading the soundtrack to the event's `audio.url`
then settle and `POST /api/render/event {settled}`; on `mode` run the video
fade. On (re)connect,
pull `GET /api/state` to resync. SSE auto-reconnects, so a flaky link
self-heals.
- Runs full-screen on the laptop driving the projector; no keyboard/mouse needed
once launched.
### 6.2 Controller — `/remote`, a new touch-first page (the faithful twin)
- The six controls laid out like the intended physical panel: **Visual** toggle +
**Audio** selector, **Altitude** encoder dial (reuse the existing SVG dial
component), **Left** / **Right** / **Mood** knobs.
- On any input → `POST /api/control` with just the changed field. Live knobs post
on drag; Altitude posts encoder ticks; Visual/Audio post on change.
- Per-knob transition state (§4): a transitional control goes **pending** on
touch, reflects **transitioning** from the SSE feed, clears on **settled**.
Altitude shows a settling indicator and its detents soft-lock (further ticks
queue as the next target rather than stacking); **Visual** shows the toggle as
pending until the video fade completes; **Audio** is live (no pending state).
- It **also** subscribes to `/api/events` so it stays in sync with other
sources (turn the future Arduino's knob and the iPad reflects it, and
vice-versa). On load it GETs `/api/state` to draw current positions.
- Touch-sized hit targets, no hover dependence, works in mobile Safari with no
install.
## 7. The future-Arduino seam (contract only in v1)
No firmware in this feature, but the design **proves the seam** so sub-project 4
is a true drop-in:
- **ESP32 (wifi Arduino)** → posts straight to `POST /api/control` over the LAN.
Zero server change. Recommended hardware target when the time comes.
- **Classic USB Arduino** → emits serial frames to a tiny `bridge.py` on the
laptop that translates each frame into a `POST /api/control`. The old roadmap
"3⇄4 serial framing contract" shrinks to *just* the Arduino↔bridge link; the
renderer/server never know the difference.
- Either way the Arduino sends **absolute pot values** (`set`) + **encoder
ticks** (`altitude_delta`) — exactly the iPad's message shape.
This is what makes "faithful twin" real: the iPad and the Arduino are the same
control source speaking the same contract.
## 8. Error handling / resilience
- **Renderer drops:** SSE auto-reconnects; on reconnect it GETs `/api/state` and
resyncs. The per-field transition timeout clears any knob left `transitioning`
by a renderer that vanished mid-animation.
- **Controller drops / flaky wifi:** POSTs are fire-and-retry; absolute `set`
values are idempotent, so a retried knob value is harmless. `altitude_delta` is
the one relative input — on a failed POST the remote keeps the tick in its
local pending target and resends rather than double-counting.
- **No renderer connected:** the server still accepts control changes and holds
state; a renderer that connects later catches up from the snapshot.
- **Stale control source:** every event carries `seq`; a source ignores events
older than what it has applied.
## 9. Testing (satisfies the mandatory pipeline tiers)
- **Pure unit** — a `SessionState` store in Python: apply patch → live vs.
transitional classification; `altitude_delta` → ring move via the existing
`advance_ring`; `seq` bump; transition flag set/clear; timeout. No I/O, tested
like the rest of `player/`.
- **API/contract** — `GET /api/state`; `POST /api/control` (live + transitional +
bad input → 422); `POST /api/render/event` clears the flag; the SSE
event-builder tested as a pure function.
- **E2E (Playwright, required for the UI surface)** — open the renderer and
`/remote` as two pages: move a live knob → assert the renderer's render
updates; tick Altitude → assert a transition plays, the remote shows
`transitioning`, then clears on settle.
## 10. Scope
**In v1:**
- Server `SessionState` + the four endpoints (`/api/state`, `/api/control`,
`/api/events`, `/api/render/event`).
- Renderer refactor to display-only + SSE client + settled ack.
- The `/remote` iPad twin: the five controls + per-knob transition state.
- Wifi/LAN only; laptop renders.
**Explicitly NOT in v1 (deferred):**
- Any Arduino/firmware (contract defined here; hardware later).
- Bluetooth / USB transport.
- Auth / room-token.
- The Pi renderer.
- Multi-room / multi-renderer fan-out (the SSE design allows it; not a v1 goal).
## 11. Roadmap impact
- **Sub-project 4** is reframed: the panel is now "any control source over the
`/api/control` contract," not USB-serial-to-Pi first. The iPad twin is the v1
deliverable; the Arduino is a later drop-in.
- **Sub-project 3's "serial input" slice** is absorbed into a transport detail
(the Arduino↔bridge link), no longer a Pi-coupled concern.
- `ROADMAP.md` to be updated at implementation time.
@@ -0,0 +1,105 @@
# Scrub-driven Altitude Transitions — Design
**Status:** Approved (brainstormed 2026-06-27, session 0024). Ready for writing-plans.
## Goal
Make the altitude transition **driven by the knob position** rather than auto-played.
Turning the physical knob (or, in the simulator, dragging the Altitude dial) at any
speed scrubs the morph video and crossfades the audio in lockstep with the knob. The
knob position *is* the scene: turn slow → the morph eases slowly; turn fast → it
races; stop halfway → it holds in a mid-morph blend; turn back → it reverses exactly.
This supersedes the timed dial-needle sweep (the dial-vs-video sync problem dissolves
because the knob and the transition become the same thing).
## Core model
A continuous **altitude position** `pos` (float): integers are altitudes/detents,
fractions are mid-morph blend. `pos = 2.4` ⇒ 40 % from coast (index 2) toward reef
(index 3). The knob/needle maps directly to `pos`; the morph video's `currentTime`
and the audio crossfade are scrubbed by `frac(pos)`.
**Decisions (locked):**
- **In-between state:** hold the blend wherever the knob stops — no auto-complete
(continuous encoder model). *(Q1 → a)*
- **Turn-back:** scrubs the same morph in reverse; lands on exactly the previous
altitude's clip you started from (fully reversible).
- **Destination randomness:** the next altitude's clip is a random pick from its pool,
**fixed during a single continuous gesture**, **re-rolled on each fresh approach**.
*(Q → a)*
- **Scroll wheel:** auto-scrub one altitude over ~0.6 s, then lock. *(Q2 → a)*
- **Lock-per-altitude** still holds: a resting altitude's clip stays locked until you
commit into a different one.
## Components
### 1. Scrub controller (frontend — `simulator/static/app.js`)
Replaces the discrete `advance(delta)` "commit then play" for drag/knob input.
- Drives `pos` from the dial pointer angle: `pos = restPos + accum / dialStep`. The
needle follows continuously (the needle *is* the knob — live follow is correct here).
- For the active segment `[floor, floor+1]` in the travel direction, sets
`vid.currentTime = frac(pos) × vid.duration`, **throttled to one seek per
`requestAnimationFrame`** (avoid seek thrash).
- Crossing an integer **commits**: the crossed altitude's chosen clip becomes the
locked `activeClipId`, `ringIndex` updates, and the next segment begins (re-roll).
- Reversible: `pos` decreasing scrubs the morph backward.
- Keep the pure, testable bits separable (position→segment, `frac`→currentTime,
integer-crossing commit, re-roll-on-fresh-approach) from the DOM/event glue.
### 2. Clip selection / lock
Entering a segment toward neighbor `k±1`, pick a random clip from that scale's pool
(client-side) and resolve the directed morph via the existing
`morphByPair["<fromClip>→<toClip>"]` lookup (built from `ring.transitions`). Fixed for
the gesture; re-rolled on a fresh approach. *(This moves the random pick client-side
for responsiveness vs the current server pick in `/api/ring/advance`; acceptable for
the simulator — the Pi player can mirror the same client-side pick. `pick_clip_id` in
`player/ring.py` stays the canonical pure helper.)*
### 3. Audio crossfade (frontend)
During a segment, crossfade the two adjacent scale soundtracks by `frac(pos)` (gains
`1-frac` / `frac`); at rest only the current scale plays. Hook into the existing audio
layer (`applyAudio` / the per-scale `audio` field in the ring).
### 4. Morph media re-bake (pipeline — `simulator/build_pool_manifest.py`)
Smooth scrubbing = seeking to arbitrary frames, which needs **dense keyframes**. The
current morphs use a sparse GOP, so they scrub "steppy." Re-bake the 154 morphs
**all-intra** (e.g. x264 `-g 1` / `keyint=1`, or `-intra`) so every frame is seekable.
Files grow (~176 MB → ~0.50.9 GB) but each clip stays small (front-end upload limit is
a non-issue; that was the 68 MB *base*, unrelated). Media is committed via **git-LFS**
(see `.gitattributes`); re-push after re-baking.
**Phasing:** build the interaction first against the *current* morphs to validate the
feel (will scrub somewhat steppy), then re-bake all-intra for smoothness. Two plan
phases.
### 5. Wheel + tap
- **Wheel:** auto-scrub `pos` by ±1 over ~0.6 s (drives the same scrub path), then lock.
- **Tap a dial label:** auto-scrub to that altitude the shortest way around.
## Testing
- **Unit (pytest or JS):** the pure controller logic — position→segment mapping,
`frac``currentTime`, commit+lock on integer crossing, re-roll on fresh approach,
reverse direction.
- **E2E (Playwright, `simulator/e2e`):** drag the dial partway → assert the morph
`currentTime` and the two audio gains track the angle; drag back → values reverse;
a full turn → commits and locks the destination clip; the wheel → auto-scrubs one
altitude and locks. (Extends the existing `altitude-lock.spec.ts` tier.)
## Out of scope
- Physical hardware / encoder firmware (the simulator drag is the stand-in; the model
is designed to map 1:1 to an encoder position later — see the networked control-surface
spec).
- Changing the clip-pair morph *set* or naming (`<src>__<dst>.mp4` + `.rev`) — reused as-is.
## Key existing context (for the implementer)
- Dial + transition logic: `simulator/static/app.js` (`advance`, `playTransition`,
`onDialDown/Move/Up`, `renderDial/setNeedle`, `morphByPair`, the preload helpers).
- Ring/morph contract: `player/ring.py` (`Scale.pool`, `pick_clip_id`, `morph_for`,
`resolve_move`), `simulator/clips.py` (`ring_to_dict`, `resolved_move_to_dict`),
`/api/ring/advance` in `simulator/app.py`.
- Morph manifest + baking: `simulator/build_pool_manifest.py` (`_make_transition`,
`_make_reverse`, `generate_media`, `build_manifest`); morphs at
`simulator/sample_media/transitions/<src>__<dst>.mp4` (+ `.rev`).
- The clip-pair morph + lock feature this builds on shipped in session 0024 (plan:
`docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md`).
+17 -14
View File
@@ -88,11 +88,12 @@ class AnalyticalOverlay:
@dataclass(frozen=True)
class AffectOverlay:
"""Affect channel (Left x Right): emotion-words surfaced only when BOTH the
analytical (Left) and dreamlike (Right) knobs are up. `strength` is
`min(left, right)` (0..4) — a runtime affect track picks which feeling words
appear by it; `intensity` 0..1 is their opacity. The dream leaking into the
machine's reading (affect-channel design, session 0013)."""
"""Affect channel (RIGHT brain): emotion-words surfaced by the Right (dreamlike)
knob alone — the analytical Left knob does NOT gate them. `strength` is the Right
knob (0..4) — a runtime affect track picks which feeling words appear by it;
`intensity` 0..1 is their opacity. Feeling is the right brain's output, surfaced
as the machine's felt reading (affect-channel design, session 0013; Right-only
since session 0018)."""
strength: int
intensity: float
@@ -122,8 +123,9 @@ class RenderPlan:
@property
def is_identity(self) -> bool:
"""True when the plan leaves the neutral base un-altered. Affect needs no
clause: `min(left,right) > 0` implies `left > 0`, so `overlay.level > 0`
already makes a plan with active affect non-identity."""
clause: `affect.strength == right`, and `right > 0` already makes
`dream.strength > 0`, so an active-affect plan is non-identity via the
dream check below."""
return (
self.grade.is_identity
and self.overlay.level == 0
@@ -135,13 +137,14 @@ def _overlay_intensity(left: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
def _affect_strength(left: int, right: int) -> int:
"""Affect surfaces only when BOTH knobs are up — gated by the smaller one."""
return min(left, right)
def _affect_strength(right: int) -> int:
"""Affect is a RIGHT-brain output: the emotion channel is driven by the Right
(dreamlike) knob alone — the analytical Left knob no longer gates it."""
return right
def _affect_intensity(left: int, right: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * _affect_strength(left, right) / KNOB_MAX, 0.0, 1.0)
def _affect_intensity(right: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * _affect_strength(right) / KNOB_MAX, 0.0, 1.0)
def _dream_intensity(right: int, cal: Calibration) -> float:
@@ -163,8 +166,8 @@ def plan_alteration(
intensity=_overlay_intensity(coord.left, calibration),
),
affect=AffectOverlay(
strength=_affect_strength(coord.left, coord.right),
intensity=_affect_intensity(coord.left, coord.right, calibration),
strength=_affect_strength(coord.right),
intensity=_affect_intensity(coord.right, calibration),
),
dream=Dream(
strength=coord.right,
+53
View File
@@ -0,0 +1,53 @@
"""Resolve the orthogonal Visual × Audio controls into render outputs (audio spec
§4/§6). The single source of truth for: whether the projector shows video, and
which audio url plays for a given (audio source, current altitude).
Replaces player/content.py's 7-way bundled table. Soundtrack follows the single
Altitude dial; off is silence. `white_noise` and `music` are deferred sources —
not in v1 (the live control is a simple Audio on/off toggle where on=soundtrack)."""
from __future__ import annotations
from dataclasses import dataclass
VISUAL_POSITIONS = frozenset({"on", "off"})
# v1 audio sources. The live UI is an Audio on/off toggle (on=soundtrack).
# "white_noise" and "music" are deferred (no live control) and intentionally absent.
AUDIO_SOURCES = frozenset({"off", "soundtrack"})
@dataclass(frozen=True)
class AudioResolution:
source: str
url: str | None
altitude_coupled: bool
def resolve_visual(position: str) -> bool:
"""Visual on/off → whether the renderer shows video (vs fade-to-black)."""
if position not in VISUAL_POSITIONS:
raise ValueError(
f"unknown visual position {position!r}; expected one of {sorted(VISUAL_POSITIONS)}"
)
return position == "on"
def resolve_audio_url(source: str, *, scale_audio: str) -> str | None:
"""The audio url for (source, current altitude): soundtrack → the current
scale's asset (or None if none authored); off → None. `scale_audio` is the ring
scale's `audio` path (relative to /media/audio/)."""
if source not in AUDIO_SOURCES:
raise ValueError(
f"unknown audio source {source!r}; expected one of {sorted(AUDIO_SOURCES)}"
)
if source == "off":
return None
# soundtrack — couple to the current altitude's asset
return f"/media/audio/{scale_audio}" if scale_audio else None
def resolve_audio(source: str, *, scale_audio: str) -> AudioResolution:
"""The full render.audio view: source, resolved url, and whether it follows the
Altitude dial (true only for soundtrack)."""
url = resolve_audio_url(source, scale_audio=scale_audio)
return AudioResolution(source=source, url=url, altitude_coupled=(source == "soundtrack"))
-42
View File
@@ -1,42 +0,0 @@
"""Resolve the 7-way content dial into an audio source + video on/off (§6).
The single source of truth for design §6's table: which audio source plays and
whether the projector shows video, for each dial position.
"""
from __future__ import annotations
from dataclasses import dataclass
# The four distinct audio sources. "white_noise" is generated at runtime;
# "music" is the public-domain classical pool; "audio_track" is the clip's own
# field audio; "none" is silence.
AUDIO_SOURCES = frozenset({"none", "white_noise", "music", "audio_track"})
@dataclass(frozen=True)
class ContentResolution:
audio_source: str
video: bool
# Design §6, one row per dial position.
_TABLE = {
"off": ContentResolution("none", False),
"white_noise": ContentResolution("white_noise", False),
"music": ContentResolution("music", False),
"audio_track": ContentResolution("audio_track", False),
"video": ContentResolution("none", True),
"music_video": ContentResolution("music", True),
"audio_video": ContentResolution("audio_track", True),
}
def resolve_content(position: str) -> ContentResolution:
"""Map a content-dial position to its audio source and video flag (§6)."""
try:
return _TABLE[position]
except KeyError:
raise ValueError(
f"unknown content position {position!r}; expected one of {sorted(_TABLE)}"
) from None
+15 -12
View File
@@ -1,20 +1,17 @@
"""Control-panel state: the data shape read from the Arduino over serial.
This is the serial-contract payload shared with sub-project 4 (firmware). It
models the full panel from design §6/§7: the 7-way content dial, the four
experience knobs (0..4), and the two intensity levels (volume, brightness).
The wire framing itself is the separate 3<->4 serial contract; this module is
the *decoded* form.
models the full panel from the audio spec §3: a Visual toggle (on/off) + an
Audio source selector (off/soundtrack; white-noise/music deferred), the four experience knobs
(0..4), and the two intensity levels (volume, brightness). The wire framing
itself is the separate 3<->4 serial contract; this module is the *decoded* form.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
# The seven positions of the content dial (design §6).
CONTENT_POSITIONS = frozenset(
{"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"}
)
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS
KNOB_FIELDS = ("left", "right", "dark", "light", "volume", "brightness")
KNOB_MIN = 0
@@ -27,7 +24,8 @@ class ControlsError(ValueError):
@dataclass(frozen=True)
class Controls:
content: str
visual: str
audio: str
left: int
right: int
dark: int
@@ -38,10 +36,15 @@ class Controls:
def validate_controls(c: Controls) -> None:
"""Raise ControlsError if the payload is structurally invalid."""
if c.content not in CONTENT_POSITIONS:
if c.visual not in VISUAL_POSITIONS:
raise ControlsError(
f"invalid content position {c.content!r}; "
f"expected one of {sorted(CONTENT_POSITIONS)}"
f"invalid visual position {c.visual!r}; "
f"expected one of {sorted(VISUAL_POSITIONS)}"
)
if c.audio not in AUDIO_SOURCES:
raise ControlsError(
f"invalid audio source {c.audio!r}; "
f"expected one of {sorted(AUDIO_SOURCES)}"
)
for name in KNOB_FIELDS:
value = getattr(c, name)
+89 -39
View File
@@ -63,6 +63,7 @@ class Scale:
id: str
clip_id: str
pool: tuple[str, ...] = ()
audio: str = "" # per-scale soundtrack, relative to /media/audio/ (audio spec §5.1)
@property
def members(self) -> tuple[str, ...]:
@@ -73,57 +74,61 @@ class Scale:
@dataclass(frozen=True)
class Transition:
"""A pre-baked zoom/warp morph along ONE ring edge.
"""A pre-baked zoom/warp morph between TWO specific clips in adjacent
altitudes.
`file` plays FORWARD when zooming inward (scales[i] -> scales[i+1] in ring
order); the renderer reverses it for the outward direction, so one clip
covers both directions of an edge.
`file` plays as-is: the manifest holds both directions as separate, directed
entries — a forward zoom-in (`<src>__<dst>.mp4`, descending) and its `.rev`
zoom-out companion (ascending). So a directed `(from_clip, to_clip)` pair maps
to exactly one file and the renderer needs no reverse logic at play time.
"""
from_clip: str
to_clip: str
file: str
model: str = ""
@dataclass(frozen=True)
class ScaleRing:
"""A closed ring of scales joined by per-edge transitions.
"""A closed ring of scales joined by per-clip-pair morphs.
Invariant: a ring of N>=2 scales has exactly N transitions (one per edge,
including the small->large wrap seam). A degenerate single-scale ring has no
edges. An empty ring is rejected.
Each `Transition` is a directed morph between two clips in adjacent altitudes
(both directions stored explicitly). A single-scale (or empty-pool) ring has
no morphs. An empty ring is rejected. The directed `(from_clip, to_clip) ->
file` lookup is built once for `morph_for`.
"""
scales: tuple[Scale, ...]
transitions: tuple[Transition, ...]
def __post_init__(self) -> None:
n = len(self.scales)
if n == 0:
if len(self.scales) == 0:
raise RingError("a ScaleRing needs at least one scale")
expected = 0 if n == 1 else n
if len(self.transitions) != expected:
raise RingError(
f"a {n}-scale ring needs {expected} transitions, "
f"got {len(self.transitions)}"
)
lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions}
object.__setattr__(self, "_morphs", lookup)
def __len__(self) -> int:
return len(self.scales)
def morph_for(self, from_clip: str, to_clip: str) -> str | None:
"""The morph file for the directed clip pair, or None if none was baked."""
return self._morphs.get((from_clip, to_clip))
@dataclass(frozen=True)
class TransitionStep:
"""One transition to play during a move: which edge clip, in which direction,
and the scale index it lands on.
"""One STRUCTURAL step of a move: which edge is crossed, in which direction,
and the scale index it lands on. The actual morph FILE is resolved later by
`resolve_move` (it depends on the clip picked for the destination), so this
step carries no file.
`blended` marks the single collapsed step of a fast-spin pass (see
`advance_ring`'s `fast_spin_threshold`): the renderer plays it as one quick
accelerated morph straight to the destination instead of a full transition.
`blended` marks a fast-spin step (see `advance_ring`'s `fast_spin_threshold`):
the renderer plays it as one quick accelerated morph rather than at 1x.
"""
edge: int
reversed: bool
file: str
to_index: int
blended: bool = False
@@ -206,31 +211,24 @@ def advance_ring(
if edge == n - 1:
wrapped = True
steps.append(
TransitionStep(
edge=edge,
reversed=reversed_,
file=ring.transitions[edge].file,
to_index=nxt,
)
TransitionStep(edge=edge, reversed=reversed_, to_index=nxt)
)
index = nxt
# Fast spin: collapse the whole chain to a single blended arrival pass. The
# landing index, seam-crossing, and arrival edge/direction are exactly those
# of the full chain — only the in-between transitions are dropped.
# Fast spin: play the WHOLE chain quickly (every crossed altitude still
# morphs, so the chained member->member morphs all show) — each step is just
# marked `blended` so the renderer accelerates it, rather than collapsing the
# chain to a single arrival pass.
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
arrival = steps[-1]
blended = TransitionStep(
edge=arrival.edge,
reversed=arrival.reversed,
file=arrival.file,
to_index=arrival.to_index,
blended=True,
fast_steps = tuple(
TransitionStep(edge=s.edge, reversed=s.reversed,
to_index=s.to_index, blended=True)
for s in steps
)
return RingMove(
from_index=start,
to_index=index,
steps=(blended,),
steps=fast_steps,
wrapped=wrapped,
fast=True,
)
@@ -238,3 +236,55 @@ def advance_ring(
return RingMove(
from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped
)
@dataclass(frozen=True)
class MorphStep:
"""One resolved morph to play while advancing: from the clip currently shown
to the chosen clip of the next altitude, the morph file, the index it lands
on, and whether it plays fast (a fast-spin pass). `file` is None if no morph
was baked for the pair (the renderer falls back to a plain cut)."""
from_clip: str
to_clip: str
file: str | None
to_index: int
blended: bool
@dataclass(frozen=True)
class ResolvedMove:
"""A move with each step's destination clip chosen and its morph resolved.
`target_clip_id` is the final clip the viewer locks onto."""
steps: tuple[MorphStep, ...]
target_clip_id: str
def resolve_move(
ring: ScaleRing,
move: RingMove,
from_clip_id: str,
picks: tuple[float, ...],
) -> ResolvedMove:
"""Choose the destination clip for each crossed altitude (injected `picks`,
one per step) and resolve the morph from the previous clip to it. The
destination clip is chosen BEFORE the morph, so the footage matches what we
land on; the morph direction (zoom-in vs zoom-out) is encoded in the directed
`(src, dst)` pair, which `morph_for` maps to the right baked file. The final
`target_clip_id` is the clip the viewer locks onto."""
if len(picks) != len(move.steps):
raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}")
steps: list[MorphStep] = []
src = from_clip_id
for st, r in zip(move.steps, picks):
dst = pick_clip_id(scale_at(ring, st.to_index), r)
steps.append(MorphStep(
from_clip=src,
to_clip=dst,
file=ring.morph_for(src, dst),
to_index=st.to_index,
blended=st.blended,
))
src = dst
return ResolvedMove(steps=tuple(steps), target_clip_id=src)
+14 -8
View File
@@ -20,7 +20,7 @@ from typing import Callable, Optional
from hef.selection import Coordinate
from player.alteration import RenderPlan, plan_alteration
from player.content import ContentResolution, resolve_content
from player.audio import resolve_visual
from player.controls import Controls
@@ -34,11 +34,14 @@ class TransitionKind:
@dataclass(frozen=True)
class Playback:
"""What should currently be playing."""
"""What should currently be playing. Visual and audio are orthogonal (audio
spec §2): `video` is whether the projector shows footage, `audio_source` is
the independent audio dial (off / soundtrack)."""
clip_id: Optional[str] # None = black walls
plan: Optional[RenderPlan] # None when black
content: ContentResolution
video: bool # whether footage is shown (Visual on/off)
audio_source: str # the Audio dial source (off/soundtrack)
volume: int
brightness: int
@@ -58,7 +61,8 @@ def _first(library):
_BLACK = Playback(
clip_id=None,
plan=None,
content=ContentResolution("none", False),
video=False,
audio_source="off",
volume=0,
brightness=0,
)
@@ -78,12 +82,13 @@ class Player:
self._current = _BLACK
def _resolve(self, controls: Controls) -> Playback:
content = resolve_content(controls.content)
if not content.video:
video = resolve_visual(controls.visual)
if not video:
return Playback(
clip_id=None,
plan=None,
content=content,
video=False,
audio_source=controls.audio,
volume=controls.volume,
brightness=controls.brightness,
)
@@ -92,7 +97,8 @@ class Player:
return Playback(
clip_id=clip.id,
plan=plan_alteration(coord),
content=content,
video=True,
audio_source=controls.audio,
volume=controls.volume,
brightness=controls.brightness,
)
+6
View File
@@ -24,3 +24,9 @@ sim = [
"uvicorn[standard]>=0.29",
"httpx>=0.27",
]
# Playwright E2E browser tests (audio spec §9). Install with the browser binary:
# pip install -e '.[e2e]' && python -m playwright install chromium
# The E2E suite skips cleanly when Playwright/Chromium is absent (headless box).
e2e = [
"pytest-playwright>=0.4",
]
@@ -0,0 +1,122 @@
# Session 0018.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-25T09-42 (PST)
> End: 2026-06-26T05-56 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: b96041e5-c682-49f5-88ee-380ea7abb6af
> Status: **FINALIZED**
## Launch prompt
```
Find a better public-domain nebula flythrough video for the cosmos scale (the current text-free SVS Orion clip lost quality when cropped to remove its text overlay), or upscale the existing one if no better source exists.
```
A content-and-polish session on the simulator's cosmos scale that grew, by
operator follow-ups, into four merged PRs: the nebula swap, two cache fixes, a
ring-landing artifact fix, and a Right-brain affect rework.
## Pre-state
- Branch `feature/ring-transitions-real` (later merged): HEAD `129bb23` had just
swapped the cosmos primary off the captioned/cropped JPL "Orion: Dust and Death"
clip onto the text-free SVS 30957 Orion flythrough.
- Sim served by a manually-launched `uvicorn ... --port 8000` (no `--reload`),
booted before the session's media changes.
- Affect channel gated on `min(left, right)` (session 0013 rule).
- Concurrent unfinalized sessions present (0015, 0017).
## Arc (turn by turn)
1. **Anchor + research.** Classified as planning-and-executing, claimed session
0018. Found the current cosmos clip was already the uncropped text-free SVS
30957 Orion (the crop damage the prompt described was already fixed at HEAD).
Researched NASA SVS: (a) the same Orion clip exists in 4K; (b) the Webb
**"Cosmic Cliffs"** Carina flythrough (SVS 31348) is a 4K PD alternative.
Offered three options; **operator chose #2 — switch to Cosmic Cliffs.**
2. **Cosmos swap (PR #21).** Downloaded the Carina 4K master
(`Clifs-3d-STScI.mp4`, 3840×2160, 60 fps, 103 s), sampled frames to find a
clean window, trimmed **1034 s** (clears the title card ~8 s and the
end-credit card ~102 s), ran `tools.pipeline.run.process_clip` → 4K master +
supersampled 1080p base (5.5 Mbps, crisper than the old 2.9 Mbps Orion encode).
Updated `build_pool_manifest.py` META (new `CCBY_WEBB` credit, Carina labels:
emission nebula / protostar, redshift→distance ≈7,500 ly), regenerated
`manifest.json` (also corrected stale JPL-Orion provenance) + all ring-edge
transitions, and `docs/content-candidate-pool.md`. Operator authorized the
merge → opened + merged **PR #21** (the whole `feature/ring-transitions-real`
line), synced main, deleted the local branch. Remote branch delete returned
HTTP 405 (known Gitea perm limit).
3. **"Still seeing Orion" — caching (PRs #22, #23).** Diagnosed: the sim server
(PID 33672) had been running since before the media swap and was launched
**without `--reload`**, holding the pre-swap manifest in memory; the browser
also held the old clip via the in-memory blob preload and a possible pre-
`no-cache` immutable HTTP entry. Restarted the server with `make sim-local`
(`--reload`). Shipped **PR #22** (`cache: "reload"` on the preload fetch), then
— at operator request for a permanent fix — **PR #23**: a content-hash
`GET /api/media-versions` endpoint (sha1[:12] per served file, cached by
mtime/size) and client `?v=<hash>` on every /media URL, so a re-baked clip's
URL changes with its bytes.
4. **Coast landing artifact (in PR #23).** Operator noticed zooming into coast
showed birdrock then switched to another clip. Root cause: the baked ring
transition lands on the scale **primary** (birdrock), but the rotating pool
picks a **random member** on landing — a non-primary pick hard-cut from primary
to chosen. Fix: `advance()` masks the swap behind a short fade through the
existing `#black` overlay when `target_clip_id !== ring.scales[to_index].clip_id`;
general across all pooled scales.
5. **Right-brain affect (PR #24).** Operator: emotions should be controlled by the
right brain, not the left. Reversed `affect.strength = min(left, right)`
`strength = right`; intensity keyed off Right; Left no longer gates emotions.
Updated engine + client comments + the affect design spec (revision note) +
tests. Live-verified: left=0/right=4 → strength 4; left=4/right=0 → 0.
## Cut state
- **`main` @ (session-0018 merges all landed):** PRs **#21** (`7da7446`), **#22**
(`aa56dfe`), **#23** (`5bb762a`), **#24** (`d0ad9a9`) — all confirmed in main's
ancestry. Session 0019 has since advanced main further (cosmos_miri pool member,
networked-control-surface spec, sim-local venv fix).
- **Tests:** 271 passed, 2 skipped (+ media-version tests + rewritten affect
tests).
- **Sim:** running on :8000 as `uvicorn --reload` (PID 50199, restarted this
session). The cosmos Cosmic Cliffs clip, content-hash cache-bust, coast fade,
and Right-only affect are all live and API/logic-verified.
- **No plan artifact** archived — leaf content/polish task executed directly
(no `superpowers:writing-plans` doc).
## Operator plate
- **Cosmic Cliffs looks "incredible"** (operator) — the cosmos primary is now the
Webb Carina flythrough.
- **Unverified by eye** (no Chrome on the box): the coast landing fade timing, and
the Right-only affect look (Left=0 + Right up should show emotion words over an
un-annotated scene). Worth a by-eye pass in `make sim-local`.
- **Pipeline §9:** changes merged to `main` (= done); no PPE/prod run — the E2E +
PPE machinery is still being built (§10.6), and the simulator is a local dev
surface. Not shipped to prod.
- **Left untouched (not this session's work):** session 0019's in-flight audio
work is dirty in the tree (`docs/audio-candidate-pool.md`, `.gitignore` audio
entries) — left for 0019 to finalize. Pre-existing unfinalized sessions 0015,
0017, 0019 remain `--INPROGRESS`.
## Deferred decisions
None logged — every call this session was operator-directed (the option-2 clip
choice, the permanent cache-bust request, the Right-brain affect direction).
## Next-session prompt
```
/goal By-eye review of session 0018's sim changes in `make sim-local` (:8000): the
Webb Cosmic Cliffs cosmos clip, the coast/pool landing fade (no hard cut to the
random member), and Right-only emotions (Left=0 + Right up shows feeling words over
an un-annotated scene). Tune the ~200ms fade timing if it feels off. Then continue
the experience/content line — deferred i2v ring transitions and/or the Pi renderer
(per [[simulator-first-before-hardware]]). Note: session 0019 separately owns the
audio pool + networked-control-surface work.
```
@@ -1,25 +0,0 @@
# Session 0018.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-25T09-42 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: b96041e5-c682-49f5-88ee-380ea7abb6af
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0018 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0018.0-TRANSCRIPT-2026-06-25T09-42--<end>.md form at session end.
## Launch prompt
```
Find a better public-domain nebula flythrough video for the cosmos scale (the current text-free SVS Orion clip lost quality when cropped to remove its text overlay), or upscale the existing one if no better source exists.
```
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,95 @@
# Session 0019.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-26T04-47 (PST)
> End: 2026-06-26T05-47 (PST)
> Type: brainstorming
> Posture: careful
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
> Anchor: ROADMAP sub-project 4 (Arduino Firmware / control panel) — reframed
> Status: **FINALIZED**
## Launch prompt
```
Let's start talking about hardware. Let's assume for the first version I'm ok having my computer or laptop do the rendering. We'd still want a controller but it could be connected to the computer via wifi, USB, or bluetooth and would send commands to the software we've already written that would adjust left brain, right brain, etc. We'd prototype this by having a controller simulator run on another device, such as an ipad (just riffing here) but eventually move to a physical remote running on an Arduino or similar
```
## Pre-state
- Simulator is a **FastAPI** app, **stateless**: the single browser holds knob
state in JS and POSTs to `/api/alteration` for a `RenderPlan`. Renderer and
controller are the same browser tab.
- Operator-facing control surface (sim today): Content (7-way `<select>`),
Altitude (endless circular dial across 5 scales), Left/Right (sliders 04),
Mood (slider 4…+4). (Dark+Light → Mood in s0013; Altitude dial in s0018.)
- ROADMAP sub-project 4 = "Arduino Firmware (control panel)" assuming a **Pi**
runs the room and the panel talks **USB serial** to it.
- Standing directive: simulator-first before hardware.
- Checkout shared with a concurrent audio-sourcing session (0017/0018), with
uncommitted `.gitignore` + untracked files on `main`.
## Arc
1. **Session gate / routing.** Classified the opening ("let's talk about
hardware… just riffing") as **brainstorming**; invoked `wgl-brainstorming`,
claimed session **0019** (`--type brainstorming`, careful posture). Three stale
`--INPROGRESS` sessions (0015/0017/0018) noted as leftovers, not live overlaps;
no worktree (the audio session's edits are unrelated/uncommitted).
2. **Orientation.** Read `docs/ROADMAP.md` + the simulator (`app.py`, `index.html`)
to ground the discussion; confirmed the real control surface and the stateless
architecture. Recognized the request as a **reframe** of sub-project 4.
3. **Brainstorming dialogue** (`superpowers:brainstorming`), decisions reached:
- **Intent:** both — decouple control/display cleanly AND want the handheld feel.
- **Transport (v1):** wifi/web (iPad opens a controller page off the FastAPI
server; least friction; Arduino transport left open).
- **Remote fidelity:** faithful twin — decide the physical panel inventory now,
iPad mirrors it.
- Operator refinement: **per-knob transition state** — live controls
(Left/Right/Mood) are instant; transitional ones (Altitude, Content) carry a
lifecycle so the remote's feedback is honest.
- **Architecture:** approach A — server-authoritative `SessionState` + **SSE**
push (chosen over polling / WebRTC).
4. **Design presented section by section** (roles → transition model → server
contract → two pages + Arduino seam → error/test/scope), each approved.
5. **Spec written** to
`docs/superpowers/specs/2026-06-26-networked-control-surface-design.md`,
committed on `feat/networked-control-surface`, pushed; operator approved going
straight to finalize.
6. **Finalize.** Single-repo app (CONTENT_REMOTE empty) → merging the branch IS
the submission. Discovered the shared checkout had switched to `main` (the
audio session) and `main` had advanced to PR #25; landed the spec via a safe
local `--no-ff` merge that added only the new spec file (their uncommitted work
untouched), pushed `main` (`229af60..f28a06c`), deleted the feature branch.
## Cut state
- `main` @ `f28a06c` — spec merged. Feature branch deleted.
- Spec `status: proposed` (careful posture); operator reviewed it live, approving
every section before write.
- No code written (brainstorming session). 0 new tests (none applicable).
- Concurrent audio session's uncommitted `.gitignore`/untracked files left intact.
## Deferred decisions
_None — no autonomous low-confidence calls. The two flagged design choices
(last-write-wins concurrency; no auth in v1) were presented to the operator and
approved in-session, not deferred._
## Operator plate
- Review the merged spec if desired:
`docs/superpowers/specs/2026-06-26-networked-control-surface-design.md` on `main`.
- Next: open a **writing-plans** session to turn the spec into an implementation
plan (the `/goal` below).
## Next /goal
```
/goal Write the implementation plan for the networked control surface, per docs/superpowers/specs/2026-06-26-networked-control-surface-design.md
```
@@ -0,0 +1,109 @@
# Session 0020.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-26T06-47 (PST)
> End: 2026-06-26T07-58 (PST)
> Type: writing-plans (extended into execution — operator approved "then build it" at the plan gate)
> Posture: careful (execution proceeded under explicit operator approval)
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
> Anchor: design docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md (R2a)
> Status: FINALIZED
## Launch prompt
> Write the implementation plan for the "Audio + Video — separated Visual & Audio
> controls" feature, then build it. (Spec:
> docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md.)
> Split the bundled 7-way Content selector into orthogonal Visual (on/off) ×
> Audio (off / soundtrack / white noise). Audio follows the single Altitude dial;
> white noise synthesized (ffmpeg anoisesrc). Reframe the (unbuilt) networked-
> control-surface spec §3/§5 as a clean pre-implementation edit. Honor the testing
> tiers; resolve the three §12 flagged decisions.
## Pre-state
- `main` clean at 99a751c. Two stale `--INPROGRESS` placeholders (0015, 0017) from
2026-06-24/25 — prior dead sessions, not live; left untouched.
- The networked-control-surface architecture (SSE / `/remote` / renderer split) was
**unbuilt** — the sim is a single fused page (`simulator/static/index.html`) talking
to FastAPI via `POST /api/alteration` (knobs) + `POST /api/ring/advance` (altitude).
- Audio assets (5 sourced ambiences) sat gitignored under `simulator/sample_media/audio/<scale>/`,
unreferenced by the manifest, unserved; no `<audio>` element anywhere; video muted.
## Session arc (turn by turn)
1. **Session gate → writing-plans.** Classified the two-part "write the plan, then
build it" prompt as the build track's entry; claimed session **0020** (`--type
writing-plans`, careful posture). Baseline clean; created branch `session-0020`.
2. **Scoping.** Read both specs + the audio candidate pool; dispatched an Explore
agent to map the current simulator. Key finding: the control-surface architecture
is unbuilt, so the audio spec's §6 server contract had no host. **Resolved by the
standing simulator-first directive:** build the audio feature into the current
single-page sim (ships sound now); reframe the control-surface spec on paper only;
do NOT build the SSE/`/remote`/renderer split (it's "v1 hardware," deferred). This
build is the "Content step" the audio spec §11 anticipates.
3. **Plan.** Wrote `docs/superpowers/plans/2026-06-26-audio-video-separated-controls.md`
(10 tasks, all testing tiers). Committed + pushed; presented at the careful-posture
plan gate. Operator answered **"Approve — build it."** §12 decisions resolved:
audio=live (~0.6 s crossfade), noise=pink, autoplay=tap-to-start overlay; asset
params 18 LUFS / mp3 / loop mirrors the proven video crossfade recipe.
4. **Build (TDD, commit per task):**
- T12: pure ffmpeg audio builders (`tools/pipeline/audio_ops.py`) + runner
(`audio_run.py`) + `simulator/build_audio_media.py`; produced the 5 normalized
loops + pink-noise bed (gitignored).
- T3: `Scale.audio` manifest field + read/emit/serialize + `SCALE_AUDIO`; manifest
regenerated (clean diff — only 5 audio lines).
- T46: pure `player/audio.py` resolver; retired `player/content.py` (+ test);
reframed `player/controls.py` + `player/state.py`; `/api/alteration` validates
visual+audio and returns `render.video` + server-resolved `render.audio.url` from
`altitude_index`. Full Python suite green.
- T78: client — Content `<select>` → Visual toggle + Audio selector; A/B `<audio>`
gain-crossfade layer; tap-to-start gesture. Smoke-tested page + media (200s).
- T9: reframed the networked-control-surface spec §3/§4/§5/§6; recorded §12
resolutions; ROADMAP note.
- T10: Playwright E2E (`[e2e]` extra). Installed Chromium and ran it for real —
surfaced + fixed three client issues (see Cut state). **3 E2E pass**, full suite
**288 passed, 2 skipped**.
5. **Verify + ship.** Screenshotted the running app (cosmos plays, soundtrack audibly
playing `cosmos/pillars.loop.mp3`, split controls visible, zero JS errors). Merged
`session-0020``main` (`--no-ff`) and pushed. (`gh` PR create denied — Gitea host;
merged locally with branch→merge discipline.)
## Deferred decisions
_No low-confidence autonomous calls. The scope decision (current-sim vs full control
surface) was approved at the plan gate; the three §12 resolutions were operator-
delegated by the launch prompt ("resolve the three flagged decisions"); asset params
(18 LUFS, mp3, pink) follow the spec's stated direction._
## Cut state
- `main` @ `aa76cd1` (merge of session-0020), clean, synced with origin.
- **288 tests + 3 Playwright E2E pass; 2 skipped** (opt-in media integration).
- Three client robustness fixes the E2E drove out (worth remembering):
- `fadeVolume` uses `setInterval`, not `requestAnimationFrame` (rAF stalls when the
tab is backgrounded → the gain ramp hung in headless).
- The start gesture drops per-element "priming": a single page click grants Chrome
autoplay document-wide; priming srcless `<audio>` only hung the handler / polluted
the paused-state.
- `applyAudio` does not `await` `play()` (awaiting a srcless/buffering play can hang).
- Plan archival: this app has **no content repo** (`CONTENT_REMOTE` empty), so the plan
was not archived to a `plans/` collection — it lives in-repo at
`docs/superpowers/plans/` and is already on `main`.
- §9 pipeline: localhost + E2E stage is **green**; there is no PPE/prod infra for this
simulator-first art app (deferred with the Pi renderer), so no further ship stage ran.
## Operator plate (what's true now / next)
- The simulator has **sound**: per-altitude soundtracks that follow the Altitude dial +
a pink white-noise bed, behind orthogonal **Visual** × **Audio** controls. The unheard
audio wants a by-ear review/tune.
- Deferred (already roadmap-tracked, not re-parked in memory): the **music** layer
(reserved Audio position, no assets), the networked-control-surface SSE/`/remote`
build, i2v ring transitions, the Pi renderer.
## Next /goal
```
/goal review/tune the new audio by ear in the sim (per-altitude soundtracks + pink white-noise + the ~0.6s A/B crossfade; tap-to-start), then source/compose the deferred per-altitude music layer (the reserved Audio position), per docs/audio-candidate-pool.md
```
@@ -0,0 +1,88 @@
# Session 0021.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-26T08-12 (PST)
> End: 2026-06-26T08-20 (PST)
> Type: executing-plans (operator-driven hotfix/revision of session 0020's audio feature)
> Posture: yolo
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
> Status: FINALIZED
## Launch prompt
> Show video unchecked doesn't turn off the video. Make "show video" just "Video"
> and have a toggle like "Dev Mode" has. I can't hear any audio when "soundtrack
> (follows altitude)" is selected. Let's just have Audio as off/on like Video for
> now and get rid of white noise.
## Pre-state
- Session 0020 had just merged the audio feature to `main` (Visual×Audio split,
per-altitude soundtracks, pink white-noise, A/B crossfade). The operator tested it
live and reported three issues.
## Session arc
1. **Diagnosis (the key work).** Both reported bugs were reproduced *only* in the
operator's real browser — neither appeared in headless Chromium **or** WebKit
(installed `playwright install webkit` to test Safari's engine). Root causes:
- **Video-off doesn't blank:** `#vid` (`<video>`) and `#paint` (WebGL `<canvas>`)
are GPU-composited layers; the `#black` cover had *auto* z-index, so on a real
GPU (Safari/Chrome) the video layer can composite *above* it despite DOM order.
Headless software-raster hides this.
- **No soundtrack audio:** real Safari/iOS unlocks an `<audio>` element only when
`play()` is called *synchronously inside* the user gesture; the 0020 gesture
played in an async continuation (after `await fetch`), which Chrome allows but
Safari blocks. Headless WebKit relaxes autoplay, so it passed.
This is the session's main lesson: **Playwright headless relaxes both GPU
compositing and autoplay**, so the 0020 E2E couldn't have caught either — real
device / by-eye review is required.
2. **Fixes (branch `session-0021`):**
- **Video** + **Audio** are now Dev-Mode-style on/off toggles (`.dev-switch`),
wired on `change`. Audio on → soundtrack; **white-noise deferred** (removed from
the live control; `AUDIO_SOURCES={off,soundtrack}`; the pure pink-noise
machinery + tests stay for later).
- Video-off: `.black` gets `z-index:50` + `translateZ(0)` (own layer) **and** the
video layers are set `opacity:0` on video-off (belt-and-suspenders blanking).
- Safari audio: the tap-to-start gesture now primes both A/B `<audio>` elements
**synchronously in-gesture** (set src, vol 0, `play().then(pause)`), unlocking
the later async crossfades.
- Server: `player/audio.py` resolver + `controls.py`/`state.py` + `/api/alteration`
drop white-noise; `build_audio_media.py` stops emitting the noise bed.
3. **Verify + ship.** Updated unit/contract/E2E tests; rewrote the 3 E2E for the new
toggles (click the visible `.dev-switch-track`; wait for the opacity transition).
**288 passed, 2 skipped.** Re-verified in **both Chromium and WebKit**: Audio toggle
plays `cosmos/pillars.loop.mp3`, video-off sets vid+paint opacity 0 with `#black`
shown, zero JS errors; screenshotted the new toggle UI. Merged `session-0021`
`main` and pushed. (Also untracked a stray `docs/.threads/*.json` editor artifact
swept in by `git add -A`, and gitignored `.threads/`.)
## Deferred decisions
_No low-confidence calls. All three changes were explicit operator requests; the
root-cause fixes (GPU z-index/layer-hide; Safari in-gesture unlock) are the standard
remedies for these well-known browser behaviors._
## Cut state
- `main` @ `fddb6d6` (merge of session-0021), clean, pushed.
- **288 tests + 3 Playwright E2E pass** (2 skipped: opt-in media integration).
- White-noise is deferred, not deleted: `tools/pipeline/audio_ops.white_noise_args` +
`audio_run.generate_white_noise` + their tests remain; only the live control + the
build-script emission were removed.
- **Unvalidated by this session:** real-device audio (Safari autoplay) and real-GPU
video-off — headless can't exercise either; needs the operator's by-ear/by-eye pass.
## Operator plate
- Video + Audio are simple on/off toggles (Dev-Mode style). Audio on = the current
altitude's soundtrack. The two real-browser bugs should now be fixed — but please
confirm on a real device (especially iPad/Safari).
- Deferred: white-noise (easy to re-add as a 3rd Audio position), the per-altitude
music layer, the networked-control-surface build, Pi renderer.
## Next /goal
```
/goal by-ear/by-eye review the audio + Video/Audio toggles on a REAL device (esp. iPad/Safari — headless can't validate Safari autoplay or GPU compositing), then source/compose the deferred per-altitude music layer per docs/audio-candidate-pool.md (or re-enable white-noise as a 3rd Audio position if wanted)
```
@@ -0,0 +1,79 @@
# Session 0022.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-26T08-40 (PST)
> End: 2026-06-26T08-50 (PST)
> Type: executing-plans (operator-driven follow-up to the audio feature)
> Posture: yolo
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
> Status: FINALIZED
## Launch prompt
> (1) "It shouldn't say 'Tap to Start Sound'… it can start with both sound and video
> off but immediately go to the app. We should have a 'Loading Universe…' loading
> screen until all media has been downloaded and the experience is ready to run."
> (2) "I still can't hear the sound, even in private mode."
> (3) "The first time video is turned on, also turn on audio. If audio is turned on
> before video after the app loads, just audio should turn on."
## Pre-state
- Sessions 0020/0021 had shipped the audio feature + a 0021 "Safari fix" (tap-to-start
overlay that primed A/B `<audio>` elements). The operator reported audio STILL
silent on their real browser (even in private mode), plus the UX requests above.
## Session arc
1. **Why the 0021 fix failed.** It primed *srcless* A/B elements in the tap gesture
and played the real audio in an *async continuation* (after `await fetch`). Safari
unlocks an element only when `play()` runs **synchronously inside** the gesture and
on an element **with a src** — so neither condition was met. Headless WebKit relaxes
autoplay, which is why automated tests never caught it.
2. **Redesign (client-only, branch `session-0022`):**
- **Single `<audio id="aud">`** element (dropped A/B). It's played **synchronously
inside the toggle's own `change` handler** — the canonical Safari unlock. Altitude
changes reuse the now-unlocked element with a brief fade-swap.
- **"Loading Universe…" splash** (`#loading`, in the HTML so it shows pre-JS): `main()`
now `await`s `preloadAllMedia()` (driving a progress bar), then `hideLoading()`.
Replaces the tap-to-start wall.
- **Both toggles default OFF** (black + silent at start) — no autoplay needed until a
toggle is flipped.
- **First Video-on couples Audio:** the Video `change` handler, on the first on,
sets `#audio` checked and plays — in the same gesture (so it unlocks on Safari).
Audio toggled on first stays audio-only.
- Server contract unchanged (`{off,soundtrack}`); the client now drives playback
directly from the ring's `scale.audio`, ignoring `render.audio`.
3. **Verify + ship.** Rewrote the 5 E2E for the new flow (single `#aud`, no gesture,
loading wait, the coupling, video-off blanking). Caught a test bug — `#black.hidden`
is `display:none`, so `wait_for_selector` default (visible) timed out; switched to
`state="attached"`. **290 passed, 2 skipped; 5 E2E pass.** Verified in headless
**Chromium AND WebKit**: first-Video-on couples audio, soundtrack plays, video shows,
zero JS errors; screenshotted the splash + running app. Merged to `main`.
## Deferred decisions
_No low-confidence calls — all three were explicit operator requests. The
single-element synchronous-in-gesture play is the standard Safari remedy._
## Cut state
- `main` @ `4ac39c9`, clean, pushed. 290 tests + 5 Playwright E2E pass.
- **Honest caveat — still unverifiable headlessly:** every engine Playwright ships
(Chromium, WebKit) relaxes autoplay, so I cannot *prove* real-Safari/iOS now plays.
The fix is the canonical pattern (sync `play()` in the gesture, real src) and is a
genuine change from 0021's broken approach — but it needs the operator's real-device ear.
## Operator plate
- No tap wall: "Loading Universe…" → app with both toggles off (black/silent). Flip
**Video** → video + audio together; flip **Audio** alone → audio only.
- If sound is STILL silent on the real device, that points to something beyond autoplay
(codec/MIME, file, or element error) — next session should capture `aud.error` and the
exact browser.
## Next /goal
```
/goal confirm on a REAL device (esp. iPad/Safari) that audio is now audible when Video/Audio is toggled on and the "Loading Universe…" → app flow feels right; if still silent, capture aud.error + the exact browser. Then the deferred per-altitude music layer per docs/audio-candidate-pool.md
```
+12
View File
@@ -52,5 +52,17 @@
},
"0018": {
"title": ""
},
"0019": {
"title": ""
},
"0020": {
"title": ""
},
"0021": {
"title": ""
},
"0022": {
"title": ""
}
}
+92 -14
View File
@@ -26,15 +26,21 @@ from player.alteration import (
plan_alteration,
render_plan_to_dict,
)
from player.content import resolve_content
from player.controls import CONTENT_POSITIONS
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS, resolve_audio, resolve_visual
from player.ring import (
DEFAULT_FAST_SPIN_THRESHOLD,
ResolvedMove,
advance_ring,
pick_clip_id,
resolve_move,
scale_at,
)
from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict
from simulator.clips import (
load_manifest,
load_ring,
resolved_move_to_dict,
ring_to_dict,
)
STATIC_DIR = Path(__file__).parent / "static"
MEDIA_DIR = Path(__file__).parent / "sample_media"
@@ -59,8 +65,38 @@ def _asset_version() -> str:
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
# Content-hash tokens for served media, so the client can append `?v=<hash>` to a
# /media URL. A clip re-baked under the SAME path (e.g. a re-sourced cosmos base)
# changes its hash → its URL → a fresh fetch, busting any cached prior bytes
# permanently (even an immutable-pinned entry a plain reload can't revalidate).
# Cached by (mtime_ns, size) so the full-file hash is recomputed only when the
# file actually changes — a re-bake is picked up without a server restart.
_media_hash_cache: dict[str, tuple[int, int, str]] = {}
def _media_version(rel: str) -> Optional[str]:
"""Short content hash of the media file at `rel` under MEDIA_DIR, or None if
it's absent. Cheap on repeat calls: re-hashes only when (mtime, size) change."""
path = MEDIA_DIR / rel
try:
st = path.stat()
except OSError:
return None
cached = _media_hash_cache.get(rel)
if cached and cached[0] == st.st_mtime_ns and cached[1] == st.st_size:
return cached[2]
h = hashlib.sha1()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
token = h.hexdigest()[:12]
_media_hash_cache[rel] = (st.st_mtime_ns, st.st_size, token)
return token
class ControlsModel(BaseModel):
content: str
visual: str
audio: str
left: int = Field(ge=0, le=4)
right: int = Field(ge=0, le=4)
dark: int = Field(ge=0, le=4)
@@ -77,12 +113,14 @@ class CalibrationModel(BaseModel):
class AlterationRequest(BaseModel):
controls: ControlsModel
altitude_index: int = 0
calibration: Optional[CalibrationModel] = None
class RingAdvanceRequest(BaseModel):
from_index: int = 0
delta: int
from_clip_id: str = ""
class AuthorTrackRequest(BaseModel):
@@ -131,8 +169,10 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
@app.post("/api/alteration")
def api_alteration(req: AlterationRequest):
c = req.controls
if c.content not in CONTENT_POSITIONS:
raise HTTPException(status_code=422, detail=f"invalid content {c.content!r}")
if c.visual not in VISUAL_POSITIONS:
raise HTTPException(status_code=422, detail=f"invalid visual {c.visual!r}")
if c.audio not in AUDIO_SOURCES:
raise HTTPException(status_code=422, detail=f"invalid audio {c.audio!r}")
coord = Coordinate(c.left, c.right, c.dark, c.light)
cal = (
Calibration(
@@ -144,10 +184,22 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
else DEFAULT_CALIBRATION
)
plan = plan_alteration(coord, cal)
content = resolve_content(c.content)
# Resolve the soundtrack url against the CURRENT altitude (server-side, audio
# spec §6.1). White-noise/off ignore the scale; soundtrack follows it.
scale_audio = ""
if app.state.ring is not None:
scale_audio = scale_at(app.state.ring, req.altitude_index).audio
audio = resolve_audio(c.audio, scale_audio=scale_audio)
return {
"plan": render_plan_to_dict(plan),
"content": {"audio_source": content.audio_source, "video": content.video},
"render": {
"video": {"shown": resolve_visual(c.visual)},
"audio": {
"source": audio.source,
"url": audio.url,
"altitude_coupled": audio.altitude_coupled,
},
},
}
@app.get("/dev/version")
@@ -160,6 +212,24 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
def api_clips():
return {"clips": [c.to_dict() for c in app.state.clips]}
@app.get("/api/media-versions")
def api_media_versions():
"""Per-file content-hash tokens the client appends to /media URLs as
`?v=<hash>`. Covers every served file: each clip's base footage plus every
ring transition morph (both directions are explicit entries). A re-baked
clip's hash changes, so its URL changes and the browser refetches — a
permanent cache-bust."""
files = {c.base_file for c in app.state.clips}
if app.state.ring is not None:
for t in app.state.ring.transitions:
files.add(t.file)
versions = {}
for f in sorted(files):
v = _media_version(f)
if v:
versions[f] = v
return {"versions": versions}
@app.get("/api/ring")
def api_ring():
if app.state.ring is None:
@@ -176,12 +246,20 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
req.delta,
fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD,
)
# Rotating pool: pick a random member of the LANDED scale (content-pipeline
# §11.1). A delta=0 advance is the initial / re-roll pick (a no-op move that
# still yields a fresh random clip). The pure pick takes injected randomness.
landed = scale_at(app.state.ring, move.to_index)
chosen = pick_clip_id(landed, random.random())
return ring_move_to_dict(move, app.state.ring, chosen)
if move.steps:
# Choose the destination member of each crossed altitude FIRST (one
# random draw per step — content-pipeline §11.1), then resolve the
# morph from the prior clip to it, so the footage matches what we land
# on. The currently-shown clip threads in as `from_clip_id`.
src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
picks = tuple(random.random() for _ in move.steps)
resolved = resolve_move(app.state.ring, move, src, picks)
else:
# A no-op move (delta=0) is the initial / re-roll pick: a fresh random
# member of the current scale, no morph.
landed = scale_at(app.state.ring, move.to_index)
resolved = ResolvedMove(steps=(), target_clip_id=pick_clip_id(landed, random.random()))
return resolved_move_to_dict(move, resolved)
@app.post("/api/author/track")
def api_author_track(req: AuthorTrackRequest):
+45
View File
@@ -0,0 +1,45 @@
"""Production pass for the 5 per-altitude soundtracks (audio spec §5.3). The audio
analogue of build_pool_manifest.py --media: read the sourced ambience clips
(re-downloadable per docs/audio-candidate-pool.md), and make each a seamless
loudness-normalized loop.
Media is gitignored; this script is the reproducible record. Outputs land under
simulator/sample_media/audio/<scale>/<name>.loop.mp3, served at /media/audio/...
Run: python simulator/build_audio_media.py
(White-noise is deferred — the live Audio control is a simple on/off soundtrack
toggle. `tools.pipeline.audio_run.generate_white_noise` remains for when it's
wanted, but no noise bed is built here.)
"""
from __future__ import annotations
from pathlib import Path
from tools.pipeline.audio_run import process_soundtrack
AUDIO = Path(__file__).parent / "sample_media" / "audio"
# scale -> (sourced raw file, production output file), both under audio/<scale>/.
# Sources per docs/audio-candidate-pool.md (the "✓ KEEP" picks).
SOURCES: dict[str, tuple[str, str]] = {
"cosmos": ("pillars.mp3", "pillars.loop.mp3"),
"orbit": ("spaceamb.mp3", "spaceamb.loop.mp3"),
"coast": ("waves.mp3", "waves.loop.mp3"),
"reef": ("soundscape.mp3", "soundscape.loop.mp3"),
"abyss": ("whale.wav", "whale.loop.mp3"),
}
def main() -> None:
for scale, (raw, out) in SOURCES.items():
src = AUDIO / scale / raw
if not src.exists():
print(f" SKIP {scale}: source missing ({src}) — re-source per audio-candidate-pool.md")
continue
dst = process_soundtrack(src, AUDIO / scale / out)
print(f" produced {dst.relative_to(AUDIO.parent)}")
if __name__ == "__main__":
main()
+55 -23
View File
@@ -38,7 +38,7 @@ MANIFEST = MEDIA / "manifest.json"
# --- Pools: scale id -> ordered clip ids (primary first) (content-candidate-pool.md) ---
POOLS: dict[str, list[str]] = {
"cosmos": ["cosmos", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
"cosmos": ["cosmos", "cosmos_miri", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
"orbit": ["orbit_planetearth", "orbit_crewobs", "orbit_bluemarble"],
"coast": ["coast_birdrock", "coast_surfgrass", "coast_elkbeach", "coast_drakesbeach"],
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_snapper", "reef_coralspacific"],
@@ -48,6 +48,16 @@ POOLS: dict[str, list[str]] = {
# Ring order (large -> small, wraps): cosmos -> orbit -> coast -> reef -> abyss -> cosmos.
RING_ORDER = ["cosmos", "orbit", "coast", "reef", "abyss"]
# Per-scale soundtrack (audio spec §5.1) — the production-pass output of
# simulator/build_audio_media.py, served at /media/audio/<path>.
SCALE_AUDIO: dict[str, str] = {
"cosmos": "cosmos/pillars.loop.mp3",
"orbit": "orbit/spaceamb.loop.mp3",
"coast": "coast/waves.loop.mp3",
"reef": "reef/soundscape.loop.mp3",
"abyss": "abyss/whale.loop.mp3",
}
PD = "public-domain (US Gov, 17 U.S.C. §105)"
PD_NPS = "public-domain (NPS, no © — 17 U.S.C. §105)"
CCBY_STSCI = "CC-BY-class — credit NASA, ESA, STScI"
@@ -59,6 +69,10 @@ META: dict[str, tuple[str, str, str]] = {
"cosmos": ("Cosmic Cliffs — Carina Nebula flythrough (NASA/ESA/CSA/STScI)", CCBY_WEBB,
"NASA SVS 31348 Clifs-3d-STScI (Exploring the Cosmic Cliffs in 3D, Webb NIRCam); "
"4K source, trim 1034s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough."),
"cosmos_miri": ("Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)", CCBY_WEBB,
"ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, "
"trim 426s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold "
"dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free."),
"cosmos_galaxies": ("Flying Through Galaxies (NASA SVS)", PD,
"NASA SVS a014950 14950_Galaxies_FlyThrough_4k; trim 832s, crossfade-loop"),
"cosmos_hudf": ("Hubble Ultra Deep Field zoom (NASA/ESA/STScI)", CCBY_STSCI,
@@ -167,6 +181,11 @@ LABELS: dict[str, list[dict]] = {
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · a newborn star, <1 Myr old"], [0.54, 0.12, 0.12, 0.14]),
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_miri": [
static_label("detected.nebula", 4, ["dust", "nebula", "dust ridge", "dust ridge · mid-infrared glow of the Carina cliffs"], [0.10, 0.45, 0.7, 0.45]),
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · its dust laid bare in mid-infrared"], [0.48, 0.18, 0.14, 0.16]),
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_galaxies": [
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
measure("measure.distance", "~Mly", [0.06, 0.06, 0.18, 0.08], 3),
@@ -348,12 +367,20 @@ def build_manifest() -> dict:
for scale in RING_ORDER:
for clip_id in POOLS[scale]:
clips.append(_clip_entry(scale, clip_id))
scales = [{"id": s, "clip_id": POOLS[s][0], "pool": POOLS[s]} for s in RING_ORDER]
scales = [
{"id": s, "clip_id": POOLS[s][0], "pool": POOLS[s], "audio": SCALE_AUDIO[s]}
for s in RING_ORDER
]
transitions = []
n = len(RING_ORDER)
for i in range(n):
a, b = RING_ORDER[i], RING_ORDER[(i + 1) % n]
transitions.append({"file": f"transitions/{a}-{b}.mp4", "model": "placeholder-zoom"})
for H, L in _adjacent_edges(): # H higher altitude, L lower
for h in POOLS[H]:
for l in POOLS[L]:
transitions.append({"from": h, "to": l,
"file": f"transitions/{h}__{l}.mp4", # zoom IN (descend)
"model": "placeholder-zoom"})
transitions.append({"from": l, "to": h,
"file": f"transitions/{h}__{l}.rev.mp4", # zoom OUT (ascend)
"model": "placeholder-zoom"})
return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
@@ -366,14 +393,19 @@ def _ffmpeg() -> str:
return imageio_ffmpeg.get_ffmpeg_exe()
def _make_transition(ff: str, scale_a: str, scale_b: str) -> Path:
"""A zoom/warp morph between two scales, from their PRIMARY pool members'
bases (mirrors setup_scales_media.py but keyed by scale->primary). This is the
recipe behind the well-liked orbit-coast edge; generate_media() now applies it
uniformly to every edge so none carry stale footage from an earlier ring."""
a = MEDIA / POOLS[scale_a][0] / "base.mp4"
b = MEDIA / POOLS[scale_b][0] / "base.mp4"
out = MEDIA / "transitions" / f"{scale_a}-{scale_b}.mp4"
def _adjacent_edges() -> list[tuple[str, str]]:
"""Ordered (higher, lower) altitude edges around the ring, including the wrap."""
n = len(RING_ORDER)
return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)]
def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
"""A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked
orbit-coast recipe), keyed by clip id so every adjacent member pair gets its
own morph. Forward = zoom IN (descend src->dst)."""
a = MEDIA / src_clip / "base.mp4"
b = MEDIA / dst_clip / "base.mp4"
out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4"
out.parent.mkdir(parents=True, exist_ok=True)
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
subprocess.run([
@@ -398,16 +430,16 @@ def _make_reverse(ff: str, forward: Path) -> Path:
def generate_media() -> None:
"""(Re)build EVERY ring-edge transition from the current real primary bases —
the orbit-coast recipe applied uniformly, overwriting any stale clips left from
an earlier ring — plus a reversed companion per edge for zoom-out moves."""
"""Bake EVERY adjacent member-pair morph from real bases — forward (zoom in,
descend) plus its reversed companion (zoom out, ascend), for every pair of
clips in adjacent altitudes."""
ff = _ffmpeg()
n = len(RING_ORDER)
for i in range(n):
a, b = RING_ORDER[i], RING_ORDER[(i + 1) % n]
fwd = _make_transition(ff, a, b)
_make_reverse(ff, fwd)
print(f"generated transitions/{a}-{b}.mp4 (+ .rev) from real bases")
for H, L in _adjacent_edges():
for h in POOLS[H]:
for l in POOLS[L]:
fwd = _make_transition(ff, h, l)
_make_reverse(ff, fwd)
print(f"generated {len(POOLS[H]) * len(POOLS[L])} {H}__{L} morphs (+ .rev) from real bases")
def main(argv: list[str]) -> None:
+27 -21
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from player.ring import RingMove, Scale, ScaleRing, Transition, scale_at
from player.ring import ResolvedMove, RingMove, Scale, ScaleRing, Transition
@dataclass(frozen=True)
@@ -82,7 +82,7 @@ def _scale_from_dict(s: dict[str, Any]) -> Scale:
"""
pool = tuple(s.get("pool", []))
clip_id = s.get("clip_id") or (pool[0] if pool else "")
return Scale(id=s["id"], clip_id=clip_id, pool=pool)
return Scale(id=s["id"], clip_id=clip_id, pool=pool, audio=s.get("audio", ""))
def load_ring(path: str | Path) -> ScaleRing | None:
@@ -99,7 +99,12 @@ def load_ring(path: str | Path) -> ScaleRing | None:
return None
scales = tuple(_scale_from_dict(s) for s in ring.get("scales", []))
transitions = tuple(
Transition(file=t["file"], model=t.get("model", ""))
Transition(
from_clip=t.get("from", ""),
to_clip=t.get("to", ""),
file=t["file"],
model=t.get("model", ""),
)
for t in ring.get("transitions", [])
)
return ScaleRing(scales=scales, transitions=transitions)
@@ -119,6 +124,7 @@ def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict:
"id": s.id,
"clip_id": s.clip_id,
"title": titles.get(s.clip_id, s.id),
"audio": s.audio,
"pool": [
{"clip_id": cid, "title": titles.get(cid, cid)}
for cid in s.members
@@ -126,35 +132,35 @@ def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict:
}
for s in ring.scales
],
"transitions": [{"file": t.file, "model": t.model} for t in ring.transitions],
"transitions": [
{"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model}
for t in ring.transitions
],
}
def ring_move_to_dict(
move: RingMove, ring: ScaleRing, chosen_clip_id: str | None = None
) -> dict:
"""JSON form of an encoder move: the landing scale's clip and the ordered
transition clips to play (with direction). `fast` flags a collapsed fast-spin
pass; the single step then carries `blended` so the renderer plays it quick.
def resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict:
"""JSON form of a RESOLVED encoder move: where it lands, the locked clip, and
the ordered chained morphs. Each step carries the clip it morphs FROM (the
clip currently shown), the chosen clip it morphs TO, and the matching morph
file (None if no morph was baked — the renderer plain-cuts). `fast` marks a
fast spin; each step then carries `blended` so the renderer accelerates it.
`target_clip_id` is the pool member the player should load on landing. The
caller passes the random pick (`pick_clip_id(landed_scale, random.random())`,
content-pipeline §11.1); when omitted it falls back to the scale's primary
`clip_id` (deterministic — keeps pre-pool callers/tests working)."""
`target_clip_id` is the final clip the viewer locks onto."""
return {
"from_index": move.from_index,
"to_index": move.to_index,
"wrapped": move.wrapped,
"fast": move.fast,
"target_clip_id": chosen_clip_id or scale_at(ring, move.to_index).clip_id,
"target_clip_id": resolved.target_clip_id,
"steps": [
{
"edge": st.edge,
"reversed": st.reversed,
"file": st.file,
"to_index": st.to_index,
"blended": st.blended,
"from_clip": s.from_clip,
"to_clip": s.to_clip,
"file": s.file,
"to_index": s.to_index,
"blended": s.blended,
}
for st in move.steps
for s in resolved.steps
],
}
+34
View File
@@ -0,0 +1,34 @@
# Simulator E2E (Playwright)
Browser end-to-end tests for the Human Experience Filter simulator. The §9
pipeline requires an E2E browser tier for any UI surface; this is that tier.
## What it covers
`tests/altitude-lock.spec.ts` drives the real simulator and asserts the
altitude-morph behavior:
- Zooming to a new altitude plays the morph that **matches the clip it lands on**
(forward zoom-in, and `.rev` zoom-out).
- After landing, the clip is **locked** — it does not change while parked (no
secondary swap, no re-roll).
The played morph paths are read from `window.__hefMorphs` (a diagnostic array
populated by `advance()` in `static/app.js`), which is reliable even when a morph
is served from the in-memory blob cache. The locked clip is read from the
Dev-Mode `#scale-name` readout.
## Running
Prerequisites: the project Python venv on PATH (the Playwright `webServer` boots
`uvicorn simulator.app:app` from the repo root), Node, and the Chromium browser.
```bash
# from this directory (simulator/e2e)
npm install
npx playwright install chromium
npm test
```
`playwright.config.ts` starts uvicorn on port 8099 automatically (`reuseExistingServer`
locally) and points the tests at it.
+1
View File
@@ -0,0 +1 @@
../@playwright/test/cli.js
+1
View File
@@ -0,0 +1 @@
../playwright-core/cli.js
+55
View File
@@ -0,0 +1,55 @@
{
"name": "hef-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Portions Copyright (c) Microsoft Corporation.
Portions Copyright 2017 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Playwright
Copyright (c) Microsoft Corporation
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
+318
View File
@@ -0,0 +1,318 @@
# 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-149.0.7827.55-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-151.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord)
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
Playwright is a framework for web automation and testing. It drives Chromium, Firefox, and WebKit with a single API — in your tests, in your scripts, and as a tool for AI agents.
## Get Started
Choose the path that fits your workflow:
| | Best for | Install |
|---|---|---|
| **[Playwright Test](#playwright-test)** | End-to-end testing | `npm init playwright@latest` |
| **[Playwright CLI](#playwright-cli)** | Coding agents (Claude Code, Copilot) | `npm i -g @playwright/cli@latest` |
| **[Playwright MCP](#playwright-mcp)** | AI agents and LLM-driven automation | `npx @playwright/mcp@latest` |
| **[Playwright Library](#playwright-library)** | Browser automation scripts | `npm i playwright` |
| **[VS Code Extension](#vs-code-extension)** | Test authoring and debugging in VS Code | [Install from Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) |
---
## Playwright Test
Playwright Test is a full-featured test runner built for end-to-end testing. It runs tests across Chromium, Firefox, and WebKit with full browser isolation, auto-waiting, and web-first assertions.
### Install
```bash
npm init playwright@latest
```
Or add manually:
```bash
npm i -D @playwright/test
npx playwright install
```
### Write a test
```TypeScript
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
await page.getByRole('link', { name: 'Get started' }).click();
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
```
### Run tests
```bash
npx playwright test
```
Tests run in parallel across all configured browsers, in headless mode by default. Each test gets a fresh browser context — full isolation with near-zero overhead.
### Key capabilities
**Auto-wait and web-first assertions.** No artificial timeouts. Playwright waits for elements to be actionable, and assertions automatically retry until conditions are met.
**Locators.** Find elements with resilient locators that mirror how users see the page:
```TypeScript
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByPlaceholder('Search...')
page.getByTestId('login-form')
```
**Test isolation.** Each test runs in its own browser context — equivalent to a fresh browser profile. Save authentication state once and reuse it across tests:
```TypeScript
// Save state after login
await page.context().storageState({ path: 'auth.json' });
// Reuse in other tests
test.use({ storageState: 'auth.json' });
```
**Tracing.** Capture execution traces, screenshots, and videos on failure. Inspect every action, DOM snapshot, network request, and console message in the [Trace Viewer](https://playwright.dev/docs/trace-viewer):
```TypeScript
// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});
```
```bash
npx playwright show-trace trace.zip
```
<!-- TODO: screenshot of trace viewer -->
**Parallelism.** Tests run in parallel by default across all configured browsers.
[Full testing documentation](https://playwright.dev/docs/intro)
---
## Playwright CLI
[Playwright CLI](https://github.com/microsoft/playwright-cli) is a command-line interface for browser automation designed for coding agents. It's more token-efficient than MCP — commands avoid loading large tool schemas and accessibility trees into the model context.
### Install
```bash
npm install -g @playwright/cli@latest
```
Optionally install skills for richer agent integration:
```bash
playwright-cli install --skills
```
### Usage
Point your coding agent at a task:
```
Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli.
Take screenshots for all successful and failing scenarios.
```
Or run commands directly:
```bash
playwright-cli open https://demo.playwright.dev/todomvc/ --headed
playwright-cli type "Buy groceries"
playwright-cli press Enter
playwright-cli screenshot
```
### Session monitoring
Use `playwright-cli show` to open a visual dashboard with live screencast previews of all running browser sessions. Click any session to zoom in and take remote control.
```bash
playwright-cli show
```
<!-- TODO: screenshot of playwright-cli show dashboard -->
[Full CLI documentation](https://playwright.dev/agent-cli/introduction) | [GitHub](https://github.com/microsoft/playwright-cli)
---
## Playwright MCP
The [Playwright MCP server](https://github.com/microsoft/playwright-mcp) gives AI agents full browser control through the [Model Context Protocol](https://modelcontextprotocol.io). Agents interact with pages using structured accessibility snapshots — no vision models or screenshots required.
### Setup
Add to your MCP client (VS Code, Cursor, Claude Desktop, Windsurf, etc.):
```json
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
```
**One-click install for VS Code:**
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20MCP%20Server&color=0098FF" alt="Install in VS Code" />](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
**For Claude Code:**
```bash
claude mcp add playwright npx @playwright/mcp@latest
```
### How it works
Ask your AI assistant to interact with any web page:
```
Navigate to https://demo.playwright.dev/todomvc and add a few todo items.
```
The agent sees the page as a structured accessibility tree:
```
- heading "todos" [level=1]
- textbox "What needs to be done?" [ref=e5]
- listitem:
- checkbox "Toggle Todo" [ref=e10]
- text: "Buy groceries"
```
It uses element refs like `e5` and `e10` to click, type, and interact — deterministically and without visual ambiguity. Tools cover navigation, form filling, screenshots, network mocking, storage management, and more.
[Full MCP documentation](https://playwright.dev/mcp/introduction) | [GitHub](https://github.com/microsoft/playwright-mcp)
---
## Playwright Library
Use `playwright` as a library for browser automation scripts — web scraping, PDF generation, screenshot capture, and any workflow that needs programmatic browser control without a test runner.
### Install
```bash
npm i playwright
```
### Examples
**Take a screenshot:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://playwright.dev/');
await page.screenshot({ path: 'screenshot.png' });
await browser.close();
```
**Generate a PDF:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://playwright.dev/');
await page.pdf({ path: 'page.pdf', format: 'A4' });
await browser.close();
```
**Emulate a mobile device:**
```TypeScript
import { chromium, devices } from 'playwright';
const browser = await chromium.launch();
const context = await browser.newContext(devices['iPhone 15']);
const page = await context.newPage();
await page.goto('https://playwright.dev/');
await page.screenshot({ path: 'mobile.png' });
await browser.close();
```
**Intercept network requests:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
await page.goto('https://playwright.dev/');
await browser.close();
```
[Library documentation](https://playwright.dev/docs/library) | [API reference](https://playwright.dev/docs/api/class-playwright)
---
## VS Code Extension
The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) brings test running, debugging, and code generation directly into your editor.
<!-- TODO: hero screenshot of VS Code with Playwright sidebar -->
**Run and debug tests** from the editor with a single click. Set breakpoints, inspect variables, and step through test execution with a live browser view.
**Generate tests with CodeGen.** Click "Record new" to open a browser — navigate and interact with your app while Playwright writes the test code for you.
**Pick locators.** Hover over any element in the browser to see the best available locator, then click to copy it to your clipboard.
**Trace Viewer integration.** Enable "Show Trace Viewer" in the sidebar to get a full execution trace after each test run — DOM snapshots, network requests, console logs, and screenshots at every step.
[Install the extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | [VS Code guide](https://playwright.dev/docs/getting-started-vscode)
---
## Cross-Browser Support
| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium<sup>1</sup> <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->26.5<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->151.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
Headless and headed execution on all platforms. <sup>1</sup> Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default.
## Other Languages
Playwright is also available for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), and [Java](https://playwright.dev/java/docs/intro).
## Resources
* [Documentation](https://playwright.dev)
* [API reference](https://playwright.dev/docs/api/class-playwright)
* [MCP server](https://github.com/microsoft/playwright-mcp)
* [CLI for coding agents](https://github.com/microsoft/playwright-cli)
* [VS Code extension](https://github.com/microsoft/playwright-vscode)
* [Contribution guide](CONTRIBUTING.md)
* [Changelog](https://github.com/microsoft/playwright/releases)
* [Discord](https://aka.ms/playwright/discord)
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { program } = require('playwright/lib/program');
program.parse(process.argv);
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from 'playwright/test';
export { default } from 'playwright/test';
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
module.exports = require('playwright/test');
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from 'playwright/test';
export { default } from 'playwright/test';
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@playwright/test",
"version": "1.61.1",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.mjs",
"require": "./index.js",
"default": "./index.js"
},
"./cli": "./cli.js",
"./package.json": "./package.json",
"./reporter": "./reporter.js"
},
"bin": {
"playwright": "cli.js"
},
"scripts": {},
"dependencies": {
"playwright": "1.61.1"
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from 'playwright/types/testReporter';
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// We only export types in reporter.d.ts.
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// We only export types in reporter.d.ts.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Portions Copyright (c) Microsoft Corporation.
Portions Copyright 2017 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Playwright
Copyright (c) Microsoft Corporation
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
+3
View File
@@ -0,0 +1,3 @@
# playwright-core
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
+13
View File
@@ -0,0 +1,13 @@
microsoft/playwright-core
THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
This package bundles third-party software inside individual files under
`lib/`. Each bundled output has a sidecar `<bundle>.js.LICENSE` file next
to it listing every npm package whose source was inlined into that
bundle, together with the full license text for each.
For example:
- lib/utilsBundle.js.LICENSE
This project incorporates components from the projects listed below. The original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise.
@@ -0,0 +1,5 @@
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
# check if running on Windows Server
if ($osInfo.ProductType -eq 3) {
Install-WindowsFeature Server-Media-Foundation
}
+33
View File
@@ -0,0 +1,33 @@
$ErrorActionPreference = 'Stop'
# This script sets up a WSL distribution that will be used to run WebKit.
$Distribution = "playwright"
$Username = "pwuser"
$distributions = (wsl --list --quiet) -split "\r?\n"
if ($distributions -contains $Distribution) {
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
} else {
Write-Host "Installing new WSL distribution '$Distribution'..."
$VhdSize = "10GB"
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
}
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
$initScript = @"
if [ ! -f "/home/$Username/node/bin/node" ]; then
mkdir -p /home/$Username/node
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
sudo -u $Username echo 'export PATH=/home/$Username/node/bin:\`$PATH' >> /home/$Username/.profile
fi
/home/$Username/node/bin/node cli.js install-deps webkit
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
"@ -replace "\r\n", "`n"
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
Write-Host "Done!"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old beta if any.
if dpkg --get-selections | grep -q "^google-chrome-beta[[:space:]]*install$" >/dev/null; then
apt-get remove -y google-chrome-beta
fi
# 2. Update apt lists (needed to install curl and chrome dependencies)
apt-get update
# 3. Install curl to download chrome
if ! command -v curl >/dev/null; then
apt-get install -y curl
fi
# 4. download chrome beta from dl.google.com and install it.
cd /tmp
curl -L -O https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
apt-get install -y ./google-chrome-beta_current_amd64.deb
rm -rf ./google-chrome-beta_current_amd64.deb
cd -
google-chrome-beta --version
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
set -x
rm -rf "/Applications/Google Chrome Beta.app"
cd /tmp
curl -L --retry 3 -o ./googlechromebeta.dmg https://dl.google.com/chrome/mac/universal/beta/googlechromebeta.dmg
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechromebeta.dmg ./googlechromebeta.dmg
cp -pR "/Volumes/googlechromebeta.dmg/Google Chrome Beta.app" /Applications
hdiutil detach /Volumes/googlechromebeta.dmg
rm -rf /tmp/googlechromebeta.dmg
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --version
@@ -0,0 +1,24 @@
$ErrorActionPreference = 'Stop'
$url = 'https://dl.google.com/tag/s/dl/chrome/install/beta/googlechromebetastandaloneenterprise64.msi'
Write-Host "Downloading Google Chrome Beta"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\google-chrome-beta.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Google Chrome Beta"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Google\\Chrome Beta\\Application\\chrome.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Google Chrome Beta."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old stable if any.
if dpkg --get-selections | grep -q "^google-chrome[[:space:]]*install$" >/dev/null; then
apt-get remove -y google-chrome
fi
# 2. Update apt lists (needed to install curl and chrome dependencies)
apt-get update
# 3. Install curl to download chrome
if ! command -v curl >/dev/null; then
apt-get install -y curl
fi
# 4. download chrome stable from dl.google.com and install it.
cd /tmp
curl -L -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt-get install -y ./google-chrome-stable_current_amd64.deb
rm -rf ./google-chrome-stable_current_amd64.deb
cd -
google-chrome --version
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
set -x
rm -rf "/Applications/Google Chrome.app"
cd /tmp
curl -L --retry 3 -o ./googlechrome.dmg https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechrome.dmg ./googlechrome.dmg
cp -pR "/Volumes/googlechrome.dmg/Google Chrome.app" /Applications
hdiutil detach /Volumes/googlechrome.dmg
rm -rf /tmp/googlechrome.dmg
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
@@ -0,0 +1,24 @@
$ErrorActionPreference = 'Stop'
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\google-chrome.msi"
Write-Host "Downloading Google Chrome"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Google Chrome"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Google\\Chrome\\Application\\chrome.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Google Chrome."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old beta if any.
if dpkg --get-selections | grep -q "^microsoft-edge-beta[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-beta
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-beta
microsoft-edge-beta --version
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl -L --retry 3 -o ./msedge_beta.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_beta.pkg -target /
rm -rf /tmp/msedge_beta.pkg
/Applications/Microsoft\ Edge\ Beta.app/Contents/MacOS/Microsoft\ Edge\ Beta --version
@@ -0,0 +1,23 @@
$ErrorActionPreference = 'Stop'
$url = $args[0]
Write-Host "Downloading Microsoft Edge Beta"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\microsoft-edge-beta.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Microsoft Edge Beta"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Microsoft\\Edge Beta\\Application\\msedge.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Microsoft Edge Beta."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old dev if any.
if dpkg --get-selections | grep -q "^microsoft-edge-dev[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-dev
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-dev
microsoft-edge-dev --version
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl -L --retry 3 -o ./msedge_dev.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_dev.pkg -target /
rm -rf /tmp/msedge_dev.pkg
/Applications/Microsoft\ Edge\ Dev.app/Contents/MacOS/Microsoft\ Edge\ Dev --version
@@ -0,0 +1,23 @@
$ErrorActionPreference = 'Stop'
$url = $args[0]
Write-Host "Downloading Microsoft Edge Dev"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\microsoft-edge-dev.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Microsoft Edge Dev"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Microsoft\\Edge Dev\\Application\\msedge.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Microsoft Edge Dev."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old stable if any.
if dpkg --get-selections | grep -q "^microsoft-edge-stable[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-stable
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-stable.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-stable
microsoft-edge-stable --version
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl -L --retry 3 -o ./msedge_stable.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_stable.pkg -target /
rm -rf /tmp/msedge_stable.pkg
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version
@@ -0,0 +1,24 @@
$ErrorActionPreference = 'Stop'
$url = $args[0]
Write-Host "Downloading Microsoft Edge"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\microsoft-edge-stable.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Microsoft Edge"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Microsoft\\Edge\\Application\\msedge.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Microsoft Edge."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+81
View File
@@ -0,0 +1,81 @@
{
"comment": "Do not edit this file, use utils/roll_browser.js",
"browsers": [
{
"name": "chromium",
"revision": "1228",
"installByDefault": true,
"browserVersion": "149.0.7827.55",
"title": "Chrome for Testing"
},
{
"name": "chromium-headless-shell",
"revision": "1228",
"installByDefault": true,
"browserVersion": "149.0.7827.55",
"title": "Chrome Headless Shell"
},
{
"name": "chromium-tip-of-tree",
"revision": "1432",
"installByDefault": false,
"browserVersion": "151.0.7886.0",
"title": "Chrome Canary for Testing"
},
{
"name": "chromium-tip-of-tree-headless-shell",
"revision": "1432",
"installByDefault": false,
"browserVersion": "151.0.7886.0",
"title": "Chrome Canary Headless Shell"
},
{
"name": "firefox",
"revision": "1532",
"installByDefault": true,
"browserVersion": "151.0",
"title": "Firefox"
},
{
"name": "firefox-beta",
"revision": "1526",
"installByDefault": false,
"browserVersion": "152.0b1",
"title": "Firefox Beta"
},
{
"name": "webkit",
"revision": "2311",
"installByDefault": true,
"revisionOverrides": {
"mac14": "2251",
"mac14-arm64": "2251",
"debian11-x64": "2105",
"debian11-arm64": "2105",
"ubuntu20.04-x64": "2092",
"ubuntu20.04-arm64": "2092"
},
"browserVersion": "26.5",
"title": "WebKit"
},
{
"name": "ffmpeg",
"revision": "1011",
"installByDefault": true,
"revisionOverrides": {
"mac12": "1010",
"mac12-arm64": "1010"
}
},
{
"name": "winldd",
"revision": "1007",
"installByDefault": false
},
{
"name": "android",
"revision": "1001",
"installByDefault": false
}
]
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { libCli, libCliTestStub } = require('./lib/coreBundle');
const { program } = require('./lib/utilsBundle');
libCli.decorateProgram(program);
libCliTestStub.decorateProgram(program);
program.parse(process.argv);
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './types/types';
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
require('./lib/bootstrap');
module.exports = require('./lib/coreBundle').inprocess.playwright;
+28
View File
@@ -0,0 +1,28 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import playwright from './index.js';
export const chromium = playwright.chromium;
export const firefox = playwright.firefox;
export const webkit = playwright.webkit;
export const selectors = playwright.selectors;
export const devices = playwright.devices;
export const errors = playwright.errors;
export const request = playwright.request;
export const _electron = playwright._electron;
export const _android = playwright._android;
export default playwright;
+88
View File
@@ -0,0 +1,88 @@
"use strict";
const minimumMajorNodeVersion = 18;
const currentNodeVersion = process.versions.node;
const major = +currentNodeVersion.split(".")[0];
if (major < minimumMajorNodeVersion) {
console.error(
"You are running Node.js " + currentNodeVersion + `.
Playwright requires Node.js ${minimumMajorNodeVersion} or higher.
Please update your version of Node.js.`
);
process.exit(1);
}
if (process.env.PW_INSTRUMENT_MODULES) {
const Module = require("module");
const originalLoad = Module._load;
const root = { name: "<root>", selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
let current = root;
const stack = [];
Module._load = function(request, _parent, _isMain) {
const node = { name: request, selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
current.children.push(node);
stack.push(current);
current = node;
const start = performance.now();
let result;
try {
result = originalLoad.apply(this, arguments);
} catch (e) {
current = stack.pop();
current.children.pop();
throw e;
}
const duration = performance.now() - start;
node.totalMs = duration;
node.selfMs = Math.max(0, duration - node.childrenMs);
current = stack.pop();
current.childrenMs += duration;
return result;
};
process.on("exit", () => {
function printTree(node, prefix, isLast, lines2, depth) {
if (node.totalMs < 1 && depth > 0)
return;
const connector = depth === 0 ? "" : isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
const time = `${node.totalMs.toFixed(1).padStart(8)}ms`;
const self = node.children.length ? ` (self: ${node.selfMs.toFixed(1)}ms)` : "";
lines2.push(`${time} ${prefix}${connector}${node.name}${self}`);
const childPrefix = prefix + (depth === 0 ? "" : isLast ? " " : "\u2502 ");
const sorted2 = node.children.slice().sort((a, b) => b.totalMs - a.totalMs);
for (let i = 0; i < sorted2.length; i++)
printTree(sorted2[i], childPrefix, i === sorted2.length - 1, lines2, depth + 1);
}
let totalModules = 0;
function count(n) {
totalModules++;
n.children.forEach(count);
}
root.children.forEach(count);
const lines = [];
const sorted = root.children.slice().sort((a, b) => b.totalMs - a.totalMs);
for (let i = 0; i < sorted.length; i++)
printTree(sorted[i], "", i === sorted.length - 1, lines, 0);
const totalMs = root.children.reduce((s, c) => s + c.totalMs, 0);
process.stderr.write(`
--- Module load tree: ${totalModules} modules, ${totalMs.toFixed(0)}ms total ---
` + lines.join("\n") + "\n");
const flat = /* @__PURE__ */ new Map();
function gather(n) {
const existing = flat.get(n.name);
if (existing) {
existing.selfMs += n.selfMs;
existing.totalMs += n.totalMs;
existing.count++;
} else {
flat.set(n.name, { selfMs: n.selfMs, totalMs: n.totalMs, count: 1 });
}
n.children.forEach(gather);
}
root.children.forEach(gather);
const top50 = [...flat.entries()].sort((a, b) => b[1].selfMs - a[1].selfMs).slice(0, 50);
const flatLines = top50.map(
([mod, { selfMs, totalMs: totalMs2, count: count2 }]) => `${selfMs.toFixed(1).padStart(8)}ms self ${totalMs2.toFixed(1).padStart(8)}ms total (x${String(count2).padStart(3)}) ${mod}`
);
process.stderr.write(`
--- Top 50 modules by self time ---
` + flatLines.join("\n") + "\n");
});
}
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
"use strict";
var import_coreBundle = require("../coreBundle");
const { program } = require("../utilsBundle");
import_coreBundle.tools.decorateCliDaemonProgram(program);
void program.parseAsync();
+3
View File
@@ -0,0 +1,3 @@
"use strict";
var import_coreBundle = require("../coreBundle");
import_coreBundle.tools.openDashboardApp();
+10
View File
@@ -0,0 +1,10 @@
"use strict";
var import_coreBundle = require("../coreBundle");
var import_package = require("../package");
const { program } = require("../utilsBundle");
const p = program.version("Version " + import_package.packageJSON.version).name("Playwright MCP");
import_coreBundle.tools.decorateMCPCommand(p);
program.parseAsync(process.argv).catch((e) => {
console.error(e.message);
import_coreBundle.utils.gracefullyProcessExitDoNotHang(1);
});
@@ -0,0 +1,3 @@
"use strict";
var import_coreBundle = require("../coreBundle");
import_coreBundle.registry.runOopDownloadBrowserMain();
+50
View File
@@ -0,0 +1,50 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var package_exports = {};
__export(package_exports, {
binPath: () => binPath,
libPath: () => libPath,
packageJSON: () => packageJSON,
packageRoot: () => packageRoot
});
module.exports = __toCommonJS(package_exports);
var import_path = __toESM(require("path"));
const packageRoot = import_path.default.join(__dirname, "..");
const packageJSON = require(import_path.default.join(packageRoot, "package.json"));
const binPath = import_path.default.join(packageRoot, "bin");
function libPath(...parts) {
return import_path.default.join(packageRoot, "lib", ...parts);
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
binPath,
libPath,
packageJSON,
packageRoot
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
"use strict";
// packages/playwright-core/src/server/electron/loader.ts
var import_electron = require("electron");
// packages/playwright-core/src/server/chromium/chromiumSwitches.ts
var disabledFeatures = [
// See https://github.com/microsoft/playwright/issues/14047
"AvoidUnnecessaryBeforeUnloadCheckSync",
// See https://github.com/microsoft/playwright/issues/38568
"BoundaryEventDispatchTracksNodeRemoval",
"DestroyProfileOnBrowserClose",
// See https://github.com/microsoft/playwright/pull/13854
"DialMediaRouteProvider",
"GlobalMediaControls",
// See https://github.com/microsoft/playwright/pull/27605
"HttpsUpgrades",
// Hides the Lens feature in the URL address bar. Its not working in unofficial builds.
"LensOverlay",
// See https://github.com/microsoft/playwright/pull/8162
"MediaRouter",
// See https://github.com/microsoft/playwright/issues/28023
"PaintHolding",
// See https://github.com/microsoft/playwright/issues/32230
"ThirdPartyStoragePartitioning",
// See https://github.com/microsoft/playwright/issues/16126
"Translate",
// See https://issues.chromium.org/u/1/issues/435410220
"AutoDeElevate",
// See https://github.com/microsoft/playwright/issues/37714
"RenderDocument",
// Prevents downloading optimization hints on startup.
"OptimizationHints",
// Disables forced sign-in in Edge.
"msForceBrowserSignIn",
// Disables updating the preferred version in LaunchServices preferences on mac.
"msEdgeUpdateLaunchServicesPreferredVersion"
].filter(Boolean);
var chromiumSwitches = (options) => [
"--disable-field-trial-config",
// https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md
"--disable-background-networking",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-back-forward-cache",
// Avoids surprises like main request not being intercepted during page.goBack().
"--disable-breakpad",
"--disable-client-side-phishing-detection",
"--disable-component-extensions-with-background-pages",
"--disable-component-update",
// Avoids unneeded network activity after startup.
"--no-default-browser-check",
"--disable-default-apps",
"--disable-dev-shm-usage",
"--disable-edgeupdater",
// Disables Edge-specific updater on mac.
"--disable-extensions",
"--disable-features=" + disabledFeatures.join(","),
process.env.PLAYWRIGHT_LEGACY_SCREENSHOT ? "" : "--enable-features=CDPScreenshotNewSurface",
"--allow-pre-commit-input",
"--disable-hang-monitor",
"--disable-ipc-flooding-protection",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-renderer-backgrounding",
"--force-color-profile=srgb",
"--metrics-recording-only",
"--no-first-run",
"--password-store=basic",
"--use-mock-keychain",
// See https://chromium-review.googlesource.com/c/chromium/src/+/2436773
"--no-service-autorun",
"--export-tagged-pdf",
// https://chromium-review.googlesource.com/c/chromium/src/+/4853540
"--disable-search-engine-choice-screen",
// https://issues.chromium.org/41491762
"--unsafely-disable-devtools-self-xss-warnings",
// Edge can potentially restart on Windows (msRelaunchNoCompatLayer) which looses its file descriptors (stdout/stderr) and CDP (3/4). Disable until fixed upstream.
"--edge-skip-compat-layer-relaunch",
// This disables Chrome for Testing infobar that is visible in the persistent context.
// The switch is ignored everywhere else, including Chromium/Chrome/Edge.
"--disable-infobars",
// Less annoying popups.
"--disable-search-engine-choice-screen",
// Prevents the "three dots" menu crash in IdentityManager::HasPrimaryAccount for ephemeral contexts.
options?.android ? "" : "--disable-sync"
].filter(Boolean);
// packages/playwright-core/src/server/electron/loader.ts
process.argv.splice(1, process.argv.indexOf("--remote-debugging-port=0"));
for (const arg of chromiumSwitches()) {
const match = arg.match(/--([^=]*)=?(.*)/);
import_electron.app.commandLine.appendSwitch(match[1], match[2]);
}
var originalWhenReady = import_electron.app.whenReady();
var originalEmit = import_electron.app.emit.bind(import_electron.app);
var readyEventArgs;
import_electron.app.emit = (event, ...args) => {
if (event === "ready") {
readyEventArgs = args;
return import_electron.app.listenerCount("ready") > 0;
}
return originalEmit(event, ...args);
};
var isReady = false;
var whenReadyCallback;
var whenReadyPromise = new Promise((f) => whenReadyCallback = f);
import_electron.app.isReady = () => isReady;
import_electron.app.whenReady = () => whenReadyPromise;
globalThis.__playwright_run = async () => {
const event = await originalWhenReady;
isReady = true;
whenReadyCallback(event);
originalEmit("ready", ...readyEventArgs);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,354 @@
packages/playwright-core/lib/serverRegistry.js
THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
The following npm packages are inlined into this bundle.
- anymatch@3.1.3 (https://github.com/micromatch/anymatch)
- binary-extensions@2.3.0 (https://github.com/sindresorhus/binary-extensions)
- braces@3.0.3 (https://github.com/micromatch/braces)
- chokidar@3.6.0 (https://github.com/paulmillr/chokidar)
- fill-range@7.1.1 (https://github.com/jonschlinkert/fill-range)
- glob-parent@5.1.2 (https://github.com/gulpjs/glob-parent)
- is-binary-path@2.1.0 (https://github.com/sindresorhus/is-binary-path)
- is-extglob@2.1.1 (https://github.com/jonschlinkert/is-extglob)
- is-glob@4.0.3 (https://github.com/micromatch/is-glob)
- is-number@7.0.0 (https://github.com/jonschlinkert/is-number)
- normalize-path@3.0.0 (https://github.com/jonschlinkert/normalize-path)
- picomatch@2.3.2 (https://github.com/micromatch/picomatch)
- readdirp@3.6.0 (https://github.com/paulmillr/readdirp)
- to-regex-range@5.0.1 (https://github.com/micromatch/to-regex-range)
%% anymatch@3.1.3 NOTICES AND INFORMATION BEGIN HERE
=========================================
The ISC License
Copyright (c) 2019 Elan Shanker, Paul Miller (https://paulmillr.com)
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
=========================================
END OF anymatch@3.1.3 NOTICES AND INFORMATION
%% binary-extensions@2.3.0 NOTICES AND INFORMATION BEGIN HERE
=========================================
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
Copyright (c) Paul Miller (https://paulmillr.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
=========================================
END OF binary-extensions@2.3.0 NOTICES AND INFORMATION
%% braces@3.0.3 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2014-present, Jon Schlinkert.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF braces@3.0.3 NOTICES AND INFORMATION
%% chokidar@3.6.0 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2012-2019 Paul Miller (https://paulmillr.com), Elan Shanker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF chokidar@3.6.0 NOTICES AND INFORMATION
%% fill-range@7.1.1 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2014-present, Jon Schlinkert.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF fill-range@7.1.1 NOTICES AND INFORMATION
%% glob-parent@5.1.2 NOTICES AND INFORMATION BEGIN HERE
=========================================
The ISC License
Copyright (c) 2015, 2019 Elan Shanker
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
=========================================
END OF glob-parent@5.1.2 NOTICES AND INFORMATION
%% is-binary-path@2.1.0 NOTICES AND INFORMATION BEGIN HERE
=========================================
MIT License
Copyright (c) 2019 Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com), Paul Miller (https://paulmillr.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
=========================================
END OF is-binary-path@2.1.0 NOTICES AND INFORMATION
%% is-extglob@2.1.1 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2014-2016, Jon Schlinkert
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF is-extglob@2.1.1 NOTICES AND INFORMATION
%% is-glob@4.0.3 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2014-2017, Jon Schlinkert.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF is-glob@4.0.3 NOTICES AND INFORMATION
%% is-number@7.0.0 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2014-present, Jon Schlinkert.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF is-number@7.0.0 NOTICES AND INFORMATION
%% normalize-path@3.0.0 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2014-2018, Jon Schlinkert.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF normalize-path@3.0.0 NOTICES AND INFORMATION
%% picomatch@2.3.2 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2017-present, Jon Schlinkert.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF picomatch@2.3.2 NOTICES AND INFORMATION
%% readdirp@3.6.0 NOTICES AND INFORMATION BEGIN HERE
=========================================
MIT License
Copyright (c) 2012-2019 Thorsten Lorenz, Paul Miller (https://paulmillr.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
=========================================
END OF readdirp@3.6.0 NOTICES AND INFORMATION
%% to-regex-range@5.0.1 NOTICES AND INFORMATION BEGIN HERE
=========================================
The MIT License (MIT)
Copyright (c) 2015-present, Jon Schlinkert.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
=========================================
END OF to-regex-range@5.0.1 NOTICES AND INFORMATION
SUMMARY
=========================================
Total Packages: 14
=========================================
@@ -0,0 +1,141 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var channelSessions_exports = {};
__export(channelSessions_exports, {
isKnownChannel: () => isKnownChannel,
listChannelSessions: () => listChannelSessions
});
module.exports = __toCommonJS(channelSessions_exports);
var import_fs = __toESM(require("fs"));
var import_net = __toESM(require("net"));
var import_os = __toESM(require("os"));
var import_path = __toESM(require("path"));
var import_extension = require("../utils/extension");
function isKnownChannel(name) {
return channelToUserDataDir.has(name);
}
async function listChannelSessions() {
if (process.env.PWTEST_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST)
return [];
const result = [];
for (const [channel, dirs] of channelToUserDataDir) {
const userDataDir = dirs[process.platform];
if (!userDataDir)
continue;
if (!await pathExists(userDataDir))
continue;
const [endpoint, extensionInstalled] = await Promise.all([
readEndpoint(userDataDir),
(0, import_extension.isPlaywrightExtensionInstalled)(userDataDir)
]);
result.push({ channel, userDataDir, endpoint, extensionInstalled });
}
return result;
}
async function pathExists(p) {
try {
await import_fs.default.promises.access(p);
return true;
} catch {
return false;
}
}
async function readEndpoint(userDataDir) {
let contents;
try {
contents = await import_fs.default.promises.readFile(import_path.default.join(userDataDir, "DevToolsActivePort"), "utf-8");
} catch {
return void 0;
}
const port = parseInt(contents.trim().split("\n")[0], 10);
if (!Number.isFinite(port))
return void 0;
if (!await isPortOpen(port))
return void 0;
return `http://localhost:${port}`;
}
async function isPortOpen(port) {
return new Promise((resolve) => {
const socket = import_net.default.createConnection(port, "127.0.0.1");
const done = (value) => {
socket.destroy();
resolve(value);
};
socket.once("connect", () => done(true));
socket.once("error", () => done(false));
socket.setTimeout(250, () => done(false));
});
}
const channelToUserDataDir = /* @__PURE__ */ new Map([
["chrome", {
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome"),
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome"),
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome", "User Data")
}],
["chrome-beta", {
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-beta"),
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Beta"),
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome Beta", "User Data")
}],
["chrome-dev", {
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-unstable"),
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Dev"),
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome Dev", "User Data")
}],
["chrome-canary", {
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-canary"),
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Canary"),
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome SxS", "User Data")
}],
["msedge", {
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge"),
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge"),
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge", "User Data")
}],
["msedge-beta", {
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-beta"),
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Beta"),
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge Beta", "User Data")
}],
["msedge-dev", {
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-dev"),
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Dev"),
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge Dev", "User Data")
}],
["msedge-canary", {
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-canary"),
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Canary"),
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge SxS", "User Data")
}]
]);
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
isKnownChannel,
listChannelSessions
});
@@ -0,0 +1,6 @@
"use strict";
var import_program = require("./program");
(0, import_program.program)().catch((e) => {
console.error(e.message);
process.exit(1);
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,128 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var minimist_exports = {};
__export(minimist_exports, {
minimist: () => minimist
});
module.exports = __toCommonJS(minimist_exports);
function minimist(args, opts) {
if (!opts)
opts = {};
const bools = {};
const strings = {};
for (const key of toArray(opts.boolean))
bools[key] = true;
for (const key of toArray(opts.string))
strings[key] = true;
const argv = { _: [] };
function setArg(key, val) {
if (argv[key] === void 0 || bools[key] || typeof argv[key] === "boolean")
argv[key] = val;
else if (Array.isArray(argv[key]))
argv[key].push(val);
else
argv[key] = [argv[key], val];
}
let notFlags = [];
const doubleDashIndex = args.indexOf("--");
if (doubleDashIndex !== -1) {
notFlags = args.slice(doubleDashIndex + 1);
args = args.slice(0, doubleDashIndex);
}
for (let i = 0; i < args.length; i++) {
const arg = args[i];
let key;
let next;
if (/^--.+=/.test(arg)) {
const m = arg.match(/^--([^=]+)=([\s\S]*)$/);
key = m[1];
if (bools[key])
throw new Error(`boolean option '--${key}' should not be passed with '=value', use '--${key}' or '--no-${key}' instead`);
setArg(key, m[2]);
} else if (/^--no-.+/.test(arg)) {
key = arg.match(/^--no-(.+)/)[1];
setArg(key, false);
} else if (/^--.+/.test(arg)) {
key = arg.match(/^--(.+)/)[1];
next = args[i + 1];
if (next !== void 0 && !/^(-|--)[^-]/.test(next) && !bools[key]) {
setArg(key, next);
i += 1;
} else if (/^(true|false)$/.test(next)) {
setArg(key, next === "true");
i += 1;
} else {
setArg(key, strings[key] ? "" : true);
}
} else if (/^-[^-]+/.test(arg)) {
const letters = arg.slice(1, -1).split("");
let broken = false;
for (let j = 0; j < letters.length; j++) {
next = arg.slice(j + 2);
if (next === "-") {
setArg(letters[j], next);
continue;
}
if (/[A-Za-z]/.test(letters[j]) && next[0] === "=") {
setArg(letters[j], next.slice(1));
broken = true;
break;
}
if (/[A-Za-z]/.test(letters[j]) && /-?\d+(\.\d*)?(e-?\d+)?$/.test(next)) {
setArg(letters[j], next);
broken = true;
break;
}
if (letters[j + 1] && letters[j + 1].match(/\W/)) {
setArg(letters[j], arg.slice(j + 2));
broken = true;
break;
} else {
setArg(letters[j], strings[letters[j]] ? "" : true);
}
}
key = arg.slice(-1)[0];
if (!broken && key !== "-") {
if (args[i + 1] && !/^(-|--)[^-]/.test(args[i + 1]) && !bools[key]) {
setArg(key, args[i + 1]);
i += 1;
} else if (args[i + 1] && /^(true|false)$/.test(args[i + 1])) {
setArg(key, args[i + 1] === "true");
i += 1;
} else {
setArg(key, strings[key] ? "" : true);
}
}
} else {
argv._.push(arg);
}
}
for (const k of notFlags)
argv._.push(k);
return argv;
}
function toArray(value) {
if (!value)
return [];
return Array.isArray(value) ? value : [value];
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
minimist
});
@@ -0,0 +1,343 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var output_exports = {};
__export(output_exports, {
JsonOutput: () => JsonOutput,
TextOutput: () => TextOutput
});
module.exports = __toCommonJS(output_exports);
var import_path = __toESM(require("path"));
var import_extension = require("../utils/extension");
class TextOutput {
constructor() {
this.json = false;
}
version(v) {
console.log(v);
}
help(text) {
console.log(text);
}
errorUnknownCommand(name, globalHelp) {
console.error(`Unknown command: ${name}
`);
console.log(globalHelp);
return process.exit(1);
}
errorUnknownOption(opts, commandHelp) {
console.error(`Unknown option${opts.length > 1 ? "s" : ""}: ${opts.map((f) => `--${f}`).join(", ")}`);
console.log("");
console.log(commandHelp);
return process.exit(1);
}
errorTooManyArguments(expected, received, commandHelp) {
console.error(`error: too many arguments: expected ${expected}, received ${received}`);
console.log("");
console.log(commandHelp);
return process.exit(1);
}
errorAttachConflict() {
console.error(`Error: only one of [name], --cdp, --endpoint, or --extension can be specified`);
return process.exit(1);
}
errorDetachNotAttached(session) {
console.error(`Error: session '${session}' was not attached; use \`playwright-cli${session !== "default" ? ` -s=${session}` : ""} close\` to stop it.`);
return process.exit(1);
}
errorBrowserNotOpenForTool(session) {
console.log(`The browser '${session}' is not open, please run open first`);
console.log("");
console.log(` playwright-cli${session !== "default" ? ` -s=${session}` : ""} open [params]`);
return process.exit(1);
}
errorAttachNoTarget() {
console.error(`Error: no target specified for attach command; use one of [name], --cdp, --endpoint, or --extension to specify the target to attach to.`);
return process.exit(1);
}
list({ all, browsers, servers, channelSessions }) {
const byWorkspace = /* @__PURE__ */ new Map();
for (const browser of browsers) {
let list = byWorkspace.get(browser.workspace);
if (!list) {
list = [];
byWorkspace.set(browser.workspace, list);
}
list.push(browser);
}
let count = 0;
for (const [workspaceKey, list] of byWorkspace) {
if (count === 0)
console.log("### Browsers");
if (all)
console.log(`${import_path.default.relative(process.cwd(), workspaceKey) || "/"}:`);
for (const browser of list)
console.log(renderBrowser(browser));
count += list.length;
}
if (!all) {
if (!count)
console.log(" (no browsers)");
return;
}
if (servers?.length) {
if (count)
console.log("");
console.log("### Browser servers available for attach");
const serversByWorkspace = /* @__PURE__ */ new Map();
for (const server of servers) {
let list = serversByWorkspace.get(server.workspaceDir ?? "");
if (!list) {
list = [];
serversByWorkspace.set(server.workspaceDir ?? "", list);
}
list.push(server);
}
for (const [workspaceKey, list] of serversByWorkspace) {
if (workspaceKey)
console.log(`${import_path.default.relative(process.cwd(), workspaceKey) || "/"}:`);
for (const server of list)
console.log(renderServer(server));
}
count += servers.length;
}
if (!count)
console.log(" (no browsers)");
if (channelSessions?.length) {
console.log("");
console.log("### Browsers available to attach via CDP");
for (const session of channelSessions)
console.log(renderChannelSession(session));
}
}
closeAll(_sessions) {
}
deleteData(session, result) {
if (!result.existed) {
console.log(`No user data found for browser '${session}'.`);
return;
}
if (result.deletedUserDataDir)
console.log(`Deleted user data for browser '${session}'.`);
}
killAll(pids) {
for (const pid of pids)
console.log(`Killed daemon process ${pid}`);
if (pids.length === 0)
console.log("No daemon processes found.");
else
console.log(`Killed ${pids.length} daemon process${pids.length === 1 ? "" : "es"}.`);
}
open(session, pid, toolResult) {
console.log(`### Browser \`${session}\` opened with pid ${pid}.`);
if (toolResult)
console.log(toolResult);
}
attach(session, pid, endpoint, toolResult) {
if (endpoint) {
console.log(`### Session \`${session}\` created, attached to \`${endpoint}\`.`);
console.log(`Run commands with: playwright-cli --s=${session} <command>`);
console.log("");
} else {
console.log(`### Browser \`${session}\` opened with pid ${pid}.`);
}
if (toolResult)
console.log(toolResult);
}
close(session, wasOpen) {
if (!wasOpen) {
console.log(`Browser '${session}' is not open.`);
return;
}
console.log(`Browser '${session}' closed
`);
}
detach(session, wasAttached) {
if (!wasAttached) {
console.log(`Browser '${session}' is not attached.`);
return;
}
console.log(`Browser '${session}' detached
`);
}
installed() {
}
show(_session, pid) {
if (process.env.PWTEST_PRINT_DASHBOARD_PID_FOR_TEST)
console.log(`### Dashboard opened with pid ${pid}.`);
}
toolResult(text) {
console.log(text);
}
installStdio() {
return "inherit";
}
}
class JsonOutput {
constructor() {
this.json = true;
}
version(v) {
this._emit({ version: v });
}
help(text) {
this._emit({ help: text });
}
errorUnknownCommand(name, _globalHelp) {
this._emit({ isError: true, error: `Unknown command: ${name}` });
return process.exit(1);
}
errorUnknownOption(opts, _commandHelp) {
this._emit({ isError: true, error: `Unknown option${opts.length > 1 ? "s" : ""}: ${opts.map((f) => `--${f}`).join(", ")}` });
return process.exit(1);
}
errorTooManyArguments(expected, received, _commandHelp) {
this._emit({ isError: true, error: `error: too many arguments: expected ${expected}, received ${received}` });
return process.exit(1);
}
errorAttachConflict() {
this._emit({ isError: true, error: `only one of [name], --cdp, --endpoint, or --extension can be specified` });
return process.exit(1);
}
errorDetachNotAttached(session) {
this._emit({ isError: true, error: `session '${session}' was not attached; use close to stop it.` });
return process.exit(1);
}
errorBrowserNotOpenForTool(session) {
this._emit({ isError: true, error: `The browser '${session}' is not open, please run open first` });
return process.exit(1);
}
errorAttachNoTarget() {
this._emit({ isError: true, error: `no target specified for attach command; use one of [name], --cdp, --endpoint, or --extension to specify the target to attach to.` });
return process.exit(1);
}
list({ all, browsers, servers, channelSessions }) {
const payload = { browsers };
if (all) {
payload.servers = servers ?? [];
payload.channelSessions = channelSessions ?? [];
}
this._emit(payload);
}
closeAll(sessions) {
this._emit({ closed: sessions });
}
deleteData(session, result) {
this._emit({ session, deleted: result.existed });
}
killAll(pids) {
this._emit({ killed: pids.length, pids });
}
open(session, pid, toolResult) {
this._emit({ session, pid, result: parseJsonText(toolResult) });
}
attach(session, pid, endpoint, toolResult) {
this._emit({
session,
pid,
...endpoint ? { endpoint } : {},
result: parseJsonText(toolResult)
});
}
close(session, wasOpen) {
this._emit({ session, status: wasOpen ? "closed" : "not-open" });
}
detach(session, wasAttached) {
this._emit({ session, status: wasAttached ? "detached" : "not-attached" });
}
installed() {
this._emit({ installed: true });
}
show(session, pid) {
this._emit({ session, pid });
}
toolResult(text) {
console.log(text);
}
installStdio() {
return "ignore";
}
_emit(value) {
console.log(JSON.stringify(value, null, 2));
}
}
function parseJsonText(text) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
function renderBrowser(browser) {
const lines = [`- ${browser.name}:`];
lines.push(` - status: ${browser.status}`);
if (browser.status === "open" && !browser.compatible)
lines.push(` - version: v${browser.version} [incompatible please re-open]`);
if (browser.browserType)
lines.push(` - browser-type: ${browser.browserType}${browser.attached ? " (attached)" : ""}`);
if (!browser.attached) {
if (browser.userDataDir === null)
lines.push(` - user-data-dir: <in-memory>`);
else
lines.push(` - user-data-dir: ${browser.userDataDir}`);
if (browser.headed !== void 0)
lines.push(` - headed: ${browser.headed}`);
}
return lines.join("\n");
}
function renderServer(server) {
const lines = [`- browser "${server.title}":`];
lines.push(` - browser: ${server.browser.browserName}`);
lines.push(` - version: v${server.playwrightVersion}`);
if (server.browser.userDataDir)
lines.push(` - data-dir: ${server.browser.userDataDir}`);
else
lines.push(` - data-dir: <in-memory>`);
lines.push(` - run \`playwright-cli attach "${server.title}"\` to attach`);
return lines.join("\n");
}
function renderChannelSession(session) {
const lines = [`- ${session.channel}:`];
lines.push(` - data-dir: ${session.userDataDir}`);
if (session.extensionInstalled)
lines.push(` - attach (extension): \`playwright-cli attach --extension=${session.channel}\``);
else
lines.push(` - attach (extension): install at ${import_extension.playwrightExtensionInstallUrl}`);
if (session.endpoint) {
lines.push(` - attach (remote debugging): \`playwright-cli attach --cdp=${session.channel}\``);
} else {
const inspectScheme = session.channel.startsWith("msedge") ? "edge" : "chrome";
lines.push(` - attach (remote debugging): enable at ${inspectScheme}://inspect/#remote-debugging`);
}
return lines.join("\n");
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
JsonOutput,
TextOutput
});
@@ -0,0 +1,404 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var program_exports = {};
__export(program_exports, {
calculateSha1: () => calculateSha1,
program: () => program
});
module.exports = __toCommonJS(program_exports);
var import_child_process = require("child_process");
var import_crypto = __toESM(require("crypto"));
var import_os = __toESM(require("os"));
var import_path = __toESM(require("path"));
var import_channelSessions = require("./channelSessions");
var import_output = require("./output");
var import_registry = require("./registry");
var import_session = require("./session");
var import_package = require("../../package");
var import_serverRegistry = require("../../serverRegistry");
var import_minimist = require("./minimist");
const globalOptions = [
"json",
"raw",
"session"
];
const booleanOptions = [
"all",
"help",
"json",
"raw",
"version"
];
async function program(options) {
const clientInfo = (0, import_registry.createClientInfo)();
const help = require((0, import_package.libPath)("tools", "cli-client", "help.json"));
const argv = process.argv.slice(2);
const boolean = [...help.booleanOptions, ...booleanOptions];
const args = (0, import_minimist.minimist)(argv, { boolean, string: ["_"] });
if (args.s) {
args.session = args.s;
delete args.s;
}
const output = args.json ? new import_output.JsonOutput() : new import_output.TextOutput();
const commandName = args._?.[0];
if (args.version || args.v) {
output.version(options?.embedderVersion ?? clientInfo.version);
process.exit(0);
}
const command = commandName && help.commands[commandName];
if (args.help || args.h || !commandName) {
if (command) {
output.help(command.help);
} else {
const lines = ["playwright-cli - run playwright mcp commands from terminal"];
if (process.env.CLAUDECODE || process.env.COPILOT_CLI)
lines.push(`Agent skill: ${import_path.default.relative(process.cwd(), (0, import_package.libPath)("tools", "cli-client", "skill", "SKILL.md"))}`);
lines.push(help.global);
output.help(lines.join("\n\n"));
}
process.exit(0);
}
if (!command)
output.errorUnknownCommand(commandName, help.global);
validateFlags(args, command, output);
validateArgs(args, command, output);
const registry = await import_registry.Registry.load();
const sessionName = (0, import_registry.resolveSessionName)(args.session);
switch (commandName) {
case "list": {
const data = await collectList(registry, clientInfo, !!args.all);
output.list(data);
return;
}
case "close-all": {
const entries = registry.entries(clientInfo);
const closed = [];
for (const entry of entries) {
await new import_session.Session(entry).stop();
closed.push(entry.config.name);
}
output.closeAll(closed);
return;
}
case "delete-data": {
const entry = registry.entry(clientInfo, sessionName);
if (!entry) {
output.deleteData(sessionName, { existed: false, deletedUserDataDir: false });
return;
}
const result = await new import_session.Session(entry).deleteData();
output.deleteData(sessionName, result);
return;
}
case "kill-all": {
const pids = await killAllDaemons();
output.killAll(pids);
return;
}
case "open": {
const { pid } = await startSession(sessionName, registry, clientInfo, args, "open");
const newEntry = await registry.loadEntry(clientInfo, sessionName);
const params = args._.slice(1);
const toolText = await runInSessionOrStop(newEntry, clientInfo, { _: ["goto", ...params.length ? params : ["about:blank"]] }, output);
output.open(sessionName, pid, toolText);
return;
}
case "attach": {
const attachTarget = args._[1];
const targetCount = (attachTarget ? 1 : 0) + (args.cdp ? 1 : 0) + (args.endpoint ? 1 : 0) + (args.extension ? 1 : 0);
if (targetCount > 1)
output.errorAttachConflict();
if (attachTarget)
args.endpoint = attachTarget;
const extensionChannel = typeof args.extension === "string" ? args.extension : void 0;
if (extensionChannel) {
args.browser = extensionChannel;
args.extension = true;
}
const cdpChannel = typeof args.cdp === "string" && (0, import_channelSessions.isKnownChannel)(args.cdp) ? args.cdp : void 0;
const targetName = attachTarget ?? cdpChannel ?? extensionChannel ?? args.endpoint ?? args.cdp;
if (!targetName)
output.errorAttachNoTarget();
const attachSessionName = (0, import_registry.explicitSessionName)(args.session) ?? attachTarget ?? cdpChannel ?? extensionChannel ?? sessionName;
args.session = attachSessionName;
const { pid } = await startSession(attachSessionName, registry, clientInfo, args, "attach");
const newEntry = await registry.loadEntry(clientInfo, attachSessionName);
const toolText = await runInSessionOrStop(newEntry, clientInfo, { _: ["snapshot"], filename: "<auto>" }, output);
output.attach(attachSessionName, pid, targetName, toolText);
return;
}
case "close": {
const closeEntry = registry.entry(clientInfo, sessionName);
const { wasOpen } = closeEntry ? await new import_session.Session(closeEntry).stop() : { wasOpen: false };
output.close(sessionName, wasOpen);
return;
}
case "detach": {
const detachEntry = registry.entry(clientInfo, sessionName);
if (detachEntry && !detachEntry.config.attached)
output.errorDetachNotAttached(sessionName);
const { wasOpen } = detachEntry ? await new import_session.Session(detachEntry).stop() : { wasOpen: false };
output.detach(sessionName, wasOpen);
return;
}
case "install":
await runInitWorkspace(args, output);
output.installed();
return;
case "install-browser":
await installBrowser();
output.installed();
return;
case "show": {
const daemonScript = (0, import_package.libPath)("entry", "dashboardApp.js");
const daemonArgs = [
daemonScript,
`--workspaceDir=${clientInfo.workspaceDir ?? ""}`
];
const explicit = (0, import_registry.explicitSessionName)(args.session);
if (explicit)
daemonArgs.push(`--sessionName=${explicit}`);
if (args.port !== void 0)
daemonArgs.push(`--port=${args.port}`);
if (args.host !== void 0)
daemonArgs.push(`--host=${args.host}`);
if (args.kill) {
daemonArgs.push(`--kill`);
const child2 = (0, import_child_process.spawn)(process.execPath, daemonArgs, { stdio: "ignore" });
await new Promise((resolve) => child2.on("exit", () => resolve()));
return;
}
if (args.annotate) {
const entry = registry.entry(clientInfo, sessionName);
if (!entry)
output.errorBrowserNotOpenForTool(sessionName);
args.raw = true;
const text = await runInSession(entry, clientInfo, args, output);
output.toolResult(text);
return;
}
const foreground = args.port !== void 0;
const child = (0, import_child_process.spawn)(process.execPath, daemonArgs, {
detached: !foreground,
stdio: foreground ? "inherit" : ["pipe", "pipe", "ignore"]
});
if (foreground) {
await new Promise((resolve) => child.on("exit", () => resolve()));
return;
}
const timer = setTimeout(() => child.stdin.destroy(), 6e4);
child.unref();
let daemonPid;
try {
await new Promise((resolve, reject) => {
let outLog = "";
child.stdout.on("data", (data) => {
outLog += data.toString();
const match = outLog.match(/Dashboard is running pid=(\d+)/);
if (match) {
daemonPid = Number(match[1]);
resolve();
}
});
child.once("exit", (code, signal) => reject(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY${outLog ? "\n" + outLog : ""}`)));
});
} finally {
clearTimeout(timer);
child.removeAllListeners("exit");
child.stdin.destroy();
child.stdout.destroy();
}
output.show(sessionName, daemonPid);
return;
}
default: {
const entry = registry.entry(clientInfo, sessionName);
if (!entry)
output.errorBrowserNotOpenForTool(sessionName);
if (command.raw)
args.raw = true;
const text = await runInSession(entry, clientInfo, args, output);
output.toolResult(text);
}
}
}
async function startSession(sessionName, registry, clientInfo, args, mode) {
const entry = registry.entry(clientInfo, sessionName);
if (entry)
await new import_session.Session(entry).stop();
return await import_session.Session.startDaemon(clientInfo, args, mode);
}
async function runInSession(entry, clientInfo, args, output) {
const raw = !!args.raw;
for (const globalOption of globalOptions)
delete args[globalOption];
const session = new import_session.Session(entry);
const result = await session.run(clientInfo, args, { raw, json: output.json });
return result.text;
}
async function runInSessionOrStop(entry, clientInfo, args, output) {
try {
return await runInSession(entry, clientInfo, args, output);
} catch (e) {
await new import_session.Session(entry).stop().catch(() => {
});
throw e;
}
}
async function runInitWorkspace(args, output) {
const cliPath = (0, import_package.libPath)("entry", "cliDaemon.js");
const daemonArgs = [cliPath, "--init-workspace", ...args.skills ? ["--init-skills", String(args.skills)] : []];
await new Promise((resolve, reject) => {
const child = (0, import_child_process.spawn)(process.execPath, daemonArgs, {
stdio: output.installStdio(),
cwd: process.cwd()
});
child.on("close", (code) => {
if (code === 0)
resolve();
else
reject(new Error(`Workspace initialization failed with exit code ${code}`));
});
});
}
async function installBrowser() {
const argv = process.argv.map((arg) => arg === "install-browser" ? "install" : arg);
const { libCli } = require("../../coreBundle.js");
const { program: program2 } = require("../../utilsBundle.js");
if (!program2.version())
libCli.decorateProgram(program2);
program2.parse(argv);
}
const daemonProcessPatterns = ["run-mcp-server", "run-cli-server", "cli-daemon", "cliDaemon.js", "dashboardApp.js"];
async function killAllDaemons() {
const platform = import_os.default.platform();
const pidFilterEnv = process.env.PWTEST_KILL_ALL_PID_FILTER_FOR_TEST;
const pidFilter = pidFilterEnv ? new Set(pidFilterEnv.split(",").map((p) => parseInt(p, 10)).filter((n) => !isNaN(n))) : void 0;
const killed = [];
try {
if (platform === "win32") {
const clauses = [`(${daemonProcessPatterns.map((p) => `$_.CommandLine -like '*${p}*'`).join(" -or ")})`];
if (pidFilter)
clauses.push(`(${[...pidFilter].map((p) => `$_.ProcessId -eq ${p}`).join(" -or ")})`);
const whereClause = clauses.join(" -and ");
const result = (0, import_child_process.execSync)(
`powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { ${whereClause} } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $_.ProcessId }"`,
{ encoding: "utf-8" }
);
const pids = result.split("\n").map((line) => line.trim()).filter((line) => /^\d+$/.test(line));
for (const pid of pids)
killed.push(parseInt(pid, 10));
} else {
const result = (0, import_child_process.execSync)("ps auxww", { encoding: "utf-8" });
const lines = result.split("\n");
for (const line of lines) {
if (daemonProcessPatterns.some((p) => line.includes(p))) {
const parts = line.trim().split(/\s+/);
const pid = parts[1];
if (pid && /^\d+$/.test(pid)) {
const numericPid = parseInt(pid, 10);
if (pidFilter && !pidFilter.has(numericPid))
continue;
try {
process.kill(numericPid, "SIGKILL");
killed.push(numericPid);
} catch {
}
}
}
}
}
} catch (e) {
}
return killed;
}
async function collectList(registry, clientInfo, all) {
const browsers = [];
const entries = registry.entryMap();
const serverEntries = await import_serverRegistry.serverRegistry.list();
const key = (0, import_registry.clientKey)(clientInfo);
for (const [workspaceKey, list] of entries) {
if (!all && workspaceKey !== key)
continue;
for (const entry of list) {
const session = new import_session.Session(entry);
const canConnect = await session.canConnect();
if (!canConnect) {
await session.deleteSessionConfig();
continue;
}
const config = session.config;
const channel = config.browser?.launchOptions.channel ?? config.browser?.browserName;
browsers.push({
name: session.name,
workspace: workspaceKey,
status: canConnect ? "open" : "closed",
browserType: channel,
userDataDir: config.browser?.userDataDir ?? null,
headed: config.browser ? !config.browser.launchOptions.headless : void 0,
persistent: !!config.cli.persistent,
attached: !!config.attached,
compatible: session.isCompatible(clientInfo),
version: config.version
});
}
}
if (!all)
return { all, browsers };
const servers = [...serverEntries.values()].flat();
return { all, browsers, servers, channelSessions: await (0, import_channelSessions.listChannelSessions)() };
}
function validateFlags(args, command, output) {
const unknownFlags = [];
for (const key of Object.keys(args)) {
if (key === "_")
continue;
if (globalOptions.includes(key))
continue;
if (!(key in command.flags))
unknownFlags.push(key);
}
if (unknownFlags.length)
output.errorUnknownOption(unknownFlags, command.help);
}
function validateArgs(args, command, output) {
const positional = args._.slice(1);
if (positional.length > command.args.length)
output.errorTooManyArguments(command.args.length, positional.length, command.help);
}
function calculateSha1(buffer) {
const hash = import_crypto.default.createHash("sha1");
hash.update(buffer);
return hash.digest("hex");
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
calculateSha1,
program
});
@@ -0,0 +1,176 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var registry_exports = {};
__export(registry_exports, {
Registry: () => Registry,
baseDaemonDir: () => baseDaemonDir,
clientKey: () => clientKey,
createClientInfo: () => createClientInfo,
explicitSessionName: () => explicitSessionName,
resolveSessionName: () => resolveSessionName
});
module.exports = __toCommonJS(registry_exports);
var import_crypto = __toESM(require("crypto"));
var import_fs = __toESM(require("fs"));
var import_os = __toESM(require("os"));
var import_path = __toESM(require("path"));
var import_package = require("../../package");
function clientKey(clientInfo) {
return clientInfo.workspaceDir || clientInfo.workspaceDirHash;
}
class Registry {
constructor(files) {
this._files = files;
}
entry(clientInfo, sessionName) {
const key = clientKey(clientInfo);
const entries = this._files.get(key) || [];
return entries.find((entry) => entry.config.name === sessionName);
}
entries(clientInfo) {
return this._files.get(clientKey(clientInfo)) || [];
}
entryMap() {
return this._files;
}
async loadEntry(clientInfo, sessionName) {
const entry = await Registry._loadSessionEntry(clientInfo.daemonProfilesDir, sessionName + ".session");
if (!entry)
throw new Error(`Could not start the session "${sessionName}"`);
const key = clientKey(clientInfo);
let list = this._files.get(key);
if (!list) {
list = [];
this._files.set(key, list);
}
const oldIndex = list.findIndex((e) => e.config.name === sessionName);
if (oldIndex !== -1)
list.splice(oldIndex, 1);
list.push(entry);
return entry;
}
static async _loadSessionEntry(daemonDir, file) {
try {
const fileName = import_path.default.join(daemonDir, file);
const data = await import_fs.default.promises.readFile(fileName, "utf-8");
const config = JSON.parse(data);
if (!config.name)
config.name = import_path.default.basename(file, ".session");
if (!config.timestamp)
config.timestamp = 0;
return { file: fileName, config, daemonDir };
} catch {
return void 0;
}
}
static async load() {
const sessions = /* @__PURE__ */ new Map();
const hashDirs = await import_fs.default.promises.readdir(baseDaemonDir).catch(() => []);
for (const workspaceDirHash of hashDirs) {
const daemonDir = import_path.default.join(baseDaemonDir, workspaceDirHash);
const stat = await import_fs.default.promises.stat(daemonDir);
if (!stat.isDirectory())
continue;
const files = await import_fs.default.promises.readdir(daemonDir).catch(() => []);
for (const file of files) {
if (!file.endsWith(".session"))
continue;
const entry = await Registry._loadSessionEntry(daemonDir, file);
if (!entry)
continue;
const key = entry.config.workspaceDir || workspaceDirHash;
let list = sessions.get(key);
if (!list) {
list = [];
sessions.set(key, list);
}
list.push(entry);
}
}
return new Registry(sessions);
}
}
const baseDaemonDir = (() => {
if (process.env.PWTEST_DAEMON_SESSION_DIR)
return process.env.PWTEST_DAEMON_SESSION_DIR;
let localCacheDir;
if (process.platform === "linux")
localCacheDir = process.env.XDG_CACHE_HOME || import_path.default.join(import_os.default.homedir(), ".cache");
if (process.platform === "darwin")
localCacheDir = import_path.default.join(import_os.default.homedir(), "Library", "Caches");
if (process.platform === "win32")
localCacheDir = process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local");
if (!localCacheDir)
throw new Error("Unsupported platform: " + process.platform);
return import_path.default.join(localCacheDir, "ms-playwright", "daemon");
})();
function createClientInfo() {
const workspaceDir = findWorkspaceDir(process.cwd());
const version = process.env.PLAYWRIGHT_CLI_VERSION_FOR_TEST || import_package.packageJSON.version;
const hash = import_crypto.default.createHash("sha1");
hash.update(workspaceDir || import_package.packageRoot);
const workspaceDirHash = hash.digest("hex").substring(0, 16);
return {
version,
workspaceDir,
workspaceDirHash,
daemonProfilesDir: daemonProfilesDir(workspaceDirHash),
homeDir: import_os.default.homedir()
};
}
function findWorkspaceDir(startDir) {
let dir = startDir;
for (let i = 0; i < 10; i++) {
if (import_fs.default.existsSync(import_path.default.join(dir, ".playwright")))
return dir;
const parentDir = import_path.default.dirname(dir);
if (parentDir === dir)
break;
dir = parentDir;
}
return void 0;
}
const daemonProfilesDir = (workspaceDirHash) => {
return import_path.default.join(baseDaemonDir, workspaceDirHash);
};
function explicitSessionName(sessionName) {
return sessionName || process.env.PLAYWRIGHT_CLI_SESSION;
}
function resolveSessionName(sessionName) {
return explicitSessionName(sessionName) || "default";
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Registry,
baseDaemonDir,
clientKey,
createClientInfo,
explicitSessionName,
resolveSessionName
});
@@ -0,0 +1,254 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var session_exports = {};
__export(session_exports, {
Session: () => Session
});
module.exports = __toCommonJS(session_exports);
var import_child_process = require("child_process");
var import_fs = __toESM(require("fs"));
var import_net = __toESM(require("net"));
var import_os = __toESM(require("os"));
var import_path = __toESM(require("path"));
var import_package = require("../../package");
var import_socketConnection = require("../utils/socketConnection");
var import_registry = require("./registry");
class Session {
constructor(sessionFile) {
this.config = sessionFile.config;
this.name = this.config.name;
this._sessionFile = sessionFile;
}
isCompatible(clientInfo) {
return (0, import_socketConnection.compareSemver)(clientInfo.version, this.config.version) >= 0;
}
async run(clientInfo, args, options) {
if (!this.isCompatible(clientInfo))
throw new Error(`Client is v${clientInfo.version}, session '${this.name}' is v${this.config.version}. Run
playwright-cli${this.name !== "default" ? ` -s=${this.name}` : ""} open
to restart the browser session.`);
const { socket } = await this._connect();
if (!socket)
throw new Error(`Browser '${this.name}' is not open. Run
playwright-cli${this.name !== "default" ? ` -s=${this.name}` : ""} open
to start the browser session.`);
return await SocketConnectionClient.sendAndClose(socket, "run", { args, cwd: process.cwd(), raw: options?.raw, json: options?.json });
}
async stop() {
if (!await this.canConnect())
return { wasOpen: false };
await this._stopDaemon();
return { wasOpen: true };
}
async deleteData() {
await this.stop();
const dataDirs = await import_fs.default.promises.readdir(this._sessionFile.daemonDir).catch(() => []);
const matchingEntries = dataDirs.filter((file) => file === `${this.name}.session` || file.startsWith(`ud-${this.name}-`));
if (matchingEntries.length === 0)
return { existed: false, deletedUserDataDir: false };
let deletedUserDataDir = false;
for (const entry of matchingEntries) {
const userDataDir = import_path.default.resolve(this._sessionFile.daemonDir, entry);
for (let i = 0; i < 5; i++) {
try {
await import_fs.default.promises.rm(userDataDir, { recursive: true });
if (entry.startsWith("ud-"))
deletedUserDataDir = true;
break;
} catch (e) {
if (e.code === "ENOENT")
break;
await new Promise((resolve) => setTimeout(resolve, 1e3));
if (i === 4)
throw e;
}
}
}
return { existed: true, deletedUserDataDir };
}
async _connect() {
return await new Promise((resolve) => {
const socket = import_net.default.createConnection(this.config.socketPath, () => {
resolve({ socket });
});
socket.on("error", (error) => {
if (import_os.default.platform() !== "win32")
void import_fs.default.promises.unlink(this.config.socketPath).catch(() => {
}).then(() => resolve({ error }));
else
resolve({ error });
});
});
}
async canConnect() {
const { socket } = await this._connect();
if (socket) {
socket.destroy();
return true;
}
return false;
}
static async startDaemon(clientInfo, cliArgs, mode) {
await import_fs.default.promises.mkdir(clientInfo.daemonProfilesDir, { recursive: true });
const cliPath = (0, import_package.libPath)("entry", "cliDaemon.js");
const sessionName = (0, import_registry.resolveSessionName)(cliArgs.session);
const errLog = import_path.default.join(clientInfo.daemonProfilesDir, sessionName + ".err");
const err = import_fs.default.openSync(errLog, "w");
const args = [
cliPath,
sessionName
];
if (cliArgs.headed)
args.push("--headed");
if (cliArgs.browser)
args.push(`--browser=${cliArgs.browser}`);
if (cliArgs.persistent)
args.push("--persistent");
if (cliArgs.profile)
args.push(`--profile=${cliArgs.profile}`);
if (cliArgs.config)
args.push(`--config=${cliArgs.config}`);
if (cliArgs.extension)
args.push("--extension");
else if (cliArgs.cdp)
args.push(`--cdp=${cliArgs.cdp}`);
else if (cliArgs.endpoint)
args.push(`--endpoint=${cliArgs.endpoint}`);
const child = (0, import_child_process.spawn)(process.execPath, args, {
detached: true,
stdio: ["ignore", "pipe", err],
cwd: process.cwd()
// Will be used as root.
});
let signalled = false;
const sigintHandler = () => {
signalled = true;
child.kill("SIGINT");
};
const sigtermHandler = () => {
signalled = true;
child.kill("SIGTERM");
};
process.on("SIGINT", sigintHandler);
process.on("SIGTERM", sigtermHandler);
let outLog = "";
const rejectWithPid = (reject, message) => reject(Object.assign(new Error(`Daemon pid=${child.pid}: ${message}`), { daemonPid: child.pid }));
await new Promise((resolve, reject) => {
child.stdout.on("data", (data) => {
outLog += data.toString();
if (outLog.includes("Daemon listening on"))
resolve();
});
child.on("close", (code) => {
if (!signalled) {
const errLogContent = import_fs.default.readFileSync(errLog, "utf-8");
rejectWithPid(reject, `Daemon process exited with code ${code}` + (outLog ? "\n" + outLog : "") + (errLogContent ? "\n" + errLogContent : ""));
}
});
});
process.off("SIGINT", sigintHandler);
process.off("SIGTERM", sigtermHandler);
child.stdout.destroy();
child.unref();
return { pid: child.pid, sessionName, endpoint: cliArgs.endpoint };
}
async _stopDaemon() {
const { socket } = await this._connect();
if (!socket)
return;
let error;
await SocketConnectionClient.sendAndClose(socket, "stop", {}).catch((e) => error = e);
if (error && !error?.message?.includes("Session closed"))
throw error;
}
async deleteSessionConfig() {
await import_fs.default.promises.rm(this._sessionFile.file).catch(() => {
});
}
}
class SocketConnectionClient {
constructor(socket) {
this._nextMessageId = 1;
this._callbacks = /* @__PURE__ */ new Map();
this._connection = new import_socketConnection.SocketConnection(socket);
this._connection.onmessage = (message) => this._onMessage(message);
this._connection.onclose = () => this._rejectCallbacks();
}
async send(method, params = {}) {
const messageId = this._nextMessageId++;
const message = {
id: messageId,
method,
params
};
const responsePromise = new Promise((resolve, reject) => {
this._callbacks.set(messageId, { resolve, reject, method, params });
});
const [result] = await Promise.all([responsePromise, this._connection.send(message)]);
return result;
}
static async sendAndClose(socket, method, params = {}) {
const connection = new SocketConnectionClient(socket);
try {
return await connection.send(method, params);
} finally {
connection.close();
}
}
close() {
this._connection.close();
}
_onMessage(object) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id);
this._callbacks.delete(object.id);
if (object.error)
callback.reject(new Error(object.error));
else
callback.resolve(object.result);
} else if (object.id) {
throw new Error(`Unexpected message id: ${object.id}`);
} else {
throw new Error(`Unexpected message without id: ${JSON.stringify(object)}`);
}
}
_rejectCallbacks() {
for (const callback of this._callbacks.values())
callback.reject(new Error("Session closed"));
this._callbacks.clear();
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Session
});
@@ -0,0 +1,404 @@
---
name: playwright-cli
description: Automate browser interactions, test web pages and work with Playwright tests.
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
---
# Browser Automation with playwright-cli
## Quick start
```bash
# open new browser
playwright-cli open
# navigate to a page
playwright-cli goto https://playwright.dev
# interact with the page using refs from the snapshot
playwright-cli click e15
playwright-cli type "page.click"
playwright-cli press Enter
# take a screenshot (rarely used, as snapshot is more common)
playwright-cli screenshot
# close the browser
playwright-cli close
```
## Commands
### Core
```bash
playwright-cli open
# open and navigate right away
playwright-cli open https://example.com/
playwright-cli goto https://playwright.dev
playwright-cli type "search query"
playwright-cli click e3
playwright-cli dblclick e7
# --submit presses Enter after filling the element
playwright-cli fill e5 "user@example.com" --submit
playwright-cli drag e2 e8
# drop files or data onto an element (from outside the page)
playwright-cli drop e4 --path=./image.png
playwright-cli drop e4 --data="text/plain=hello world"
playwright-cli hover e4
playwright-cli select e9 "option-value"
playwright-cli upload ./document.pdf
playwright-cli check e12
playwright-cli uncheck e12
playwright-cli snapshot
playwright-cli eval "document.title"
playwright-cli eval "el => el.textContent" e5
# get element id, class, or any attribute not visible in the snapshot
playwright-cli eval "el => el.id" e5
playwright-cli eval "el => el.getAttribute('data-testid')" e5
playwright-cli dialog-accept
playwright-cli dialog-accept "confirmation text"
playwright-cli dialog-dismiss
playwright-cli resize 1920 1080
playwright-cli close
```
### Navigation
```bash
playwright-cli go-back
playwright-cli go-forward
playwright-cli reload
```
### Keyboard
```bash
playwright-cli press Enter
playwright-cli press ArrowDown
playwright-cli keydown Shift
playwright-cli keyup Shift
```
### Mouse
```bash
playwright-cli mousemove 150 300
playwright-cli mousedown
playwright-cli mousedown right
playwright-cli mouseup
playwright-cli mouseup right
playwright-cli mousewheel 0 100
```
### Save as
```bash
playwright-cli screenshot
playwright-cli screenshot e5
playwright-cli screenshot --filename=page.png
playwright-cli pdf --filename=page.pdf
```
### Tabs
```bash
playwright-cli tab-list
playwright-cli tab-new
playwright-cli tab-new https://example.com/page
playwright-cli tab-close
playwright-cli tab-close 2
playwright-cli tab-select 0
```
### Storage
```bash
playwright-cli state-save
playwright-cli state-save auth.json
playwright-cli state-load auth.json
# Cookies
playwright-cli cookie-list
playwright-cli cookie-list --domain=example.com
playwright-cli cookie-get session_id
playwright-cli cookie-set session_id abc123
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
playwright-cli cookie-delete session_id
playwright-cli cookie-clear
# LocalStorage
playwright-cli localstorage-list
playwright-cli localstorage-get theme
playwright-cli localstorage-set theme dark
playwright-cli localstorage-delete theme
playwright-cli localstorage-clear
# SessionStorage
playwright-cli sessionstorage-list
playwright-cli sessionstorage-get step
playwright-cli sessionstorage-set step 3
playwright-cli sessionstorage-delete step
playwright-cli sessionstorage-clear
```
### Network
```bash
playwright-cli route "**/*.jpg" --status=404
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
playwright-cli route-list
playwright-cli unroute "**/*.jpg"
playwright-cli unroute
```
### DevTools
```bash
playwright-cli console
playwright-cli console warning
playwright-cli requests
playwright-cli request 5
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
playwright-cli run-code --filename=script.js
playwright-cli tracing-start
playwright-cli tracing-stop
playwright-cli video-start video.webm
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
playwright-cli video-stop
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
playwright-cli video-show-actions --duration=600 --position=top-right
playwright-cli video-hide-actions
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
playwright-cli show --annotate
# generate a Playwright locator for an element from its ref or selector
playwright-cli generate-locator e5 --raw
# show a persistent highlight overlay for an element, optionally with a custom style
playwright-cli highlight e5
playwright-cli highlight e5 --style="outline: 3px dashed red"
# hide a single element highlight, or all page highlights when no target is given
playwright-cli highlight e5 --hide
playwright-cli highlight --hide
```
## Raw output
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
```bash
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
playwright-cli --raw snapshot > before.yml
playwright-cli click e5
playwright-cli --raw snapshot > after.yml
diff before.yml after.yml
TOKEN=$(playwright-cli --raw cookie-get session_id)
playwright-cli --raw localstorage-get theme
```
For structured output wrapping every reply as JSON, pass --json
```bash
playwright-cli list --json
```
## Open parameters
```bash
# Use specific browser when creating session
playwright-cli open --browser=chrome
playwright-cli open --browser=firefox
playwright-cli open --browser=webkit
playwright-cli open --browser=msedge
# Use persistent profile (by default profile is in-memory)
playwright-cli open --persistent
# Use persistent profile with custom directory
playwright-cli open --profile=/path/to/profile
# Connect to browser via Playwright Extension
playwright-cli attach --extension=chrome
# Connect to a running Chrome or Edge by channel name
playwright-cli attach --cdp=chrome
playwright-cli attach --cdp=msedge
# Connect to a running browser via CDP endpoint
playwright-cli attach --cdp=http://localhost:9222
# Start with config file
playwright-cli open --config=my-config.json
# Close the browser
playwright-cli close
# Detach from an attached browser (leaves the external browser running)
playwright-cli -s=msedge detach
# Delete user data for the default session
playwright-cli delete-data
```
## URLs with `&` on Windows
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
```batch
playwright-cli goto "https://example.com/?a=1^&b=2"
```
```powershell
playwright-cli --% goto "https://example.com/?a=1&b=2"
```
## Snapshots
After each command, playwright-cli provides a snapshot of the current browser state.
```bash
> playwright-cli goto https://example.com
### Page
- Page URL: https://example.com/
- Page Title: Example Domain
### Snapshot
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
```
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
```bash
# default - save to a file with timestamp-based name
playwright-cli snapshot
# save to file, use when snapshot is a part of the workflow result
playwright-cli snapshot --filename=after-click.yaml
# snapshot an element instead of the whole page
playwright-cli snapshot "#main"
# limit snapshot depth for efficiency, take a partial snapshot afterwards
playwright-cli snapshot --depth=4
playwright-cli snapshot e34
# include each element's bounding box as [box=x,y,width,height]
playwright-cli snapshot --boxes
```
## Targeting elements
By default, use refs from the snapshot to interact with page elements.
```bash
# get snapshot with refs
playwright-cli snapshot
# interact using a ref
playwright-cli click e15
```
You can also use css selectors or Playwright locators.
```bash
# css selector
playwright-cli click "#main > button.submit"
# role locator
playwright-cli click "getByRole('button', { name: 'Submit' })"
# test id
playwright-cli click "getByTestId('submit-button')"
```
## Browser Sessions
```bash
# create new browser session named "mysession" with persistent profile
playwright-cli -s=mysession open example.com --persistent
# same with manually specified profile directory (use when requested explicitly)
playwright-cli -s=mysession open example.com --profile=/path/to/profile
playwright-cli -s=mysession click e6
playwright-cli -s=mysession close # stop a named browser
playwright-cli -s=mysession delete-data # delete user data for persistent session
playwright-cli list
# Close all browsers
playwright-cli close-all
# Forcefully kill all browser processes
playwright-cli kill-all
```
## Installation
If global `playwright-cli` command is not available, try a local version via `npx playwright-cli`:
```bash
npx --no-install playwright-cli --version
```
When local version is available, use `npx playwright-cli` in all commands. Otherwise, install `playwright-cli` as a global command:
```bash
npm install -g @playwright/cli@latest
```
## Example: Form submission
```bash
playwright-cli open https://example.com/form
playwright-cli snapshot
playwright-cli fill e1 "user@example.com"
playwright-cli fill e2 "password123"
playwright-cli click e3
playwright-cli snapshot
playwright-cli close
```
## Example: Multi-tab workflow
```bash
playwright-cli open https://example.com
playwright-cli tab-new https://example.com/other
playwright-cli tab-list
playwright-cli tab-select 0
playwright-cli snapshot
playwright-cli close
```
## Example: Debugging with DevTools
```bash
playwright-cli open https://example.com
playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli console
playwright-cli requests
playwright-cli close
```
```bash
playwright-cli open https://example.com
playwright-cli tracing-start
playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli tracing-stop
playwright-cli close
```
## Example: Interactive session
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
```bash
playwright-cli open https://example.com
playwright-cli show --annotate
```
## Specific tasks
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
* **Running Playwright code** [references/running-code.md](references/running-code.md)
* **Browser session management** [references/session-management.md](references/session-management.md)
* **Spec-driven testing (plan / generate / heal)** [references/spec-driven-testing.md](references/spec-driven-testing.md)
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
* **Test generation** [references/test-generation.md](references/test-generation.md)
* **Tracing** [references/tracing.md](references/tracing.md)
* **Video recording** [references/video-recording.md](references/video-recording.md)
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
@@ -0,0 +1,23 @@
# Inspecting Element Attributes
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
## Examples
```bash
playwright-cli snapshot
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
# get the element's id
playwright-cli eval "el => el.id" e7
# get all CSS classes
playwright-cli eval "el => el.className" e7
# get a specific attribute
playwright-cli eval "el => el.getAttribute('data-testid')" e7
playwright-cli eval "el => el.getAttribute('aria-label')" e7
# get a computed style property
playwright-cli eval "el => getComputedStyle(el).display" e7
```
@@ -0,0 +1,39 @@
# Running Playwright Tests
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
```bash
# Run all tests
PLAYWRIGHT_HTML_OPEN=never npx playwright test
# Run all tests through a custom npm script
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
```
# Debugging Playwright Tests
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
```bash
# Run the test
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
# ...
# ... debugging instructions for "tw-abcdef" session ...
# ...
# Attach to the test
playwright-cli attach tw-abcdef
```
Keep the test running in the background while you explore and look for a fix.
The test is paused at the start, so you should step over or pause at a particular location
where the problem is most likely to be.
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
After fixing the test, stop the background test run. Rerun to check that test passes.
@@ -0,0 +1,87 @@
# Request Mocking
Intercept, mock, modify, and block network requests.
## CLI Route Commands
```bash
# Mock with custom status
playwright-cli route "**/*.jpg" --status=404
# Mock with JSON body
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
# Mock with custom headers
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
# Remove headers from requests
playwright-cli route "**/*" --remove-header=cookie,authorization
# List active routes
playwright-cli route-list
# Remove a route or all routes
playwright-cli unroute "**/*.jpg"
playwright-cli unroute
```
## URL Patterns
```
**/api/users - Exact path match
**/api/*/details - Wildcard in path
**/*.{png,jpg,jpeg} - Match file extensions
**/search?q=* - Match query parameters
```
## Advanced Mocking with run-code
For conditional responses, request body inspection, response modification, or delays:
### Conditional Response Based on Request
```bash
playwright-cli run-code "async page => {
await page.route('**/api/login', route => {
const body = route.request().postDataJSON();
if (body.username === 'admin') {
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
} else {
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
}
});
}"
```
### Modify Real Response
```bash
playwright-cli run-code "async page => {
await page.route('**/api/user', async route => {
const response = await route.fetch();
const json = await response.json();
json.isPremium = true;
await route.fulfill({ response, json });
});
}"
```
### Simulate Network Failures
```bash
playwright-cli run-code "async page => {
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
}"
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
```
### Delayed Response
```bash
playwright-cli run-code "async page => {
await page.route('**/api/slow', async route => {
await new Promise(r => setTimeout(r, 3000));
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
});
}"
```
@@ -0,0 +1,241 @@
# Running Custom Playwright Code
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
## Syntax
```bash
playwright-cli run-code "async page => {
// Your Playwright code here
// Access page.context() for browser context operations
}"
```
You can also load the function from a file:
```bash
playwright-cli run-code --filename=./my-script.js
```
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
import/export/require syntax is not supported.
## Geolocation
```bash
# Grant geolocation permission and set location
playwright-cli run-code "async page => {
await page.context().grantPermissions(['geolocation']);
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
}"
# Set location to London
playwright-cli run-code "async page => {
await page.context().grantPermissions(['geolocation']);
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
}"
# Clear geolocation override
playwright-cli run-code "async page => {
await page.context().clearPermissions();
}"
```
## Permissions
```bash
# Grant multiple permissions
playwright-cli run-code "async page => {
await page.context().grantPermissions([
'geolocation',
'notifications',
'camera',
'microphone'
]);
}"
# Grant permissions for specific origin
playwright-cli run-code "async page => {
await page.context().grantPermissions(['clipboard-read'], {
origin: 'https://example.com'
});
}"
```
## Media Emulation
```bash
# Emulate dark color scheme
playwright-cli run-code "async page => {
await page.emulateMedia({ colorScheme: 'dark' });
}"
# Emulate light color scheme
playwright-cli run-code "async page => {
await page.emulateMedia({ colorScheme: 'light' });
}"
# Emulate reduced motion
playwright-cli run-code "async page => {
await page.emulateMedia({ reducedMotion: 'reduce' });
}"
# Emulate print media
playwright-cli run-code "async page => {
await page.emulateMedia({ media: 'print' });
}"
```
## Wait Strategies
```bash
# Wait for network idle
playwright-cli run-code "async page => {
await page.waitForLoadState('networkidle');
}"
# Wait for specific element
playwright-cli run-code "async page => {
await page.locator('.loading').waitFor({ state: 'hidden' });
}"
# Wait for function to return true
playwright-cli run-code "async page => {
await page.waitForFunction(() => window.appReady === true);
}"
# Wait with timeout
playwright-cli run-code "async page => {
await page.locator('.result').waitFor({ timeout: 10000 });
}"
```
## Frames and Iframes
```bash
# Work with iframe
playwright-cli run-code "async page => {
const frame = page.locator('iframe#my-iframe').contentFrame();
await frame.locator('button').click();
}"
# Get all frames
playwright-cli run-code "async page => {
const frames = page.frames();
return frames.map(f => f.url());
}"
```
## File Downloads
```bash
# Handle file download
playwright-cli run-code "async page => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download' }).click();
const download = await downloadPromise;
await download.saveAs('./downloaded-file.pdf');
return download.suggestedFilename();
}"
```
## Clipboard
```bash
# Read clipboard (requires permission)
playwright-cli run-code "async page => {
await page.context().grantPermissions(['clipboard-read']);
return await page.evaluate(() => navigator.clipboard.readText());
}"
# Write to clipboard
playwright-cli run-code "async page => {
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
}"
```
## Page Information
```bash
# Get page title
playwright-cli run-code "async page => {
return await page.title();
}"
# Get current URL
playwright-cli run-code "async page => {
return page.url();
}"
# Get page content
playwright-cli run-code "async page => {
return await page.content();
}"
# Get viewport size
playwright-cli run-code "async page => {
return page.viewportSize();
}"
```
## JavaScript Execution
```bash
# Execute JavaScript and return result
playwright-cli run-code "async page => {
return await page.evaluate(() => {
return {
userAgent: navigator.userAgent,
language: navigator.language,
cookiesEnabled: navigator.cookieEnabled
};
});
}"
# Pass arguments to evaluate
playwright-cli run-code "async page => {
const multiplier = 5;
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
}"
```
## Error Handling
```bash
# Try-catch in run-code
playwright-cli run-code "async page => {
try {
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
return 'clicked';
} catch (e) {
return 'element not found';
}
}"
```
## Complex Workflows
```bash
# Login and save state
playwright-cli run-code "async page => {
await page.goto('https://example.com/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'auth.json' });
return 'Login successful';
}"
# Scrape data from multiple pages
playwright-cli run-code "async page => {
const results = [];
for (let i = 1; i <= 3; i++) {
await page.goto(\`https://example.com/page/\${i}\`);
const items = await page.locator('.item').allTextContents();
results.push(...items);
}
return results;
}"
```
@@ -0,0 +1,225 @@
# Browser Session Management
Run multiple isolated browser sessions concurrently with state persistence.
## Named Browser Sessions
Use `-s` flag to isolate browser contexts:
```bash
# Browser 1: Authentication flow
playwright-cli -s=auth open https://app.example.com/login
# Browser 2: Public browsing (separate cookies, storage)
playwright-cli -s=public open https://example.com
# Commands are isolated by browser session
playwright-cli -s=auth fill e1 "user@example.com"
playwright-cli -s=public snapshot
```
## Browser Session Isolation Properties
Each browser session has independent:
- Cookies
- LocalStorage / SessionStorage
- IndexedDB
- Cache
- Browsing history
- Open tabs
## Browser Session Commands
```bash
# List all browser sessions
playwright-cli list
# Stop a browser session (close the browser)
playwright-cli close # stop the default browser
playwright-cli -s=mysession close # stop a named browser
# Stop all browser sessions
playwright-cli close-all
# Forcefully kill all daemon processes (for stale/zombie processes)
playwright-cli kill-all
# Delete browser session user data (profile directory)
playwright-cli delete-data # delete default browser data
playwright-cli -s=mysession delete-data # delete named browser data
```
## Environment Variable
Set a default browser session name via environment variable:
```bash
export PLAYWRIGHT_CLI_SESSION="mysession"
playwright-cli open example.com # Uses "mysession" automatically
```
## Common Patterns
### Concurrent Scraping
```bash
#!/bin/bash
# Scrape multiple sites concurrently
# Start all browsers
playwright-cli -s=site1 open https://site1.com &
playwright-cli -s=site2 open https://site2.com &
playwright-cli -s=site3 open https://site3.com &
wait
# Take snapshots from each
playwright-cli -s=site1 snapshot
playwright-cli -s=site2 snapshot
playwright-cli -s=site3 snapshot
# Cleanup
playwright-cli close-all
```
### A/B Testing Sessions
```bash
# Test different user experiences
playwright-cli -s=variant-a open "https://app.com?variant=a"
playwright-cli -s=variant-b open "https://app.com?variant=b"
# Compare
playwright-cli -s=variant-a screenshot
playwright-cli -s=variant-b screenshot
```
### Persistent Profile
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
```bash
# Use persistent profile (auto-generated location)
playwright-cli open https://example.com --persistent
# Use persistent profile with custom directory
playwright-cli open https://example.com --profile=/path/to/profile
```
## Attaching to a Running Browser
Use `attach` to connect to a browser that is already running, instead of launching a new one.
### Attach by channel name
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
```bash
# Attach to Chrome
playwright-cli attach --cdp=chrome
# Attach to Chrome Canary
playwright-cli attach --cdp=chrome-canary
# Attach to Microsoft Edge
playwright-cli attach --cdp=msedge
# Attach to Edge Dev
playwright-cli attach --cdp=msedge-dev
```
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
### Attach via CDP endpoint
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
```bash
playwright-cli attach --cdp=http://localhost:9222
```
### Attach via browser extension
Connect to a browser with the Playwright extension installed:
```bash
playwright-cli attach --extension
```
### Detach
Tear down an attached session without affecting the external browser:
```bash
# Detach the default attached session
playwright-cli detach
# Detach a specific attached session
playwright-cli -s=msedge detach
```
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
## Default Browser Session
When `-s` is omitted, commands use the default browser session:
```bash
# These use the same default browser session
playwright-cli open https://example.com
playwright-cli snapshot
playwright-cli close # Stops default browser
```
## Browser Session Configuration
Configure a browser session with specific settings when opening:
```bash
# Open with config file
playwright-cli open https://example.com --config=.playwright/my-cli.json
# Open with specific browser
playwright-cli open https://example.com --browser=firefox
# Open in headed mode
playwright-cli open https://example.com --headed
# Open with persistent profile
playwright-cli open https://example.com --persistent
```
## Best Practices
### 1. Name Browser Sessions Semantically
```bash
# GOOD: Clear purpose
playwright-cli -s=github-auth open https://github.com
playwright-cli -s=docs-scrape open https://docs.example.com
# AVOID: Generic names
playwright-cli -s=s1 open https://github.com
```
### 2. Always Clean Up
```bash
# Stop browsers when done
playwright-cli -s=auth close
playwright-cli -s=scrape close
# Or stop all at once
playwright-cli close-all
# If browsers become unresponsive or zombie processes remain
playwright-cli kill-all
```
### 3. Delete Stale Browser Data
```bash
# Remove old browser data to free disk space
playwright-cli -s=oldsession delete-data
```
@@ -0,0 +1,305 @@
# Spec-driven testing (plan → generate → heal)
End-to-end workflow for authoring and maintaining Playwright tests using `playwright-cli`. The three sections below can be used independently:
- **Planning** — explore the app, produce a spec file describing what to test.
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
All three lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics and [test-generation.md](test-generation.md) for how every `playwright-cli` action emits Playwright TypeScript.
---
## 1. Planning
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
### 1.1 Prerequisite: workspace
Check the workspace has Playwright installed before anything else:
```bash
# Either of these confirms a workspace:
test -f playwright.config.ts || test -f playwright.config.js
npx --no-install playwright --version
```
If there is no Playwright install, bootstrap one and let the user pick the defaults:
```bash
npm init playwright@latest
```
### 1.2 Prerequisite: seed test
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
Minimum viable seed:
```ts
// tests/seed.spec.ts
import { test } from '@playwright/test';
test('seed', async ({ page }) => {
await page.goto('https://example.com/');
});
```
Preferred — push navigation into a fixture so scenario tests reuse it:
```ts
// tests/fixtures.ts
import { test as baseTest } from '@playwright/test';
export { expect } from '@playwright/test';
export const test = baseTest.extend({
page: async ({ page }, use) => {
await page.goto('https://example.com/');
await use(page);
},
});
```
```ts
// tests/seed.spec.ts
import { test } from './fixtures';
test('seed', async ({ page }) => {
// Fixture already navigates. This empty body tells agents where to start.
});
```
If no seed exists, create one that at least navigates to the app.
### 1.3 Explore the app
Launch the app via the seed in the background and attach:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
# wait for "Debugging Instructions" and the session name tw-XXXX
playwright-cli attach tw-XXXX
```
Resume so the seed runs, then probe the app:
```bash
playwright-cli resume # resume so that seed test runs fully
playwright-cli snapshot # inventory of interactive elements
playwright-cli click e5 # follow a flow
playwright-cli eval "location.href" # read URL / state
playwright-cli show --annotate # ask the user to point at something
```
Map out:
- Interactive surfaces (forms, buttons, lists, filters, modals).
- Primary user journeys end-to-end.
- Edge cases: empty states, validation errors, very long input, boundary values.
- Persistence: reload, local/session storage, URL fragments.
- Navigation: which controls change the URL, back/forward behaviour.
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
**Important**: Stop the background test when done exploring.
### 1.4 Write the spec file
Save under `specs/<feature>.plan.md`. Use this structure:
```markdown
# <Feature> Test Plan
## Application Overview
<One paragraph describing what the feature does and why it matters.>
## Test Scenarios
### 1. <Group Name>
**Seed:** `tests/seed.spec.ts`
#### 1.1. <kebab-case-scenario-name>
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
**Steps:**
1. <Concrete user step>
- expect: <observable outcome>
- expect: <another observable outcome>
2. <Next step>
- expect: <outcome>
#### 1.2. <next-scenario>
...
### 2. <Next Group>
**Seed:** `tests/seed.spec.ts`
...
```
Guidelines:
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
- Scenario names are kebab-case and match the test file name (`should-add-single-todo``should-add-single-todo.spec.ts`).
- Cover happy path, edge cases, validation, negative flows, persistence.
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
---
## 2. Generate
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
### 2.1 Inputs
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
### 2.2 Generate one scenario
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
playwright-cli attach tw-XXXX
# resume
```
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
Every action prints the equivalent Playwright TypeScript (see [test-generation.md](test-generation.md)):
```bash
playwright-cli snapshot # find refs
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
playwright-cli press Enter
playwright-cli click e7
```
For each `- expect:` bullet, add an explicit assertion. See [test-generation.md](test-generation.md) for details.
Collect the generated code and write the test file at the path given in the spec:
```ts
// spec: specs/basic-operations.plan.md
// seed: tests/seed.spec.ts
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
test.describe('Signing in and out', () => {
test('should sign in', async ({ page }) => {
// 1. Navigate to the application
// (handled by the seed fixture)
// 2. Type 'John Doe' into the username field
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
// 3. Type password
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
// 4. Press Enter to submit
await page.getByRole('textbox', { name: 'password' }).press('Enter');
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
});
});
```
Rules:
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
- Use the describe group name verbatim from the spec (no `1.` ordinal).
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
### 2.3 Generate multiple scenarios
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
### 2.4 Run generated tests
After generation, run the new tests once:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
```
Any failure goes to Section 3.
---
## 3. Heal
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
### 3.1 Find failing tests
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test
```
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
### 3.2 Debug one failure
Run the single failing test in debug mode in the background, then attach:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
# wait for "Debugging Instructions" and the tw-XXXX session name
playwright-cli attach tw-XXXX
```
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
```bash
playwright-cli snapshot # did the element change / move / rename?
playwright-cli console # app-side errors?
playwright-cli requests # failed request? wrong payload?
playwright-cli show --annotate # ask the user to point somewhere
```
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
### 3.3 Apply the fix
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
### 3.4 Reconcile with the spec
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
- the scenario id (e.g. `2.3`),
- the spec lines that no longer match,
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
### 3.5 Iteration and giving up
- Fix failures one at a time; rerun after each.
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
---
## Cross-references
| For... | See |
|---|---|
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
| How `playwright-cli` actions become TS | [test-generation.md](test-generation.md) |
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
| Managing the CLI browser session | [session-management.md](session-management.md) |
@@ -0,0 +1,275 @@
# Storage Management
Manage cookies, localStorage, sessionStorage, and browser storage state.
## Storage State
Save and restore complete browser state including cookies and storage.
### Save Storage State
```bash
# Save to auto-generated filename (storage-state-{timestamp}.json)
playwright-cli state-save
# Save to specific filename
playwright-cli state-save my-auth-state.json
```
### Restore Storage State
```bash
# Load storage state from file
playwright-cli state-load my-auth-state.json
# Reload page to apply cookies
playwright-cli open https://example.com
```
### Storage State File Format
The saved file contains:
```json
{
"cookies": [
{
"name": "session_id",
"value": "abc123",
"domain": "example.com",
"path": "/",
"expires": 1893456000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://example.com",
"localStorage": [
{ "name": "theme", "value": "dark" },
{ "name": "user_id", "value": "12345" }
]
}
]
}
```
## Cookies
### List All Cookies
```bash
playwright-cli cookie-list
```
### Filter Cookies by Domain
```bash
playwright-cli cookie-list --domain=example.com
```
### Filter Cookies by Path
```bash
playwright-cli cookie-list --path=/api
```
### Get Specific Cookie
```bash
playwright-cli cookie-get session_id
```
### Set a Cookie
```bash
# Basic cookie
playwright-cli cookie-set session abc123
# Cookie with options
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
# Cookie with expiration (Unix timestamp)
playwright-cli cookie-set remember_me token123 --expires=1893456000
```
### Delete a Cookie
```bash
playwright-cli cookie-delete session_id
```
### Clear All Cookies
```bash
playwright-cli cookie-clear
```
### Advanced: Multiple Cookies or Custom Options
For complex scenarios like adding multiple cookies at once, use `run-code`:
```bash
playwright-cli run-code "async page => {
await page.context().addCookies([
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
]);
}"
```
## Local Storage
### List All localStorage Items
```bash
playwright-cli localstorage-list
```
### Get Single Value
```bash
playwright-cli localstorage-get token
```
### Set Value
```bash
playwright-cli localstorage-set theme dark
```
### Set JSON Value
```bash
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
```
### Delete Single Item
```bash
playwright-cli localstorage-delete token
```
### Clear All localStorage
```bash
playwright-cli localstorage-clear
```
### Advanced: Multiple Operations
For complex scenarios like setting multiple values at once, use `run-code`:
```bash
playwright-cli run-code "async page => {
await page.evaluate(() => {
localStorage.setItem('token', 'jwt_abc123');
localStorage.setItem('user_id', '12345');
localStorage.setItem('expires_at', Date.now() + 3600000);
});
}"
```
## Session Storage
### List All sessionStorage Items
```bash
playwright-cli sessionstorage-list
```
### Get Single Value
```bash
playwright-cli sessionstorage-get form_data
```
### Set Value
```bash
playwright-cli sessionstorage-set step 3
```
### Delete Single Item
```bash
playwright-cli sessionstorage-delete step
```
### Clear sessionStorage
```bash
playwright-cli sessionstorage-clear
```
## IndexedDB
### List Databases
```bash
playwright-cli run-code "async page => {
return await page.evaluate(async () => {
const databases = await indexedDB.databases();
return databases;
});
}"
```
### Delete Database
```bash
playwright-cli run-code "async page => {
await page.evaluate(() => {
indexedDB.deleteDatabase('myDatabase');
});
}"
```
## Common Patterns
### Authentication State Reuse
```bash
# Step 1: Login and save state
playwright-cli open https://app.example.com/login
playwright-cli snapshot
playwright-cli fill e1 "user@example.com"
playwright-cli fill e2 "password123"
playwright-cli click e3
# Save the authenticated state
playwright-cli state-save auth.json
# Step 2: Later, restore state and skip login
playwright-cli state-load auth.json
playwright-cli open https://app.example.com/dashboard
# Already logged in!
```
### Save and Restore Roundtrip
```bash
# Set up authentication state
playwright-cli open https://example.com
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
# Save state to file
playwright-cli state-save my-session.json
# ... later, in a new session ...
# Restore state
playwright-cli state-load my-session.json
playwright-cli open https://example.com
# Cookies and localStorage are restored!
```
## Security Notes
- Never commit storage state files containing auth tokens
- Add `*.auth-state.json` to `.gitignore`
- Delete state files after automation completes
- Use environment variables for sensitive data
- By default, sessions run in-memory mode which is safer for sensitive operations
@@ -0,0 +1,134 @@
# Test Generation
Generate Playwright test code automatically as you interact with the browser.
## How It Works
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
This code appears in the output and can be copied directly into your test files.
## Example Workflow
```bash
# Start a session
playwright-cli open https://example.com/login
# Take a snapshot to see elements
playwright-cli snapshot
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
# Fill form fields - generates code automatically
playwright-cli fill e1 "user@example.com"
# Ran Playwright code:
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
playwright-cli fill e2 "password123"
# Ran Playwright code:
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
playwright-cli click e3
# Ran Playwright code:
# await page.getByRole('button', { name: 'Sign In' }).click();
```
## Building a Test File
Collect the generated code into a Playwright test:
```typescript
import { test, expect } from '@playwright/test';
test('login flow', async ({ page }) => {
// Generated code from playwright-cli session:
await page.goto('https://example.com/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
// Add assertions
await expect(page).toHaveURL(/.*dashboard/);
});
```
## Best Practices
### 1. Use Semantic Locators
The generated code uses role-based locators when possible, which are more resilient:
```typescript
// Generated (good - semantic)
await page.getByRole('button', { name: 'Submit' }).click();
// Avoid (fragile - CSS selectors)
await page.locator('#submit-btn').click();
```
### 2. Explore Before Recording
Take snapshots to understand the page structure before recording actions:
```bash
playwright-cli open https://example.com
playwright-cli snapshot
# Review the element structure
playwright-cli click e5
```
### 3. Add Assertions Manually
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
- `toBeVisible()` — element is rendered and visible
- `toHaveText(text)` — element text content matches
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
```bash
# Get a stable locator for an element ref to use in the assertion
playwright-cli --raw generate-locator e5
# getByRole('button', { name: 'Submit' })
# Capture expected text content for toHaveText
playwright-cli --raw eval "el => el.textContent" e5
# Capture expected input value for toHaveValue/toBeEmpty
playwright-cli --raw eval "el => el.value" e5
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
# (whole page, or use a ref to scope to a region)
playwright-cli --raw snapshot
playwright-cli --raw snapshot e5
```
```typescript
// Generated action
await page.getByRole('button', { name: 'Submit' }).click();
// Manual assertions using the outputs above:
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
// toMatchAriaSnapshot on the whole page, finds a matching region
await expect(page).toMatchAriaSnapshot(`
- heading "Welcome, user"
- link /\\d+ new messages?/
- button "Sign out"
`);
// toMatchAriaSnapshot scoped to a region
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
- link "Home"
- link /\\d+ new messages?/
- link "Profile"
`);
```

Some files were not shown because too many files have changed in this diff Show More