openvibers/openvibe.shared Commits
4.204.0: buttons that show their state, resizable windows, the composer everywhere, players, csp (#460)
Changes
@@@ -1,5 +1,29 @@# Changelog++## 4.204.0 — 2026-11-11++++**Buttons that show their state, windows that resize, the one composer everywhere, players that start sooner, and one++place for the network's CSP.**++++- **Buttons that show what they do and what is happening (`kit.css`, `kit.js`, `kit-web.js`).** Kinds by importance++ (hero, soft, live beside primary, quiet, ghost, danger); a count badge, a status dot, a toggle's fill, a progress++ fill; attention (the one next step glows now and then); busy → a check, or a shake and red++ (`OpenVibeKitWeb.busy()`); hold-to-confirm for destructive buttons; icons that move by meaning. Every motion follows++ the motion setting. docs/kit.md#buttons.++- **A panel window can be resized (`ov-window.js`).** `resizable: true` adds a corner grip that a mouse, a finger or++ a pen drags and the arrow keys move; the size is kept with the place per id.++- **The composer carries its own look, and Vibe's Dock box is the same composer (`ov-composer.js`, `dock-vibe.js`).**++ Its styles moved out of `thread.css` into the script; Vibe's box gets drafts, "/" commands, the marks and settings,++ with `data-ovc-no-files` keeping its picture upload its own. (Was #458, never released.)++- **Players start sooner (`player.js`, #454).** The poster stays until a frame is presented; preload follows the++ connection; battery-aware HLS attachment with a low first rung. docs/player-startup.md.++- **No more macOS flakes from fixed sleeps (tests).** A browser test now checks the page with `eventually()` from++ browser-harness (it retries until the page is there, 5 s at most) instead of after a guessed sleep: 382 checks in 26++ files, including menu-places-chrome, whose 180 ms sleep against a 140 ms debounce failed on the slow macOS runners++ and held back 4.202.0 and 4.203.0. `test/fixed-waits.test.js` keeps the count of sleep-then-check places at zero.++- **`openvibe-shared/csp` (#455).** One place for the network's Content-Security-Policy pieces; `connectSources()`++ names Media, the composer's upload origin.++## 4.203.0 — 2026-11-11- Dock conversations update edited messages and link cards live; authors can edit their own messages.@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.203.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.204.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,13 +4,14 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.203.0",++ "version": "4.204.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)","contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)","sdk": "openvibe-sdk v0.147.0","features": [++ "buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)","the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)","the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)","the page-view beacon sends ?in=1 only for a signed-in browser and counts a guest at once, so a signed-in person is counted from presence and never also as a guest (4.191.0)",@@@ -908,7 +908,23 @@ async function run(opts = {}) {return report;}--module.exports = { textSmells,++/**++ * Retry an assertion until it passes (the browser tests' answer to a fixed wait before a check, 2026-11-11: the macOS++ * runners are slow enough that a 140 ms debounce sometimes outlasted a 180 ms sleep, and Shared's releases kept failing++ * on menu-places-chrome). check is an async function that throws while the page is not there yet; the last error is++ * thrown after timeout ms. Waits are a ceiling, never a guess: a check that is already true returns at once.++ */++async function eventually(check, { timeout = 5000, every = 50 } = {}) {++ const end = Date.now() + timeout;++ let last;++ for (;;) {++ try { return await check(); } catch (e) { last = e; }++ if (Date.now() >= end) throw last;++ await new Promise((r) => setTimeout(r, every));++ }++}++++module.exports = { textSmells, eventually,run, launch, openPage, format, summarize, findChrome, axeSource, checkUnreachable,// pure helpers, exported for tests and for the Host CLInormalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * openvibe-shared/csp — the network's Content-Security-Policy pieces, in one place.++ *++ * Every OpenVibe site builds a CSP header. Before this module each built its own string, so the composer's upload to++ * OpenVibe.Media was blocked on any site that had not named `https://openvibe.media` in connect-src (the browser sends++ * the multipart POST there, and a CSP refusal is a network error with no status). A site that uses these helpers gets++ * Network, Events and Media in connect-src by default; a site that needs more (its own websocket, Cloudflare's beacon)++ * adds it with `sites` or `extra`.++ *++ * const csp = require('openvibe-shared/csp');++ * res.setHeader('Content-Security-Policy', csp.policy({ nonce, sites: ['wss://openvibe.chat'], frames: ['https://openvibe.bot'] }));++ *++ * `connectSources()` is the list on its own, for a site that keeps its own policy but wants the network's origins.++ */++const NETWORK = 'https://openvibe.network';++const EVENTS = 'https://openvibe.events';++const MEDIA = 'https://openvibe.media';++++const uniq = (list) => [...new Set(list.filter(Boolean).map(String))];++++/**++ * The connect-src value: this site, the Network (sign-in, the Frame, settings, mentions), Events (release and++ * notification streams) and Media (composer attachments). `sites` and `extra` add more.++ */++function connectSources({ self = true, sites = [], extra = [] } = {}) {++ return uniq([self ? "'self'" : null, NETWORK, `wss://${NETWORK.slice(8)}`, EVENTS, `wss://${EVENTS.slice(8)}`, MEDIA, ...sites, ...extra]).join(' ');++}++++/**++ * A full policy. Defaults suit a server-rendered site that runs only its own scripts, with inline styles and pictures++ * and media from anywhere over https.++ *++ * nonce the response's script nonce (inline scripts need it)++ * script extra script origins++ * style extra style origins++ * images the img-src value (default https:)++ * media the media-src value (default https:)++ * frames frame-src entries (default 'self')++ * formAction the form-action value (default 'self' https://openvibe.network)++ * sites extra connect-src origins++ * connect a full connect-src value, instead of connectSources()++ * connectExtra extra connect-src entries++ * objectSrc object-src (default 'none')++ * baseUri base-uri (default 'self')++ * frameAncestors frame-ancestors (default 'self')++ * extra { directive: value } appended (e.g. { 'worker-src': "'self' blob:" })++ */++function policy({ nonce, script = [], style = [], images = 'https:', media = 'https:', frames = ["'self'"], formAction = `'self' ${NETWORK}`, connect, sites = [], connectExtra = [], objectSrc = "'none'", baseUri = "'self'", frameAncestors = "'self'", extra = {} } = {}) {++ const directives = [++ ["default-src", "'self'"],++ ["script-src", ["'self'", nonce ? `'nonce-${nonce}'` : null, ...script].filter(Boolean).join(' ')],++ ["style-src", ["'self'", "'unsafe-inline'", ...style].filter(Boolean).join(' ')],++ ["img-src", images],++ ["media-src", media],++ ["font-src", "'self' data:"],++ ["connect-src", connect || connectSources({ sites, extra: connectExtra })],++ ["frame-src", frames.join(' ')],++ ["frame-ancestors", frameAncestors],++ ["base-uri", baseUri],++ ["object-src", objectSrc],++ ["form-action", formAction],++ ];++ const seen = new Set(directives.map(([d]) => d));++ for (const [d, v] of Object.entries(extra)) if (!seen.has(d) && v != null) directives.push([d, String(v)]);++ return directives.map(([d, v]) => `${d} ${v}`).join('; ');++}++++module.exports = { NETWORK, EVENTS, MEDIA, MEDIA_UPLOAD_ORIGIN: MEDIA, connectSources, policy };@@@ -55,6 +55,21 @@.ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); }`;let ctx = null, G = {};++ // 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for++ // drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.++ const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';++ const VIBE_REV = { 'ov-composer.js': 'f52e2323e6e9' };++ function joinComposer(form) {++ if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box++ form.setAttribute('data-ovc-no-files', '');++ const go = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch (e) { console.error(e); } };++ if (root.OpenVibeComposer) { go(); return; }++ const m = /^(https?:\/\/[^?#]*\/)dock-vibe(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);++ if (!m) return;++ let sc = document.getElementById('ov-composer-script');++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js' + (VIBE_REV['ov-composer.js'] ? '?v=' + VIBE_REV['ov-composer.js'] : ''); document.head.appendChild(sc); }++ sc.addEventListener('load', go, { once: true });++ }const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };/** A round icon button with its name as label and tooltip. */const iconButton = (tag, cls, path, label) => { const b = h(tag, cls); if (tag === 'button') b.type = 'button'; b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); b.title = label; b.innerHTML = svg(path); return b; };@@@ -249,6 +264,7 @@} finally { vibe.busy = false; go.disabled = false; box.focus(); }}form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });++ joinComposer(form);async function pull(first) {let got;@@@ -89,7 +89,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': '1bfa19b01e8e', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };++ const DOCK_REV = { 'dock-vibe.js': '81365d0289bb', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -362,6 +362,37 @@ kit.tool({The frame is part of Kit 2: its look is in `kit-data.css` and its behaviour (the clear button and the examples) in`kit-data-web.js`. A page that draws no tool loads neither, so `kit.css` and `kit-web.js` weigh what they did.++## Buttons (4.204.0)++++A button says what it does and shows what is happening. One look on every site; every motion follows the motion++setting (Calm makes it smaller and slower, Off removes it).++++| Kind | When | Look |++|---|---|---|++| `hero` | the one thing the page is for (Go live, Start) | 52 px, the accent gradient, a glow |++| `primary` | the main action of a section | filled accent |++| `soft` | a second choice that still matters (Invite, Finish setup) | tinted accent |++| `quiet` | everything else | panel with a line |++| `ghost` | a link-like action inside text or a row | accent words only |++| `danger` | destructive; with `hold` it asks for a press and hold | red line |++| `live` | on air now | red, a breathing dot |++++Status, on any kind: `badge` (a count at the corner, 99+ at most), `dot` (`ok`, `warn`, `bad`, `live`; live++breathes), `pressed` (a toggle: aria-pressed, tinted), `attention` (the one next step: a soft ring goes out now and++then until it is pressed; one per screen), `nudge` (the icon moves by meaning on hover: `forward`, `back`, `add`,++`open`, `down`, `refresh`), `iconEnd` (an icon after the words), and `style="--ovk-progress: .4"` (a fill inside it).++++```js++kit.button({ label: 'Go live', icon: 'live', kind: 'hero', nudge: 'forward', iconEnd: 'chevron' })++kit.button({ label: 'Messages', icon: 'chat', kind: 'quiet', badge: 3, type: 'button' })++kit.button({ label: 'Delete', kind: 'danger', hold: 1200, type: 'button' })++await OpenVibeKitWeb.busy(button, fetch('/api/save', { method: 'POST' })) // busy → a check, or a shake and red++```++++`busy(button, work)` keeps the words, adds the mark's wait beside them and aria-busy, and disables the button; when the++work resolves the button turns green with a check for a moment, and when it fails it shakes once and turns red. A++`hold` button fires its click only after the hold has run (pointer, Enter or Space); letting go early cancels.++## The navbar's linksThe 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:nnew file mode 100644@@@ -0,0 +1,45 @@++# Player starts — ask 288++++Measured 2026-11-10 UTC with system Chrome and fresh Playwright contexts, browser cache cold. No Live stream was online (`/api/streams`: `{"streams":[],"agents":[]}`). Live `/vod/6298` redirects to Video `/watch?v=sU4LkcV7PpM`; these are two entry paths to the same 341-second ROBOT recording, not independent encodes. Its joined HLS object is `med_01M729VRTW6E3NPTTM008H0GAA`.++++Phone: 1.6 Mbit/s down, 150 ms latency, 4x CPU slowdown, 390px. Computer: 10 Mbit/s down, 40 ms latency, native CPU, 1280px. Upload 0.75 Mbit/s. TTFF starts at the explicit muted play request (after the video element appears), and ends at requestVideoFrameCallback. Rebuffer ratio is waiting time divided by the following 20 seconds of wall time. Seek tests start from 220 seconds in a fresh context. One sample per entry/profile/position; these are diagnostic samples, not statistical estimates. The shared test host was busy (load 22–26 on 8 CPUs); reruns need the same caveat. Page loading and automatic preloading may precede the play request.++++## Before++++| Entry | Profile | Position | TTFF, s | Rebuffer | Observed tier |++|---|---|---:|---:|---:|---|++| live-recording | phone | 0s | 12.451 | 16.01% | B2 via Media |++| live-recording | phone | 220s | 15.126 | 4.14% | B2 via Media |++| live-recording | computer | 0s | 2.877 | 0.00% | B2 via Media |++| live-recording | computer | 220s | 3.848 | 0.00% | B2 via Media |++| video-recording | phone | 0s | 12.123 | 16.44% | B2 via Media |++| video-recording | phone | 220s | 14.914 | 5.97% | B2 via Media |++| video-recording | computer | 0s | 2.597 | 0.00% | B2 via Media |++| video-recording | computer | 220s | 3.923 | 0.00% | B2 via Media |++++Both opening and later segments redirect to `s3.us-west-004.backblazeb2.com`; Media may proxy packed chunks. No R2 response was observed. The part-source master offers only 720p (BANDWIDTH 3403963, average 2383603): greater than the phone throughput, so buffer changes cannot make this source sustainable on that connection. A low rung must exist before it can be selected.++++Evidence: `player-starts-evidence/measure.cjs`, `before.json`. The script saves hostnames only, never presigned URL queries. Production source URLs and the public page are used directly; no database writes or credentials.++++## After++++Pending Shared review and Media deployment. Do not treat a local Shared override as a production rollout.++++## Next for the design++++No layout or control changes. Cards adopting Shared warming need a public HLS source attribute; the main session owns their markup and appearance.++++## Shared candidate, local script override against production media++++The browser replaced only Shared player.js and the consumer HLS constructor call with this branch. This is not deployed. Chrome used native HLS in these samples, so these numbers do not isolate hls.js buffer tuning or card warming. No consistent rebuffer improvement can be inferred from these single samples, especially under the changing host load (22–47).++++| Entry | Profile | Position | TTFF, s | Rebuffer | Tier |++|---|---|---:|---:|---:|---|++| live-recording | phone | 0s | 8.976 | 22.48% | B2 |++| live-recording | phone | 220s | 17.388 | 3.43% | B2 |++| live-recording | computer | 0s | 2.771 | 0.00% | B2 |++| live-recording | computer | 220s | 5.357 | 0.00% | B2 |++| video-recording | phone | 0s | 10.983 | 4.88% | B2 |++| video-recording | phone | 220s | 19.946 | 0.97% | B2 |++| video-recording | computer | 0s | 3.791 | 0.00% | B2 |++| video-recording | computer | 220s | 5.541 | 0.00% | B2 |nnew file mode 100644@@@ -0,0 +1,22 @@++# Connection-aware player startup++++`player.js` keeps the poster until a decoded frame is presented, and applies preload on enhancement: `none` for Save-Data/2g/slow-2g, `metadata` for 3g, `auto` otherwise. Set the same preload before assigning a source when creating a video dynamically: enhancement cannot undo bytes the HTML parser already requested. The Battery API is optional; its absence uses the connection policy.++++Pages that attach HLS use the same policy with their existing Hls constructor and error handling:++++```js++const hls = OpenVibePlayer.attachHls(video, Hls, playlist, { enableWorker: true });++// Keep the page's Hls.Events.ERROR fallback and destroy hls on navigation.++```++++Native HLS uses the video's preload; hls.js loads the manifest but defers segments until play on constrained connections. The first level is 0 (hls.js orders the ladder by bitrate); automatic ABR then chooses subsequent levels. No rendition is invented if the source only has one. Forward/back buffers are 60/30 seconds normally, 30/15 on 3g, 30/0 on 2g or Save-Data, and 15/0 on an unplugged battery at 20% or lower. The hard forward limits are 120/60/30 seconds. Battery and connection changes update the attachment; destroy removes listeners.++++Cards opt in using a **public**, unsigned VOD HLS source:++++```html++<a href="/watch?v=…" data-ov-player-hls="https://openvibe.media/o/…/master.m3u8">Watch</a>++```++++Call `OpenVibePlayer.warmCards(container)` for cards added later. Hover or keyboard focus for 120 ms warms the lowest playlist, its init and first media segment into HTTP cache. It stops on leave/blur, avoids live, encrypted, signed and byte-range playlists, and makes no requests on Save-Data/2g/3g or low battery. Each card is capped at 2 MiB and each document at 20 starts per minute. Oversized first segments are cancelled; warming is best effort, never a prerequisite for playback. HTTP cache reuse depends on the source's cache/CORS headers and browser partitioning.++++These helpers do not replace a consumer's existing HLS constructor automatically. Consumer adoption and release pins are necessary to get the buffer policy. Next for the design: use the source attribute on existing cards; keep their appearance and controls with the main session.@@@ -18,6 +18,10 @@* and a page move close it (document-level, so boost-swapped pages need no wiring)* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never* under reduced motion; the file loads only then)++ * hold .ovk-btn[data-hold="1200"]: press and hold to confirm (the fill runs; letting go early cancels; Enter or++ * Space held works the same); the click goes through only after the hold++ * busy(button, work) shows the work on the button: busy (the words stay, the wait beside them, aria-busy), then a++ * check for a moment, or a shake and red when it failed; work is a promise or a function returning one** Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.*/@@@ -291,7 +295,68 @@}-- const api = { enhance };++ /** The work a button started, shown on the button. Resolves with the work's value; rethrows its error. */++ async function busy(btn, work) {++ if (!btn) return typeof work === 'function' ? work() : work;++ const wait = document.createElement('span');++ wait.className = 'ovk-btn-wait'; wait.setAttribute('aria-hidden', 'true'); wait.innerHTML = '<i></i><i></i><i></i>';++ const wasDisabled = btn.disabled;++ btn.dataset.state = 'busy'; btn.setAttribute('aria-busy', 'true'); btn.appendChild(wait);++ if ('disabled' in btn) btn.disabled = true;++ const settle = (state) => {++ wait.remove(); btn.removeAttribute('aria-busy');++ if ('disabled' in btn) btn.disabled = wasDisabled;++ btn.dataset.state = state;++ setTimeout(() => { if (btn.dataset.state === state) delete btn.dataset.state; }, state === 'done' ? 1200 : 1600);++ };++ try {++ const out = await (typeof work === 'function' ? work() : work);++ settle('done');++ return out;++ } catch (e) { settle('error'); throw e; }++ }++++ // Hold to confirm: document-level, so buttons added later need no wiring.++ function bindHold() {++ if (typeof document === 'undefined' || root.__ovkHoldBound) return;++ root.__ovkHoldBound = true;++ let held = null;++ const stop = (fire) => {++ if (!held) return;++ const { btn, raf, ok } = held; held = null;++ cancelAnimationFrame(raf); btn.classList.remove('is-holding'); btn.style.removeProperty('--ovk-hold');++ if (fire && ok) { btn.dataset.ovkHeld = '1'; btn.click(); }++ };++ const start = (btn) => {++ const ms = Math.max(400, Math.min(4000, Number(btn.getAttribute('data-hold')) || 1200));++ const t0 = performance.now();++ held = { btn, raf: 0, ok: false };++ btn.classList.add('is-holding');++ const tick = (t) => {++ if (!held || held.btn !== btn) return;++ const f = Math.min(1, (t - t0) / ms);++ btn.style.setProperty('--ovk-hold', `${Math.round(f * 100)}%`);++ if (f >= 1) { held.ok = true; stop(true); return; }++ held.raf = requestAnimationFrame(tick);++ };++ held.raf = requestAnimationFrame(tick);++ };++ document.addEventListener('pointerdown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !b.disabled && e.button === 0) start(b); });++ document.addEventListener('pointerup', () => stop(false));++ document.addEventListener('pointercancel', () => stop(false));++ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !e.repeat && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); start(b); } });++ document.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') stop(false); });++ // A plain click on a hold button does nothing until the hold has run.++ document.addEventListener('click', (e) => {++ const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]');++ if (!b) return;++ if (b.dataset.ovkHeld === '1') { delete b.dataset.ovkHeld; return; }++ e.preventDefault(); e.stopImmediatePropagation();++ }, true);++ }++ bindHold();++++ const api = { enhance, busy };root.OpenVibeKitWeb = api;if (typeof module === 'object' && module.exports) { module.exports = api; return; }if (typeof document !== 'undefined') {@@@ -118,6 +118,83 @@ html[data-ov-motion=reduced] { --ov-move: .35; --ov-dur-1: .15s; --ov-dur-2: .25.ovk-icon-btn[hidden] { display: none; }.ovk-icon-btn.is-done { color: var(--ovk-ok-text); }.ovk-icon-btn--danger:hover { color: var(--ovk-bad-text); }++++/* ── Buttons that show what they do and what is happening (4.204.0) ───────────────────────────────────────────────++ The owner, 2026-11-11: "improve our buttons across the site and add better animations and effects for them, and++ different things we can use them for, and little indicators or statuses … idle animation … more important buttons …++ more intuitive and more modular … shared across everything through openvibe.design". docs/kit.md#buttons.++ Every motion is scaled by --ov-move (Calm makes it smaller and slower, Off removes it; the durations follow). */++.ovk-btn, .ovk-icon-btn { position: relative; -webkit-tap-highlight-color: transparent;++ transition: transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease), box-shadow var(--ov-dur-2, .2s) var(--ov-ease-out, ease),++ background-color var(--ov-dur-1, .12s), border-color var(--ov-dur-1, .12s), color var(--ov-dur-1, .12s), filter var(--ov-dur-1, .12s); }++.ovk-btn:hover:not([disabled]):not([aria-disabled="true"]) { transform: translateY(calc(-1px * var(--ov-move, 1))); box-shadow: 0 8px 18px -12px rgba(0, 0, 0, .6); }++.ovk-btn:active:not([disabled]), .ovk-icon-btn:active:not([disabled]) { transform: scale(calc(1 - .03 * var(--ov-move, 1))); box-shadow: none; transition-duration: .06s; }++.ovk-btn .ovk-i, .ovk-icon-btn .ovk-i { transition: transform var(--ov-dur-2, .2s) var(--ov-ease-spring, var(--ov-ease-out, ease)); }++/* The icon moves the way the button does (data-nudge): forward slides on, add turns, open lifts, down drops, refresh turns. */++.ovk-btn[data-nudge="forward"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="forward"]:hover .ovk-i { transform: translateX(calc(3px * var(--ov-move, 1))); }++.ovk-btn[data-nudge="back"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="back"]:hover .ovk-i { transform: translateX(calc(-3px * var(--ov-move, 1))); }++.ovk-btn[data-nudge="add"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="add"]:hover .ovk-i { transform: rotate(calc(90deg * var(--ov-move, 1))); }++.ovk-btn[data-nudge="open"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="open"]:hover .ovk-i { transform: translate(calc(2px * var(--ov-move, 1)), calc(-2px * var(--ov-move, 1))); }++.ovk-btn[data-nudge="down"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="down"]:hover .ovk-i { transform: translateY(calc(2px * var(--ov-move, 1))); }++.ovk-btn[data-nudge="refresh"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="refresh"]:hover .ovk-i { transform: rotate(calc(180deg * var(--ov-move, 1))); }++++/* Importance: hero (the one thing a page is for), soft (a tinted second choice), live (on air now). */++.ovk-btn--hero { min-height: 52px; padding: 12px 24px; font-size: 1.02rem; color: var(--ovk-on-fill); border: 0;++ background: linear-gradient(135deg, var(--ovk-accent), var(--ovk-fill)); box-shadow: 0 10px 26px -14px var(--ovk-fill); }++.ovk-btn--hero:hover:not([disabled]) { box-shadow: 0 14px 30px -12px var(--ovk-fill); filter: brightness(1.06); }++.ovk-btn--soft { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); border-color: color-mix(in srgb, var(--ovk-accent) 22%, transparent); }++.ovk-btn--soft:hover { background: color-mix(in srgb, var(--ovk-accent) 20%, transparent); }++.ovk-btn--live { color: #fff; background: var(--ovk-live); }++.ovk-btn--live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #fff; flex: none; animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }++@keyframes ovkBreathe { 50% { opacity: calc(1 - .65 * var(--ov-move, 1)); transform: scale(calc(1 - .2 * var(--ov-move, 1))); } }++++/* A toggle says it is on (aria-pressed): filled tint and a check-weight border. */++.ovk-btn[aria-pressed="true"], .ovk-icon-btn[aria-pressed="true"] { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 16%, transparent); border-color: color-mix(in srgb, var(--ovk-accent) 45%, transparent); }++++/* The one next step (data-attention): a soft ring goes out from it now and then, until it is pressed. */++.ovk-btn[data-attention]:not([disabled])::after, .ovk-icon-btn[data-attention]:not([disabled])::after {++ content: ""; position: absolute; inset: -1px; border-radius: inherit; pointer-events: none;++ box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovk-accent) 55%, transparent); animation: ovkAttention 3.2s var(--ov-ease-out, ease-out) infinite; }++@keyframes ovkAttention { 0%, 55% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovk-accent) 55%, transparent); } 85%, 100% { box-shadow: 0 0 0 calc(10px * var(--ov-move, 1)) transparent; } }++++/* A count (data-badge="3") and a status dot (data-dot="ok|warn|bad|live") at the top right. */++.ovk-btn[data-badge]:not([data-badge=""])::before, .ovk-icon-btn[data-badge]:not([data-badge=""])::before {++ content: attr(data-badge); position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box;++ border-radius: 999px; background: var(--ovk-bad); color: #fff; font-size: 11px; font-weight: 800; line-height: 18px; text-align: center;++ box-shadow: 0 0 0 2px var(--ovk-bg, var(--bg-primary, #0b0f19)); animation: ovkPop var(--ov-dur-2, .2s) var(--ov-ease-spring, ease-out); }++.ovk-btn--live[data-badge]::before { animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }++.ovk-btn[data-dot], .ovk-icon-btn[data-dot] { --ovk-dot: var(--ovk-muted); }++.ovk-btn[data-dot="ok"], .ovk-icon-btn[data-dot="ok"] { --ovk-dot: var(--ovk-ok); }++.ovk-btn[data-dot="warn"], .ovk-icon-btn[data-dot="warn"] { --ovk-dot: var(--warning, #f59e0b); }++.ovk-btn[data-dot="bad"], .ovk-icon-btn[data-dot="bad"], .ovk-btn[data-dot="live"], .ovk-icon-btn[data-dot="live"] { --ovk-dot: var(--ovk-bad); }++.ovk-btn[data-dot]:not([data-badge])::before, .ovk-icon-btn[data-dot]:not([data-badge])::before {++ content: ""; position: absolute; top: 4px; right: 4px; width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-dot);++ box-shadow: 0 0 0 2px var(--ovk-bg, var(--bg-primary, #0b0f19)); }++.ovk-btn[data-dot="live"]:not([data-badge])::before, .ovk-icon-btn[data-dot="live"]:not([data-badge])::before { animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }++@keyframes ovkPop { from { transform: scale(calc(1 - .6 * var(--ov-move, 1))); opacity: 0; } }++++/* Progress inside the button (style="--ovk-progress: .4"): an upload, a countdown, a hold. */++.ovk-btn[style*="--ovk-progress"] { background-image: linear-gradient(90deg, color-mix(in srgb, currentColor 18%, transparent) calc(var(--ovk-progress, 0) * 100%), transparent 0); }++++/* What is happening (data-state, set by OpenVibeKitWeb.busy): busy keeps the words and adds the mark's wait; done shows++ a check for a moment; error shakes once and turns red. */++.ovk-btn[data-state="busy"] { cursor: progress; pointer-events: none; }++.ovk-btn[data-state="busy"] > :not(.ovk-btn-wait) { opacity: .62; }++.ovk-btn-wait { display: inline-flex; gap: 3px; align-items: center; }++.ovk-btn-wait i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: ovkWait 1.1s var(--ov-ease-in-out, ease-in-out) infinite; }++.ovk-btn-wait i:nth-child(2) { animation-delay: .15s; } .ovk-btn-wait i:nth-child(3) { animation-delay: .3s; }++@keyframes ovkWait { 0%, 80%, 100% { opacity: .25; transform: translateY(0); } 40% { opacity: 1; transform: translateY(calc(-3px * var(--ov-move, 1))); } }++.ovk-btn[data-state="done"] { color: #fff; background: var(--ovk-ok); border-color: transparent; }++.ovk-btn[data-state="done"]::after { content: ""; flex: none; width: 16px; height: 16px; background: currentColor; animation: ovkPop var(--ov-dur-2, .2s) var(--ov-ease-spring, ease-out);++ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E") center / contain no-repeat;++ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E") center / contain no-repeat; }++.ovk-btn[data-state="error"] { color: var(--ovk-bad-text); border-color: var(--ovk-bad); background: color-mix(in srgb, var(--ovk-bad) 10%, transparent); animation: ovkShake .36s var(--ov-ease-out, ease-out); }++@keyframes ovkShake { 25% { transform: translateX(calc(-4px * var(--ov-move, 1))); } 50% { transform: translateX(calc(4px * var(--ov-move, 1))); } 75% { transform: translateX(calc(-2px * var(--ov-move, 1))); } }++++/* Hold to confirm (data-hold="1200" on a destructive button): the fill runs while it is held; letting go early cancels. */++.ovk-btn[data-hold] { background-image: linear-gradient(90deg, color-mix(in srgb, var(--ovk-bad) 26%, transparent) var(--ovk-hold, 0%), transparent 0); }++.ovk-btn[data-hold].is-holding { transition: none; }++@media (hover: none) { .ovk-btn:hover:not([disabled]):not([aria-disabled="true"]) { transform: none; box-shadow: none; } }.ovk-icon-btn .ovk-i { width: 20px; height: 20px; }@media (pointer: coarse) { .ovk-icon-btn { width: var(--ovk-tap); height: var(--ovk-tap); } }.ovk-badge { display: inline-flex; align-items: center; flex: none; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ovk-line); font-size: .75rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }@@@ -158,9 +158,22 @@// ── Controls ──/** A button or a link that looks like one. kind: 'primary' (default), 'quiet', 'danger', 'ghost'; small: a pill. */-- function button({ href = null, type = 'submit', label = '', icon: ic = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null } = {}) {++ // 4.204.0, buttons that show what they do and what is happening (docs/kit.md#buttons): kind 'hero' | 'soft' | 'live'++ // beside primary/quiet/ghost/danger; nudge (forward, back, add, open, down, refresh) moves the icon by meaning;++ // iconEnd puts it after the words; attention marks the one next step; badge a count; dot ok|warn|bad|live; hold++ // (ms) makes a destructive button hold-to-confirm; pressed makes it a toggle.++ function button({ href = null, type = 'submit', label = '', icon: ic = '', iconEnd = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null,++ nudge = null, attention = false, badge = null, dot = null, hold = null, pressed = null } = {}) {const cls = `ovk-btn ovk-btn--${esc(kind)}${small ? ' ovk-btn--small' : ''}`;-- const inner = `${ic ? icon(ic) : ''}<span>${esc(label)}</span>`;++ const more = { ...(extra || {}) };++ if (nudge) more['data-nudge'] = nudge;++ if (attention) more['data-attention'] = '';++ if (badge != null && badge !== '' && badge !== 0) more['data-badge'] = Number(badge) > 99 ? '99+' : String(badge);++ if (dot) more['data-dot'] = dot;++ if (hold) more['data-hold'] = String(Math.max(400, Math.min(4000, Number(hold) || 1200)));++ if (pressed != null) more['aria-pressed'] = pressed ? 'true' : 'false';++ extra = Object.keys(more).length ? more : null;++ const inner = `${ic ? icon(ic) : ''}<span>${esc(label)}</span>${iconEnd ? icon(iconEnd) : ''}`;if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;}@@@ -607,7 +607,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '1e63c27fc63c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'cca1c50e1cce', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '31d1cac08de5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -26,10 +26,72 @@'use strict';if (root.OpenVibeComposer || typeof document === 'undefined') return;++ // 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's++ // Vibe and Code boxes included. Injected once, before the first box is joined.++ const CSS = `.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }++.ovc-files[hidden] { display: none; }++.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;++ border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }++.ovc-file.is-bad { border-color: color-mix(in srgb, var(--live-red, #ef4444) 70%, transparent); }++.ovc-file.is-up::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: calc(var(--p, 0) * 100%); background: var(--ovt-accent, var(--accent, #3b82f6)); transition: width .2s; }++.ovc-file-th { flex: none; width: 40px; height: 40px; border-radius: 10px; object-fit: cover; background: #0b1220; }++.ovc-file-ic { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); color: var(--ovt-accent, var(--accent, #3b82f6)); font-size: 11px; font-weight: 750; letter-spacing: .02em; }++.ovc-file-t { min-width: 0; display: grid; line-height: 1.25; }++.ovc-file-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--text-primary, #e6edf7); }++.ovc-file-s { font-size: 12px; color: var(--text-muted, #7386a3); white-space: nowrap; }++.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }++.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }++.ovc-file-b { top: auto; bottom: 4px; }++.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }++.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }++.ovc-dropping { border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }++.ovc-dropping::before { content: "Drop to attach"; position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; border-radius: inherit; background: color-mix(in srgb, var(--bg-card, #131c2e) 86%, var(--ovt-accent, var(--accent, #3b82f6))); color: var(--text-primary, #e6edf7); font-weight: 650; pointer-events: none; }++.ovc-pop { position: absolute; z-index: 40; left: 6px; right: 6px; bottom: calc(100% + 8px); max-height: min(320px, 50vh); overflow: auto; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18)));++ background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }++.ovt.is-start .ovc-pop { bottom: auto; top: calc(100% + 8px); }++.ovc-pop[hidden] { display: none; }++.ovc-pop-i { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 6px 10px; box-sizing: border-box; border: 0; border-radius: 11px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }++.ovc-pop-i[aria-selected="true"], .ovc-pop-i:hover { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }++.ovc-pop-ic, .ovc-pop-av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); object-fit: cover; }++.ovc-pop-av { border-radius: 50%; }++.ovc-pop-ic svg { width: 17px; height: 17px; }++.ovc-pop-t { min-width: 0; display: grid; line-height: 1.3; }++.ovc-pop-t b { font-weight: 650; font-size: 14px; color: var(--text-primary, #e6edf7); }++.ovc-pop-t span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #7386a3); }++.ovc-pop-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: 999px; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); }++.ovc-mic[aria-pressed="true"] { color: var(--live-red, #ef4444); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); animation: ovc-listen 1.4s ease-in-out infinite; }++.ovc-count { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }++.ovc-count.is-low { color: var(--live-red, #ef4444); }++.ovc-count[hidden] { display: none; }++.ovt-composer.ovc-compact { padding: 6px 8px 6px 12px; border-radius: 18px; }++.ovt-composer.ovc-compact .ovt-c-row { margin-top: 0; }++.ovt-composer.ovc-compact .ovt-btn, .ovt-composer.ovc-compact .ovt-send { width: 36px; min-width: 36px; height: 36px; }++@keyframes ovc-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }++@keyframes ovc-listen { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 18%, transparent); } }++html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }++.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }++.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }++.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }++.ovc-set-in .ovc-set-f { padding: 0; }++.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }++.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }++.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }++.ovc-set-g legend { padding: 0 0 6px; font-weight: 650; font-size: 14px; }++.ovc-set label { display: flex; align-items: center; gap: 10px; min-height: 40px; font-size: 14.5px; cursor: pointer; }++.ovc-set small, .ovc-set-p { color: var(--text-muted, #7386a3); font-size: 12.5px; margin: 0; }++.ovc-set input[type=radio], .ovc-set input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }++.ovc-set-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }++.ovc-set-l li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 4px 4px 4px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }++.ovc-set-l span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #b4c0d4); font-size: 13px; }++.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }++.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }++.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }++@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }`;++ function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }const mods = [];// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded// from beside this file; without it, a plain <dialog>.-- const COMPOSER_REV = { 'ov-window.js': 'c882d11d85ff' };++ const COMPOSER_REV = { 'ov-window.js': '625783527b4e' };const selfSrc = (document.currentScript && document.currentScript.src) || '';function needWindow() {return new Promise((ok) => {@@@ -102,6 +164,7 @@// ── One box.function enhance(form) {if (!form || form.__ovc) return form && form.__ovc;++ styles();const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');if (!text) return null;const row = form.querySelector('.ovt-c-row');@@@ -111,11 +174,14 @@const inputs = () => [...form.querySelectorAll('input[type="file"]')];const mine = () => mods.filter((m) => !m.match || m.match(form));const uploader = () => { const m = mine().find((x) => typeof x.upload === 'function'); return m ? m.upload : null; };-- const canFile = () => !!(inputs().length || uploader());++ // data-ovc-no-files: the box handles its own files (the Dock's Vibe box uploads a picture as it is picked).++ const noFiles = form.hasAttribute('data-ovc-no-files');++ const canFile = () => !noFiles && !!(inputs().length || uploader());const maxFiles = Number(form.getAttribute('data-ovc-max-files')) || 10;const maxBytes = (Number(form.getAttribute('data-ovc-max-mb')) || 25) * 1048576;const files = [];let quiet = '';++ const wasRequired = !!text.required; // a box that was not required stays so (the Dock's Vibe box)const say = (s) => { if (!note) return; if (!quiet) quiet = note.textContent; note.textContent = s; clearTimeout(say.t); say.t = setTimeout(() => { note.textContent = quiet; quiet = ''; }, 5000); };const fire = () => text.dispatchEvent(new Event('input', { bubbles: true }));@@@ -143,6 +209,7 @@return acc.split(',').map((x) => x.trim().toLowerCase()).some((a) => !a || (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));};function syncInputs() {++ if (noFiles) return;if (uploader()) { inputs().forEach((i) => { i.value = ''; }); return; }const [first, ...rest] = inputs();if (!first || typeof DataTransfer === 'undefined') return;@@@ -210,7 +277,7 @@else if (e.target.closest('[data-ovc-back]')) { const t = f.text; remove(f); ctx.insert(t); }});form.addEventListener('change', (e) => {-- const i = e.target; if (!i || i.type !== 'file' || i.__ovcSync) return;++ const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;const picked = [...(i.files || [])];i.__ovcSync = true; i.value = ''; i.__ovcSync = false;add(picked);@@@ -243,8 +310,8 @@function syncSend() {if (!send || form.classList.contains('is-busy')) return;send.disabled = busy() || (!text.value.trim() && !files.length);-- if (text.required && files.length) text.required = false;-- else if (!files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;++ if (wasRequired && text.required && files.length) text.required = false;++ else if (wasRequired && !files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;}text.addEventListener('input', syncSend);@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': 'bab301d99cb6', 'ov-combo.js': '3e426e56fc75' };++ const THREAD_REV = { 'ov-composer.js': 'f52e2323e6e9', 'ov-combo.js': '3e426e56fc75' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -14,7 +14,7 @@})(typeof window !== 'undefined' ? window : globalThis, function (root) {'use strict';const NETWORK = 'https://openvibe.network';-- const USER_REV = { 'ov-window.js': 'c882d11d85ff' };++ const USER_REV = { 'ov-window.js': '625783527b4e' };const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }@@@ -33,7 +33,7 @@// 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the// same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing.-- const WALLET_REV = { 'ov-window.js': 'c882d11d85ff' };++ const WALLET_REV = { 'ov-window.js': '625783527b4e' };const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';function needWindow() {return new Promise((ok) => {@@@ -15,6 +15,9 @@* label, onClose, bare, width }); bare: the content draws its own head (the title is still* read); width: px, for content that wants its own* w.el, w.body, w.foot, w.setTitle(text), w.close()++ * A panel can be resizable (4.204.0, the owner: a robot's controls "can be popped out … and resized"): { resizable: true,++ * minWidth, minHeight, onResize({ width, height }) } adds a grip at its corner that a mouse, a finger or a pen drags and++ * the arrow keys move (Shift for bigger steps, Enter for the first size); the size is kept with its place per id.* A site's content goes in as nodes (or text, set as text): the window never parses markup it is given.*/(function (root) {@@@ -52,6 +55,12 @@ html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blu.ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; }.ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); }.ovw.is-sheet.is-dragging { animation: none; }++.ovw.is-resizable { max-height: none; }++.ovw-grip { position: absolute; right: 2px; bottom: 2px; z-index: 2; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 8px; background: none; cursor: nwse-resize; touch-action: none; color: var(--text-secondary, #a8b3c4); }++.ovw-grip::before { content: ""; position: absolute; right: 6px; bottom: 6px; width: 10px; height: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; border-radius: 0 0 3px 0; opacity: .55; }++.ovw-grip:hover::before, .ovw-grip:focus-visible::before, .ovw.is-resizing .ovw-grip::before { opacity: 1; color: var(--accent, #3b82f6); }++.ovw-grip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }++.ovw.is-resizing { animation: none; transition: none; user-select: none; }.ovw { --ovw-w: 520px; }.ovw.is-sm { --ovw-w: 380px; }.ovw.is-lg { --ovw-w: 760px; }@@@ -177,7 +186,7 @@ html.ovw-locked { overflow: hidden; }const up = (ev) => {head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up);el.classList.remove('is-dragging');-- if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, { x: Math.round(b.left), y: Math.round(b.top) }); } return; }++ if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(b.left), y: Math.round(b.top) })); } return; }const dy = ev.clientY - sy;if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe');else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); }@@@ -186,11 +195,56 @@ html.ovw-locked { overflow: hidden; }});}++ if (kind === 'panel' && o.resizable) resizable(el, o);++const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0];(first || x).focus({ preventScroll: true });return w;}++ /** A panel's corner grip: pointer drag (mouse, finger, pen) and the arrow keys; the size is kept with its place. */++ function resizable(el, o) {++ el.classList.add('is-resizable');++ const minW = Math.max(200, Number(o.minWidth) || 260), minH = Math.max(120, Number(o.minHeight) || 160);++ const grip = document.createElement('button');++ grip.type = 'button'; grip.className = 'ovw-grip';++ grip.setAttribute('aria-label', 'Resize: drag, or use the arrow keys');++ el.append(grip);++ const first = { w: el.offsetWidth, h: el.offsetHeight };++ const set = (w, h, save) => {++ const r = el.getBoundingClientRect();++ const W = Math.round(Math.max(minW, Math.min(w, root.innerWidth - r.left - 8)));++ const H = Math.round(Math.max(minH, Math.min(h, root.innerHeight - r.top - 8)));++ el.style.width = `${W}px`; el.style.height = `${H}px`;++ if (save && o.id) place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(r.left), y: Math.round(r.top), w: W, h: H }));++ if (typeof o.onResize === 'function') { try { o.onResize({ width: W, height: H }); } catch { /* the page's own */ } }++ };++ const saved = o.id ? place.get(o.id) : null;++ if (saved && saved.w && saved.h) set(saved.w, saved.h, false);++ grip.addEventListener('pointerdown', (e) => {++ if (e.button !== undefined && e.button !== 0) return;++ e.preventDefault();++ const sx = e.clientX, sy = e.clientY, w0 = el.offsetWidth, h0 = el.offsetHeight;++ el.classList.add('is-resizing');++ try { grip.setPointerCapture(e.pointerId); } catch { /* */ }++ const move = (ev) => set(w0 + ev.clientX - sx, h0 + ev.clientY - sy, false);++ const up = () => {++ grip.removeEventListener('pointermove', move); grip.removeEventListener('pointerup', up); grip.removeEventListener('pointercancel', up);++ el.classList.remove('is-resizing');++ set(el.offsetWidth, el.offsetHeight, true);++ };++ grip.addEventListener('pointermove', move); grip.addEventListener('pointerup', up); grip.addEventListener('pointercancel', up);++ });++ grip.addEventListener('keydown', (e) => {++ const step = e.shiftKey ? 80 : 24;++ const d = { ArrowRight: [step, 0], ArrowLeft: [-step, 0], ArrowDown: [0, step], ArrowUp: [0, -step] }[e.key];++ if (e.key === 'Enter' || e.key === 'Home') { e.preventDefault(); set(first.w, first.h, true); return; }++ if (!d) return;++ e.preventDefault();++ set(el.offsetWidth + d[0], el.offsetHeight + d[1], true);++ });++ }++document.addEventListener('keydown', (e) => {if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return;e.preventDefault();@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.203.0",++ "version": "4.204.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.203.0",++ "version": "4.204.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.203.0",++ "version": "4.204.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -127,6 +127,7 @@"./player": "./player.js","./vibes-pay": "./vibes-pay.js","./trace": "./trace.js",++ "./csp": "./csp.js","./ui": "./ui.js","./url-resolver": "./url-resolver.js","./ov-user": "./ov-user.js",@@@ -3,7 +3,7 @@* video players across all our sites and vods … for a more unified look").** <link rel="stylesheet" href="/shared/player.css">-- * <video data-ov-player controls src="…" poster="…" preload="metadata" playsinline></video>++ * <video data-ov-player controls src="…" poster="…" preload="none" playsinline></video>* <script src="/shared/player.js" defer></script>** Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped@@@ -207,8 +207,131 @@} catch { /* an action this browser does not know */ }}++ // Startup policy is shared with pages that own their HLS attachment. Unknown connections use the fast policy.++ const nav = typeof navigator === 'undefined' ? {} : navigator;++ let battery = null;++ if (nav.getBattery) Promise.resolve().then(() => nav.getBattery()).then((b) => { battery = b; }).catch(() => {});++ function playbackPolicy(connection = nav.connection || {}, power = battery) {++ const slow = connection.saveData || /^(slow-)?2g$/.test(connection.effectiveType || '');++ const limited = slow || connection.effectiveType === '3g';++ const lowBattery = !!power && !power.charging && power.level <= 0.2;++ return {++ preload: slow ? 'none' : limited ? 'metadata' : 'auto',++ warm: !limited && !lowBattery,++ hls: { startLevel: 0, maxBufferLength: lowBattery ? 15 : limited ? 30 : 60,++ maxMaxBufferLength: lowBattery ? 30 : limited ? 60 : 120,++ backBufferLength: lowBattery || slow ? 0 : limited ? 15 : 30 },++ };++ }++ /** Attach using the page's Hls constructor; native HLS keeps the same connection-aware preload. */++ function attachHls(video, Hls, url, options = {}) {++ const policy = playbackPolicy();++ video.preload = policy.preload;++ if (!Hls || !Hls.isSupported()) {++ if (video.canPlayType('application/vnd.apple.mpegurl')) video.src = url;++ return null;++ }++ const hls = new Hls({ ...options, ...policy.hls, autoStartLoad: policy.preload === 'auto' });++ let loading = policy.preload === 'auto';++ const start = () => { if (!loading) { loading = true; hls.startLoad(); } };++ const update = () => {++ video.preload = playbackPolicy().preload;++ Object.assign(hls.config, playbackPolicy().hls);++ if (video.paused && video.preload !== 'auto' && loading) { hls.stopLoad(); loading = false; }++ };++ video.addEventListener('play', start);++ if (nav.connection && nav.connection.addEventListener) nav.connection.addEventListener('change', update);++ // getBattery is optional and async; register only while this attachment is alive.++ let disposed = false, power = null;++ if (nav.getBattery) Promise.resolve().then(() => nav.getBattery()).then((b) => {++ if (disposed) return;++ power = battery = b; update();++ b.addEventListener('levelchange', update); b.addEventListener('chargingchange', update);++ }).catch(() => {});++ hls.on(Hls.Events.DESTROYING, () => {++ disposed = true; video.removeEventListener('play', start);++ if (nav.connection && nav.connection.removeEventListener) nav.connection.removeEventListener('change', update);++ if (power) { power.removeEventListener('levelchange', update); power.removeEventListener('chargingchange', update); }++ });++ hls.loadSource(url); hls.attachMedia(video);++ if (!video.paused) start();++ return hls;++ }++ // Warm only explicit public HLS card sources, at most 20 per minute and 2 MiB per card. No whole-file prefetch.++ const warmed = new Map();++ async function warmHls(url, signal) {++ if (!playbackPolicy().warm || typeof fetch !== 'function') return false;++ const now = Date.now();++ for (const [key, at] of warmed) if (now - at >= 60000) warmed.delete(key);++ const safe = (value, base) => {++ const u = new URL(value, base || undefined);++ if (!/^https?:$/.test(u.protocol) || u.username || u.password || /(?:sig|token|key|exp|signature)/i.test(u.search)) throw new Error('Only public playlists are warmed');++ return u.href;++ };++ let source;++ try { source = safe(url, document.baseURI); } catch { return false; }++ if (warmed.has(source) || warmed.size >= 20) return false;++ warmed.set(source, now);++ let remaining = 2 * 1024 * 1024;++ const read = async (u, manifest = false) => {++ const response = await fetch(u, { signal, credentials: 'same-origin', cache: 'force-cache' });++ if (!response.ok || !response.body) throw new Error('Not cacheable');++ const reader = response.body.getReader(), chunks = [];++ let size = 0;++ try {++ for (;;) {++ const { done, value } = await reader.read(); if (done) break;++ size += value.byteLength; remaining -= value.byteLength;++ if (remaining < 0 || (manifest && size > 65536)) throw new Error('Warm budget');++ if (manifest) chunks.push(value);++ }++ } finally { await reader.cancel().catch(() => {}); }++ if (!manifest) return '';++ const bytes = new Uint8Array(size); let offset = 0;++ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }++ return new TextDecoder().decode(bytes);++ };++ try {++ let body = await read(source, true);++ if (!body.startsWith('#EXTM3U') || /#EXT-X-(?:KEY|SESSION-KEY):|BYTERANGE[=:]/.test(body)) return false;++ if (body.includes('#EXT-X-STREAM-INF:')) {++ const lines = body.split(/\r?\n/), variants = [];++ lines.forEach((line, i) => {++ if (line.startsWith('#EXT-X-STREAM-INF:') && lines[i + 1] && !lines[i + 1].startsWith('#'))++ variants.push({ bandwidth: Number((line.match(/(?:^|,)BANDWIDTH=(\d+)/) || line.match(/:BANDWIDTH=(\d+)/) || [])[1]) || Infinity, uri: lines[i + 1] });++ });++ variants.sort((a, b) => a.bandwidth - b.bandwidth);++ if (!variants.length) return false;++ source = safe(variants[0].uri, source); body = await read(source, true);++ }++ // Live playlists, encrypted media and byte-range playlists need their player's own loader.++ if (!body.includes('#EXT-X-ENDLIST') || /#EXT-X-(?:KEY|SESSION-KEY):|BYTERANGE[=:]/.test(body)) return false;++ const init = body.match(/#EXT-X-MAP:URI="([^"]+)"/);++ const segment = body.split(/\r?\n/).find((line) => line && !line.startsWith('#'));++ if (init) await read(safe(init[1], source));++ if (segment) await read(safe(segment, source));++ return !!segment;++ } catch { return false; }++ }++ function warmCards(scope = document) {++ scope.querySelectorAll('[data-ov-player-hls]').forEach((card) => {++ if (card.__ovWarm) return; card.__ovWarm = true;++ let controller = null, timer = null;++ const cancel = () => { clearTimeout(timer); if (controller) controller.abort(); };++ const begin = () => {++ cancel();++ if (!playbackPolicy().warm || typeof AbortController !== 'function') return;++ controller = new AbortController();++ timer = setTimeout(() => warmHls(card.getAttribute('data-ov-player-hls'), controller.signal), 120);++ };++ card.addEventListener('pointerenter', begin); card.addEventListener('focusin', begin);++ card.addEventListener('pointerleave', cancel); card.addEventListener('focusout', cancel);++ });++ }++function enhance(video, options = {}) {if (!video || video.__ovPlayer) return video && video.__ovPlayer;++ video.preload = playbackPolicy().preload;const idleMs = options.idleMs || 2500;const root = el('div', 'ov-player is-paused is-start', { tabindex: '0', role: 'region', 'aria-label': options.label || video.getAttribute('aria-label') || 'Video player' });video.parentNode.insertBefore(root, video);@@@ -236,7 +359,14 @@root.insertBefore(posterLayer, video);root.insertBefore(audioLayer, video);const shown = () => root.classList.add('has-frame');-- if (video.readyState >= 2) shown(); else video.addEventListener('loadeddata', shown, { once: true });++ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(shown);++ else if (video.readyState >= 2) shown();++ else video.addEventListener('loadeddata', shown, { once: true });++ video.addEventListener('emptied', () => {++ root.classList.remove('has-frame');++ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(shown);++ else video.addEventListener('loadeddata', shown, { once: true });++ });// ── Build ────────────────────────────────────────────const big = button('ov-player-big', 'Play', ICON.play);@@@ -605,9 +735,10 @@function enhanceAll(scope) {(scope || document).querySelectorAll('video[data-ov-player]').forEach((v) => enhance(v));++ warmCards(scope || document);}-- window.OpenVibePlayer = { enhance, enhanceAll, format: fmt };++ window.OpenVibePlayer = { enhance, enhanceAll, format: fmt, playbackPolicy, attachHls, warmHls, warmCards };if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhanceAll());else enhanceAll();})();@@@ -27,7 +27,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },-- 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };++ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;function writeOwn() {for (const [file, { name, files }] of Object.entries(OWN)) {@@@ -5,6 +5,7 @@// closes it, a changed picker shows as a chip whose × puts the default back, the placeholder walks through the examples,// and a kit sidebar's button moves into the navbar on a phone, where the empty head row goes away. Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -51,8 +52,8 @@ const server = http.createServer((req, res) => {await p.goto(`${base}/`, { maxMs: 6000 });await until(p, "!!document.querySelector('[data-ovt-opts]')", 'the Options button');// Only the button and Send in the bar; the pickers are behind it.-- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-panel').checkVisibility()"), false, 'the pickers start closed');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), true, 'no dot while everything is the default');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-panel').checkVisibility()"), false, 'the pickers start closed'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), true, 'no dot while everything is the default'));// Open: a sheet at the bottom of a phone, the page behind does not scroll.await p.evaluate("document.querySelector('.ovt-opts > summary').click()");await until(p, "document.querySelector('[data-ovt-opts]').open", 'it opens');@@@ -63,16 +64,16 @@ const server = http.createServer((req, res) => {assert.ok(sheet.locked, 'the page behind stays put');// Change a picker: a chip shows beside the button, and the dot.await p.evaluate("(() => { const s = document.querySelector('select[name=who]'); s.value = 'codex'; s.dispatchEvent(new Event('change', { bubbles: true })); })()");-- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovt-opt-chip > span')].map((s) => s.textContent).join()"), 'Codex', 'the changed one is a chip');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), false, 'and the button wears a dot');++ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovt-opt-chip > span')].map((s) => s.textContent).join()"), 'Codex', 'the changed one is a chip'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), false, 'and the button wears a dot'));// A tap outside closes it and unlocks the page.await p.evaluate("(() => { const e = new PointerEvent('pointerdown', { bubbles: true }); document.querySelector('h1').dispatchEvent(e); })()");await until(p, "!document.querySelector('[data-ovt-opts]').open", 'a tap outside closes it');-- assert.strictEqual(await p.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page scrolls again');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page scrolls again'));// The chip's × puts the default back.await p.evaluate("document.querySelector('.ovt-opt-chip-x').click()");-- assert.strictEqual(await p.evaluate("document.querySelector('select[name=who]').value"), '', 'the default is back');-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovt-opt-chip').length"), 0, 'and the chip is gone');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('select[name=who]').value"), '', 'the default is back'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovt-opt-chip').length"), 0, 'and the chip is gone'));// The examples walk through the placeholder while the box is empty.await until(p, "document.querySelector('[data-ovt-text]').placeholder !== 'What should change?'", 'the placeholder moves to an example', 6000);// The sidebar button: in the navbar before the brand, and the head row with nothing else in it is gone.@@@ -83,23 +84,23 @@ const server = http.createServer((req, res) => {assert.strictEqual(lead.head, 'none', 'the empty head row goes');await p.evaluate("document.getElementById('ovnav-lead').click()");await until(p, "document.getElementById('ch-rail').classList.contains('is-open')", 'the slot opens the drawer');-- assert.strictEqual(await p.evaluate("document.getElementById('ovnav-lead').getAttribute('aria-expanded')"), 'true', 'and says it is open');-- assert.ok(await p.evaluate('document.documentElement.scrollWidth <= 390'), 'no sideways scroll');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovnav-lead').getAttribute('aria-expanded')"), 'true', 'and says it is open'));++ await eventually(async () => assert.ok(await p.evaluate('document.documentElement.scrollWidth <= 390'), 'no sideways scroll'));// A site's own action on the right, by the API.await p.evaluate("OpenVibeNavbar.action({ id: 'help', icon: 'info', label: 'Help', badge: 2, onClick: () => { window.__help = (window.__help || 0) + 1; } })");await p.evaluate("document.getElementById('ovnav-act-help').click()");-- assert.strictEqual(await p.evaluate('window.__help'), 1, 'an action button calls its handler');-- assert.strictEqual(await p.evaluate("document.querySelector('#ovnav-act-help b').textContent"), '2', 'and shows its count');++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__help'), 1, 'an action button calls its handler'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#ovnav-act-help b').textContent"), '2', 'and shows its count'));// On a computer: the panel opens beside the button, not as a sheet, and the navbar slot is not shown.const d = await h.openPage(browser, context, { width: 1280, height: 900, block: ['*openvibe.network*', '*openvibe.tools*'] });await d.goto(`${base}/`, { maxMs: 6000 });await until(d, "!!document.querySelector('[data-ovt-opts]')", 'the Options button on a computer');await d.evaluate("document.querySelector('.ovt-opts > summary').click()");await wait(250);-- assert.strictEqual(await d.evaluate("getComputedStyle(document.querySelector('.ovt-opts-panel')).position"), 'absolute', 'a panel on a computer');-- assert.strictEqual(await d.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page is not locked for a panel');++ await eventually(async () => assert.strictEqual(await d.evaluate("getComputedStyle(document.querySelector('.ovt-opts-panel')).position"), 'absolute', 'a panel on a computer'));++ await eventually(async () => assert.strictEqual(await d.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page is not locked for a panel'));await until(d, "!!document.getElementById('ovnav-lead')", 'the slot exists');-- assert.strictEqual(await d.evaluate("getComputedStyle(document.getElementById('ovnav-lead')).display"), 'none', 'but hides where the sidebar is always shown');++ await eventually(async () => assert.strictEqual(await d.evaluate("getComputedStyle(document.getElementById('ovnav-lead')).display"), 'none', 'but hides where the sidebar is always shown'));console.log('composer-options-chrome: one Options button, a sheet on a phone, chips for changes, examples, the sidebar button in the navbar, actions by API');} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++// openvibe-shared/csp: connect-src names Media (the composer's upload origin) and Network by default, a site can add++// its own origins, and policy() is a complete CSP string.++const assert = require('assert');++const csp = require('../csp');++++const src = csp.connectSources();++assert.ok(src.startsWith("'self'"));++for (const origin of ['https://openvibe.network', 'wss://openvibe.network', 'https://openvibe.events', 'wss://openvibe.events', 'https://openvibe.media']) {++ assert.ok(src.split(' ').includes(origin), `connect-src names ${origin}`);++}++assert.deepStrictEqual(csp.connectSources({ self: false, sites: ['wss://openvibe.chat'], extra: ['https://cloudflareinsights.com'] }).split(' '), [++ 'https://openvibe.network', 'wss://openvibe.network', 'https://openvibe.events', 'wss://openvibe.events', 'https://openvibe.media', 'wss://openvibe.chat', 'https://cloudflareinsights.com',++]);++assert.strictEqual(csp.MEDIA_UPLOAD_ORIGIN, 'https://openvibe.media');++++const policy = csp.policy({ nonce: 'abc123', sites: ['wss://openvibe.chat'], frames: ["'self'", 'https://openvibe.bot'] });++for (const part of ["default-src 'self'", "script-src 'self' 'nonce-abc123'", "style-src 'self' 'unsafe-inline'", "img-src https:", "media-src https:", "font-src 'self' data:", "frame-src 'self' https://openvibe.bot", "frame-ancestors 'self'", "base-uri 'self'", "object-src 'none'", "form-action 'self' https://openvibe.network"]) {++ assert.ok(policy.includes(part), `policy has ${part}`);++}++assert.ok(policy.includes('https://openvibe.media') && policy.includes('wss://openvibe.chat'), 'policy connect-src has Media and the site');++assert.ok(csp.policy({ extra: { 'worker-src': "'self' blob:" } }).includes("worker-src 'self' blob:"));++console.log('csp: all checks passed');@@@ -20,6 +20,7 @@// Dock went, and closing the window brings the panel back; the Network is told.// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -319,7 +320,7 @@ async function drag(p, from, to) {const p = await fresh(390);await p.goto(`${base}/page`, { maxMs: 5000 });await wait(700);-- assert.strictEqual(await p.evaluate(PILL), null, 'no pill while nothing runs or waits (unread alone is the bell\'s)');++ await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill while nothing runs or waits (unread alone is the bell\'s)'));scenario.state = { ...defaultState(), pinned: true };await p.goto(`${base}/page`, { maxMs: 5000 });const pin = await until(p, PILL, 'the pinned pill');@@@ -343,7 +344,7 @@ async function drag(p, from, to) {await new Promise((ok) => setTimeout(ok, 450)); // the sheet has finished risingconst placed = await p.evaluate(CARD);assert.ok(placed.w === 390 && placed.t === 0 && placed.h === 780, `the whole phone, over the bar (owner, 11-09: "all the way to the top"; ${placed.t}, ${placed.h})`);-- assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open'));await p.evaluate("document.querySelector('.ovdk .ovac-item[data-bucket=waiting] button[data-do=approve]').click()");await until(p, "document.querySelector('.ovdk .ovac-item[data-bucket=waiting] .primary').textContent === 'Approved'", 'approved in place');assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'the approval goes to the Network for that item');@@@ -351,7 +352,7 @@ async function drag(p, from, to) {await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'activity' && c.notes === 2; })()`, 'the Activity tab');await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').focus(); document.querySelector('.ovdk').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))");await until(p, `!(${CARD})`, 'Escape closes');-- assert.strictEqual(await p.evaluate('document.activeElement && document.activeElement.id'), 'ovdk-pill', 'the focus goes back to the pill');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.activeElement && document.activeElement.id'), 'ovdk-pill', 'the focus goes back to the pill'));await wait(900);const all = Object.assign({}, ...puts);assert.deepStrictEqual([all.open, all.tab], [false, 'activity'], `the Network heard where the Dock was left (${JSON.stringify(puts)})`);@@@ -361,7 +362,7 @@ async function drag(p, from, to) {await p.goto(`${base}/page`, { maxMs: 5000 });await until(p, PILL, 'the pill again');await wait(400);-- assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked');++ await eventually(async () => assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked'));await p.close();}@@@ -384,12 +385,12 @@ async function drag(p, from, to) {const cards = await until(p, "(() => { const c = [...document.querySelectorAll('.ovdk .ovdk-msg .ovdk-card')]; return c.length === 2 ? c.map((x) => { const t = x.querySelector('.ovdk-card-t'), g = x.querySelector('.ovdk-card-go'), i = x.querySelector('img'); return [t.tagName, t.textContent, t.getAttribute('href'), i ? i.getAttribute('src') : null, g ? g.textContent + ' ' + g.getAttribute('href') : null]; }) : null; })()", 'the link cards');assert.deepStrictEqual(cards[0], ['A', 'The studio tour', 'https://openvibe.video/v/42', 'https://openvibe.media/t/abc?w=320', 'Watch together https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42'], 'a card: its picture, its title as a link, its one move');assert.deepStrictEqual(cards[1], ['B', '<img src=x>', null, null, null], 'a card is data: no script link, no picture from elsewhere, no script button, the title as text');-- assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows'));await until(p, "!!document.querySelector('.ovdk .ovdk-edit')", 'the edit action');await p.evaluate("(() => { window.prompt = () => 'updated by me'; document.querySelector('.ovdk .ovdk-edit').click(); })()");await until(p, "document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text').textContent === 'updated by me'", 'the edited message');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-edited').textContent.includes('edited')"), true);-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-edited').textContent.includes('edited')"), true));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet'));await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');@@@ -412,12 +413,12 @@ async function drag(p, from, to) {const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);await share('Friday film');await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the Send to… bar');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), 'Friday film', 'what is being sent, by its title');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), 'Friday film', 'what is being sent, by its title'));await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'your conversations under it');await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");await until(p, "document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value.length > 0", 'the message in the box');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-send textarea').value"), TEXT, 'the link waits in the box');-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk-ready')"), true, 'and says it is ready to send');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-send textarea').value"), TEXT, 'the link waits in the box'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk-ready')"), true, 'and says it is ready to send'));await wait(300);assert.deepStrictEqual(said, [], 'nothing is sent until the person sends it');// Someone new, by their name: a name nobody has says so; the right one starts the conversation.@@@ -425,7 +426,7 @@ async function drag(p, from, to) {await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = '@zz'; document.querySelector('.ovdk-to').requestSubmit(); })()");await until(p, "!document.querySelector('.ovdk-share .ovdk-err').hidden", 'a name nobody has');-- assert.match(await p.evaluate("document.querySelector('.ovdk-share .ovdk-err').textContent"), /Nobody is called @zz\. Did you mean @mia\?/);++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovdk-share .ovdk-err').textContent"), /Nobody is called @zz\. Did you mean @mia\?/));await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = 'mia'; document.querySelector('.ovdk-to').requestSubmit(); })()");await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');@@@ -436,7 +437,7 @@ async function drag(p, from, to) {await p.evaluate("document.getElementById('pagesend').click()");await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar, opened by a tap on the page');await wait(300);-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet'));await p.evaluate("document.body.click()");// A phone's sheet folds back into the navbar's orb before it goes (4.185.0: 240 ms), so it is gone a moment// after the tap, not in the same instant.@@@ -473,15 +474,15 @@ async function drag(p, from, to) {// The stream: Live's embed, the task, the numbers.const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min');-- assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['pause', 'curtain', 'mute', 'end', 'float'], 'the owner\'s four controls (S5), and Float while the screen plays (4.154.0)');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min'));++ await eventually(async () => assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['pause', 'curtain', 'mute', 'end', 'float'], 'the owner\'s four controls (S5), and Float while the screen plays (4.154.0)'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list'));// Curtain now: Live is asked, and the line says so.await p.evaluate("document.querySelector('.ovda-acts button[data-act=curtain]').click()");await until(p, "document.querySelector('.ovda-meta').textContent.includes('Private screen')", 'the curtain in the line');assert.deepStrictEqual(controls, ['curtain'], 'Curtain now reached Codes, which puts it to Live');-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda-acts button[data-act=curtain]')"), false, 'no Curtain now while the curtain is up');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda-acts button[data-act=curtain]')"), false, 'no Curtain now while the curtain is up'));// S5: Pause and Mute reach Codes, which puts them to Live; the line says whose pause it is.await p.evaluate("document.querySelector('.ovda-acts button[data-act=pause]').click()");await until(p, "document.querySelector('.ovda-acts button[data-act=pause]').textContent.includes('Resume')", 'the pause in the button');@@@ -492,9 +493,9 @@ async function drag(p, from, to) {const stillSrc = await until(p, "document.querySelector('.ovda img') && document.querySelector('.ovda img').getAttribute('src')", 'the still after End');assert.ok(stillSrc.startsWith('blob:'), 'the still is read with the token, never a bare <img> URL');assert.deepStrictEqual(controls, ['curtain', 'pause', 'mute', 'end'], 'Pause, Mute and End reached Codes, which puts them to Live');-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min');-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream'));// No stream at all: the view hides itself and the tab is just the list.agentView = null;await p.goto(`${base}/page`, { maxMs: 8000 });@@@ -502,8 +503,8 @@ async function drag(p, from, to) {await p.evaluate("document.getElementById('ovdk-pill').click()");await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab again');await wait(400);-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it');-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone'));await p.close();}@@@ -516,7 +517,7 @@ async function drag(p, from, to) {assert.strictEqual(pill.text, 'Working');await p.evaluate("document.getElementById('ovdk-pill').click()");await until(p, CARD, 'the sheet at 320');-- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll'));await p.close();}@@@ -566,8 +567,8 @@ async function drag(p, from, to) {await p.evaluate("document.getElementById('ovdk-pill').click()");await until(p, CARD, 'the card');const POP = "document.querySelector('.ovdk [data-act=pop]')";-- assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button');-- assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own');++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own'));// The browser can: the button appears.await p.evaluate(PIP);await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");@@@ -577,10 +578,10 @@ async function drag(p, from, to) {// The panel moves into the window: the same DOM, the same tabs, the styles come with it.await p.evaluate(`${POP}.click()`);await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more');-- assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 5, 'the same five tabs (Vibe, Code, Agents, Chat, Activity)');-- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it");-- assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more'));++ await eventually(async () => assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 5, 'the same five tabs (Vibe, Code, Agents, Chat, Activity)'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it"));++ await eventually(async () => assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520'));const inWindow = await until(p, PILL, 'the pill stays');assert.strictEqual(inWindow.text, 'In a window', 'the page keeps the pill, and it says where the Dock went');// The state write is debounced; a slow runner (macOS CI) needs more than a fixed wait.@@@ -590,7 +591,7 @@ async function drag(p, from, to) {// The window is closed: the panel comes back to the page.await p.evaluate("window.__pip.close()");await until(p, "!!document.querySelector('.ovdk')", 'the panel back on the page');-- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window'));await wait(900);assert.ok(!puts.some((b) => 'popped' in b), `nor that it is back (${JSON.stringify(puts)})`);await p.close();@@@ -619,12 +620,12 @@ async function drag(p, from, to) {await p.evaluate("window.__pip.win.document.querySelector('#ovdk-tab-agent').click()");const src = await until(p, "(() => { const w = window.__pip.win; const f = w.document.querySelector('.ovda iframe'); return f && f.getAttribute('src'); })()", 'the stream in the window');assert.strictEqual(src, agentView.embed_url, 'the same ticket and stream, in the window');-- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovda-styles')"), true, 'the agent view keeps its styles in the window');-- assert.strictEqual(await p.evaluate("!!document.getElementById('ovda-styles')"), false, 'and does not put them on the page behind it');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovda-styles')"), true, 'the agent view keeps its styles in the window'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.getElementById('ovda-styles')"), false, 'and does not put them on the page behind it'));// Back on the page, the same view is still there.await p.evaluate("window.__pip.close()");await until(p, "!!document.querySelector('.ovdk .ovda iframe')", 'the stream back on the page');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda iframe').getAttribute('src')"), agentView.embed_url);++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda iframe').getAttribute('src')"), agentView.embed_url));await p.close();}@@@ -639,7 +640,7 @@ async function drag(p, from, to) {await until(p, PILL, 'the pinned pill');await p.evaluate("document.getElementById('ovdk-pill').click()");await until(p, CARD, 'the card');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab'));await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");@@@ -660,10 +661,10 @@ async function drag(p, from, to) {await p.evaluate("document.querySelector('.ovdk .ovdk-res-c button').click()");await until(p, "!!document.querySelector('.ovdk .ovdk-res-c.is-stopped')", 'the chip says it is stopped');assert.deepStrictEqual(undone[0], { message_id: vibeMsgs.find((m) => m.from === 'vibe').id, index: 0 }, 'undo names the answer and the result');-- assert.match(await p.evaluate("document.querySelector('.ovdk .ovdk-res-c').innerText"), /Reminder stopped: Stretch/);-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again');-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat');++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovdk .ovdk-res-c').innerText"), /Reminder stopped: Stretch/));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat'));await wait(900);assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`);await p.close();@@@ -691,15 +692,15 @@ async function drag(p, from, to) {await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic'));await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");await until(p, "window.__recording === true && document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed') === 'true'", 'recording, and the mic says so');await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");await until(p, "[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')].some((m) => /drink water at 3/.test(m.innerText))", 'what was said, asked');assert.deepStrictEqual(listened[0], { type: 'audio/ogg', bytes: 4096, lang: 'en', auth: 'Bearer tok_user' }, 'the recording goes to Chat as itself, with the person\'s token');assert.strictEqual(asked[0].message, 'remind me to drink water at 3');-- assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false');++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false'));await p.close();}@@@ -711,11 +712,11 @@ async function drag(p, from, to) {await p.goto(`${base}/page`, { maxMs: 5000 });await until(p, "!!document.querySelector('.openvibe-navbar')", 'the navbar');await wait(600);-- assert.strictEqual(await p.evaluate("!!document.getElementById('ov-glyphs-loader')"), false, 'no icon on the page: no glyphs loaded');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.getElementById('ov-glyphs-loader')"), false, 'no icon on the page: no glyphs loaded'));await p.evaluate("(() => { const i = document.createElement('i'); i.className = 'fa-solid fa-gear'; i.id = 'late-icon'; document.querySelector('main').prepend(i); })()");await until(p, "!!document.getElementById('ov-glyphs-loader') && !!window.OVGlyphs", 'the glyphs load once an icon appears');await until(p, "(() => { const e = document.getElementById('late-icon'); return !e || !!e.querySelector('svg') || e.tagName.toLowerCase() === 'svg'; })()", 'and the late icon is drawn');-- assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"ov-glyphs.js\"]').length"), 1, 'once');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"ov-glyphs.js\"]').length"), 1, 'once'));await p.close();}@@@ -769,14 +770,14 @@ async function drag(p, from, to) {const p = await fresh(1280);await p.goto(`${base}/page`, { maxMs: 5000 });await wait(700);-- assert.strictEqual(await p.evaluate(PILL), null, 'no pill without the route');++ await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill without the route'));const off = await p.evaluate("JSON.parse(localStorage.getItem('ov-dock') || 'null')");assert.ok(off && off.off > Date.now() + 23 * 3600e3, 'and it waits a day before asking again');await p.evaluate("localStorage.removeItem('ov-dock')");scenario = { counts: { running: 1, waiting: 0, unread: 0 } };await p.goto(`${base}/page?in=0`, { maxMs: 5000 });await wait(600);-- assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all'));await p.close();}// ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──@@@ -790,7 +791,7 @@ async function drag(p, from, to) {await until(p, CARD, 'the Dock opens');const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab'));await wait(400);assert.strictEqual(asked.length, before, 'nothing is sent for the person');await p.close();@@@ -5,6 +5,7 @@// task with the person's token and the list reads again; Stop stops it; signed out, it says to sign in; when Codes cannot// be reached, the box opens openvibe.codes with what was written in it.const assert = require('node:assert/strict');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('node:fs');const os = require('node:os');const path = require('node:path');@@@ -87,7 +88,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (b = await page.evaluate(`({ text: document.querySelector('.ovdc-text').value, note: document.querySelector('.ovdc-note').textContent, focused: document.activeElement === document.querySelector('.ovdc-text'), go: document.querySelector('.ovdc-go').disabled })`);assert.deepEqual(b, { text: 'Build a goal bar for my sub goal', note: 'From your chat with Vibe. Start it, or change it first.', focused: true, go: false }, 'handed-over words are in the box, ready to start (My code keeps its repository)');await page.evaluate(`document.querySelector('[data-mode="create"]').click()`);-- assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false);++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false));await page.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = ''; t.dispatchEvent(new Event('input')); })()`);// Stop.@@@ -101,7 +102,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (const out = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });await out.goto(base + '/?out=1', { maxMs: 5000 });await wait(300);-- assert.match(await out.evaluate(`document.querySelector('.ovdc-list').textContent`), /Sign in to start coding tasks/);++ await eventually(async () => assert.match(await out.evaluate(`document.querySelector('.ovdc-list').textContent`), /Sign in to start coding tasks/));assert.equal(seen.length, before, 'nothing is asked without a token');await out.close();@@@ -110,7 +111,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (const down = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });await down.goto(base + '/', { maxMs: 5000 });await wait(300);-- assert.match(await down.evaluate(`document.querySelector('.ovdc-list').textContent`), /Your tasks are on openvibe\.codes/);++ await eventually(async () => assert.match(await down.evaluate(`document.querySelector('.ovdc-list').textContent`), /Your tasks are on openvibe\.codes/));await down.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = 'A goal bar'; t.dispatchEvent(new Event('input')); document.querySelector('.ovdc-go').click(); })()`);const went = await (async () => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await down.evaluate('location.pathname + location.search').catch(() => ''); if (v.startsWith('/codes')) return v; await wait(50); } return ''; })();assert.equal(went, '/codes/?prompt=A+goal+bar&mode=create', 'it opens Codes with what you wrote');nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++// A browser test checks the page after it is ready, not after a guessed sleep (2026-11-11, the owner: "Can we fix the++// Mac OS bug once and for all?"). The macOS runners are slow: menu-places-chrome slept 180 ms for a 140 ms debounce and++// failed now and then, holding back releases 4.202.0 and 4.203.0. A check after `await wait(n)` now goes through++// browser-harness's eventually() (or the test's own until()), which retries until the page is there.++//++// This is a ratchet: test/fixtures/fixed-waits.json counts the sleep-then-assert places each file still has. A file may++// lose them, never gain them, and a new file starts at none. Lower the number in the fixture when you fix one.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const DIR = __dirname;++const baseline = JSON.parse(fs.readFileSync(path.join(DIR, 'fixtures', 'fixed-waits.json'), 'utf8'));++++/** The sleep-then-assert places in one file: an `await wait(<number>)`, then within two lines a bare assert. */++function count(text) {++ const lines = text.split('\n');++ let n = 0;++ for (let i = 0; i < lines.length; i++) {++ if (!/await\s+wait\(\s*\d+\s*\)/.test(lines[i])) continue;++ const same = lines[i].slice(lines[i].search(/await\s+wait\(/));++ const next = [same.replace(/^await\s+wait\(\s*\d+\s*\)\s*;?/, ''), lines[i + 1] || '', lines[i + 2] || ''];++ // Only a check that reads the page now (an evaluate) races the sleep; one of a value measured before does not.++ if (next.some((l) => /^\s*assert[.(]/.test(l) && /evaluate\(|inside\(\)/.test(l) && !/eventually\(|until\(/.test(l))) n++;++ }++ return n;++}++++const now = {};++for (const f of fs.readdirSync(DIR).filter((x) => x.endsWith('.test.js')).sort()) {++ const n = count(fs.readFileSync(path.join(DIR, f), 'utf8'));++ if (n) now[f] = n;++}++const grew = Object.entries(now).filter(([f, n]) => n > (baseline[f] || 0)).map(([f, n]) => `${f}: ${n} (was ${baseline[f] || 0})`);++assert.deepStrictEqual(grew, [], 'a check right after a fixed sleep: use eventually() from browser-harness (or until()) instead');++const better = Object.entries(baseline).filter(([f, n]) => (now[f] || 0) < n).map(([f, n]) => `${f}: ${now[f] || 0} (fixture says ${n})`);++assert.deepStrictEqual(better, [], 'fewer than the fixture says: lower test/fixtures/fixed-waits.json so it cannot grow back');++assert.strictEqual(count(fs.readFileSync(path.join(DIR, 'menu-places-chrome.test.js'), 'utf8')), 0, 'the test that held back 4.202 and 4.203 has none');++console.log(`fixed-waits: ${Object.values(now).reduce((a, b) => a + b, 0)} sleep-then-assert places left in ${Object.keys(now).length} files, none new`);nnew file mode 100644@@@ -0,0 +1 @@++{}@@@ -3,6 +3,7 @@// and more optimized"). Live's shell carries hidden modal forms, so every Live page fetched ov-forms.js and .css// (17 KB) for selects nobody saw. A real headless Chrome; skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -45,12 +46,12 @@ const server = http.createServer((req, res) => {const p = await h.openPage(browser, context, { width: 390 });await p.goto(`${base}/hidden`, { maxMs: 5000 });await wait(3000);-- assert.strictEqual(await p.evaluate(asked), false, 'a hidden dialog\'s select does not fetch the forms kit');-- assert.strictEqual(await p.evaluate('!!window.OVForms'), false);++ await eventually(async () => assert.strictEqual(await p.evaluate(asked), false, 'a hidden dialog\'s select does not fetch the forms kit'));++ await eventually(async () => assert.strictEqual(await p.evaluate('!!window.OVForms'), false));await p.evaluate("(document.getElementById('modal').hidden = false, 1)");assert.ok(await until(p, '!!window.OVForms'), 'the kit loads when the dialog opens');-- assert.strictEqual(await p.evaluate(asked), true);++ await eventually(async () => assert.strictEqual(await p.evaluate(asked), true));const q = await h.openPage(browser, context, { width: 390 });await q.goto(`${base}/shown`, { maxMs: 5000 });nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++// 4.204.0, buttons that show what they do and what is happening (docs/kit.md#buttons-42040): the builder's options,++// the sheet's rules (every motion scaled by --ov-move), and busy() on a fake button.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const kit = require('../kit.js');++++const hero = kit.button({ label: 'Go live', icon: 'live', kind: 'hero', nudge: 'forward', iconEnd: 'chevron', attention: true, badge: 120 });++assert.match(hero, /^<button class="ovk-btn ovk-btn--hero" type="submit" data-nudge="forward" data-attention="" data-badge="99\+">/);++assert.ok((hero.match(/<svg/g) || []).length === 2, 'an icon before and one after the words');++assert.match(kit.button({ label: 'Delete', kind: 'danger', hold: 99999, type: 'button' }), /data-hold="4000"/, 'a hold is 4 s at most');++assert.match(kit.button({ label: 'Delete', kind: 'danger', hold: 10, type: 'button' }), /data-hold="400"/, 'and 0.4 s at least');++assert.match(kit.button({ label: 'Following', kind: 'soft', pressed: true, dot: 'live', type: 'button' }), /data-dot="live" aria-pressed="true"/);++assert.ok(!/data-badge/.test(kit.button({ label: 'x', badge: 0 })), 'no badge for nothing');++assert.match(kit.button({ label: '<b>', attrs: { 'data-x': '1' } }), /data-x="1"><span><b><\/span>/, 'the words are text and other attributes stay');++++const css = fs.readFileSync(path.join(__dirname, '..', 'kit.css'), 'utf8');++for (const k of ['hero', 'soft', 'live']) assert.ok(css.includes(`.ovk-btn--${k} {`), k);++for (const sel of ['[data-attention]', '[data-badge]', '[data-dot="ok"]', '[data-state="busy"]', '[data-state="done"]', '[data-state="error"]', '[data-hold]', '--ovk-progress']) assert.ok(css.includes(sel), sel);++const start = css.indexOf('Buttons that show what they do');++const block = css.slice(start, css.indexOf('@media (hover: none) { .ovk-btn:hover', start));++const moves = block.match(/(translate[XY]?|rotate|scale)\([^;]*\)/g) || [];++assert.ok(moves.length >= 10 && moves.every((m) => /--ov-move/.test(m) || /\((0|0px)\)$/.test(m)), 'every move is scaled by --ov-move: ' + moves.filter((m) => !/--ov-move/.test(m) && !/\((0|0px)\)$/.test(m)).join(' | '));++++// busy(): a fake document and button.++global.document = { readyState: 'complete', addEventListener() {}, createElement: () => ({ className: '', innerHTML: '', setAttribute() {}, remove() { this.gone = true; } }) };++const web = require('../kit-web.js');++const btn = { dataset: {}, disabled: false, attrs: {}, kids: [], setAttribute(k, v) { this.attrs[k] = v; }, removeAttribute(k) { delete this.attrs[k]; }, appendChild(c) { this.kids.push(c); } };++(async () => {++ const p = web.busy(btn, Promise.resolve(7));++ assert.strictEqual(btn.dataset.state, 'busy'); assert.strictEqual(btn.attrs['aria-busy'], 'true'); assert.strictEqual(btn.disabled, true);++ assert.strictEqual(await p, 7);++ assert.strictEqual(btn.dataset.state, 'done'); assert.strictEqual(btn.disabled, false); assert.ok(btn.kids[0].gone);++ await assert.rejects(web.busy(btn, () => Promise.reject(new Error('no'))), /no/);++ assert.strictEqual(btn.dataset.state, 'error');++ console.log('kit-buttons: kinds, status, attention, hold and busy');++ process.exit(0);++})();@@@ -5,6 +5,7 @@// sent without asking. This checks each of those, and the tool frame's clear button and "Try" examples, in the page.// Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -50,27 +51,27 @@ const server = http.createServer((req, res) => {const p = await h.openPage(browser, context, { width: 390 });await p.goto(`${base}/`, { maxMs: 5000 });await wait(200);-- assert.strictEqual(await p.evaluate('Math.round(scrollY)'), 0, 'tabs below the fold never scroll the page to themselves');-- assert.ok(await p.evaluate("document.querySelector('.ovk-tabs').scrollLeft") > 0, 'but their strip shows the open tab');++ await eventually(async () => assert.strictEqual(await p.evaluate('Math.round(scrollY)'), 0, 'tabs below the fold never scroll the page to themselves'));++ await eventually(async () => assert.ok(await p.evaluate("document.querySelector('.ovk-tabs').scrollLeft") > 0, 'but their strip shows the open tab'));const hasClipboard = await p.evaluate('!!navigator.clipboard');if (hasClipboard) assert.strictEqual(await p.evaluate("document.querySelector('[data-ovk-copy]').hidden"), false, 'the copy button shows where the clipboard works');await p.evaluate("document.querySelector('[data-ovk-sheet]').click()");await wait(100);-- assert.strictEqual(await p.evaluate("document.getElementById('sh').open"), true, 'the sheet button opens its sheet');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('sh').open"), true, 'the sheet button opens its sheet'));await p.evaluate("document.querySelector('#sh [data-ovk-close]').click()");await p.evaluate("document.querySelector('form[data-ovk-confirm] button').click()");await wait(100);-- assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Delete it?'], 'a form that asks first asks');-- assert.ok(!(await p.evaluate("window.__sent.some(([a]) => a === '/gone')")), 'and is not sent when the answer is no');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Delete it?'], 'a form that asks first asks'));++ await eventually(async () => assert.ok(!(await p.evaluate("window.__sent.some(([a]) => a === '/gone')")), 'and is not sent when the answer is no'));// The tool frame: the clear button follows the box; a Try example fills the box and runs the tool.-- assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), true, 'an empty box hides its clear button');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), true, 'an empty box hides its clear button'));await p.evaluate("document.querySelector('a[data-ovk-try]').click()");await wait(100);-- assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), 'openvibe.network', 'the example fills the box');-- assert.ok(await p.evaluate("window.__sent.some(([, q]) => q === 'openvibe.network')"), 'and runs the tool (its form is sent)');-- assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), false, 'a box with text shows its clear button');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), 'openvibe.network', 'the example fills the box'));++ await eventually(async () => assert.ok(await p.evaluate("window.__sent.some(([, q]) => q === 'openvibe.network')"), 'and runs the tool (its form is sent)'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), false, 'a box with text shows its clear button'));await p.evaluate("document.querySelector('.ovk-tool-clear').click()");-- assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), '', 'which empties it');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), '', 'which empties it'));await p.close();console.log(`kit-web-chrome: ${hasClipboard ? 'copy shows, ' : ''}sheets open, confirms ask, the tool frame's clear and Try work ok`);} finally {@@@ -4,6 +4,7 @@// area, named for a screen reader, and a tap closes the menu. On a wide screen the button keeps its word, Close.// Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -64,26 +65,26 @@ async function openMenu(p) {await openMenu(p);const x = await p.evaluate(BUTTON);assert.ok(x && x.shown, 'the close button shows on a phone');-- assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the menu locks the page without moving it');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the menu locks the page without moving it'));assert.strictEqual(x.vw, 390, 'a phone layout');assert.ok(x.w >= 40 && x.h >= 40 && x.right <= x.vw && x.top >= 0, `a 40 px target on screen: ${JSON.stringify(x)}`);assert.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');-- assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovmb .ovmb-app').length === 3 && getComputedStyle(o.querySelector('.ovmb-bar')).transform !== 'none'; })()`), true, 'its bar and three apps have crossed into an x');-- assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb');++ await eventually(async () => assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovmb .ovmb-app').length === 3 && getComputedStyle(o.querySelector('.ovmb-bar')).transform !== 'none'; })()`), true, 'its bar and three apps have crossed into an x'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb'));const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);assert.ok(hit, 'the hit area reaches past the circle');-- assert.ok(await p.evaluate('document.activeElement.classList.contains("ovs-orb")'), 'the phone menu keeps focus on its orb');++ await eventually(async () => assert.ok(await p.evaluate('document.activeElement.classList.contains("ovs-orb")'), 'the phone menu keeps focus on its orb'));await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });await p.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });// Up to three seconds: the fold takes 280 ms, and a slow runner (macOS in CI) has needed more than 450.let stillOpen = true;for (let i = 0; i < 30 && stillOpen; i += 1) { await wait(100); stillOpen = await p.evaluate('!!document.querySelector(".ovs.is-open")'); }assert.strictEqual(stillOpen, false, 'Escape closes the menu from its orb');-- assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing unlocks the page');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing unlocks the page'));await openMenu(p);await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');await wait(450); // the menu folds back into the orb-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'a tap closes the menu');++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'a tap closes the menu'));const wide = await h.openPage(browser, context, { width: 1280, height: 900 });await wide.goto(`${base}/`, { maxMs: 5000 });@@@ -5,6 +5,7 @@// and arrows; ticking one shows it, an arrow reorders, at most four, and the choice is kept on this device. A real// headless Chrome; skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -64,11 +65,11 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neawait p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');// 4.149.0 (the owner: tabs at the top, a Feed tab, remember the last one): this site's tab first, then Feed and You.-- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-starttabs .ovs-tab')].map((t) => t.getAttribute('data-tab') + ':' + t.getAttribute('aria-selected'))`), ['here:true', 'feed:false', 'you:false'], 'three tabs, this site first');-- assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-starttabs .ovs-tab')].map((t) => t.getAttribute('data-tab') + ':' + t.getAttribute('aria-selected'))`), ['here:true', 'feed:false', 'you:false'], 'three tabs, this site first'));++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s'));await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);await wait(150);-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Top stories', 'Today'], 'by default, every feed (owner, 11-09)');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Top stories', 'Today'], 'by default, every feed (owner, 11-09)'));// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a// picture-sized tile, so the titles line up. A group with no pictures keeps plain icons.const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);@@@ -78,33 +79,33 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neawait p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');-- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']);-- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, true, true]);++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']));++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, true, true]));// Turn two off: the rest show.await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);await wait(80);await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);await wait(100);-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'a feed turned off goes');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'a feed turned off goes'));// Tick Today: it shows, after the two.await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);await wait(100);-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Today']);++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Today']));// Move Today up twice: first.await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);await wait(60);await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);await wait(100);-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'Trending', 'New worlds'], 'the arrows order them');-- assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-ovs-feed-move')`), 'up|today:today', 'focus stays on the arrow');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'Trending', 'New worlds'], 'the arrows order them'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-ovs-feed-move')`), 'up|today:today', 'focus stays on the arrow'));// Untick Trending; tick Top stories: four at most.await p.evaluate(`document.querySelector('[data-ovs-feed="media-hub:trending"]').click(), true`);await wait(60);await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);await wait(100);-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories']);-- assert.deepStrictEqual(await p.evaluate(`JSON.parse(localStorage.getItem('ovs-feeds'))`), { on: ['today:today', 'games:new-worlds', 'news:top'] }, 'kept on this device');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories']));++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`JSON.parse(localStorage.getItem('ovs-feeds'))`), { on: ['today:today', 'games:new-worlds', 'news:top'] }, 'kept on this device'));const box = await p.evaluate(`document.documentElement.scrollWidth`);assert.ok(box <= 390, `nothing runs off a phone (${box})`);@@@ -114,10 +115,10 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neawait p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));await wait(150);-- assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'here', 'it reopens on this site\'s tab, whatever was open last');++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'here', 'it reopens on this site\'s tab, whatever was open last'));await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);await wait(250);-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order'));// Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.const ctx2 = await browser.newContext();const q = await h.openPage(browser, ctx2, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });@@@ -131,7 +132,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neawait until(q, `!!document.querySelector('.ovs-feeds')`);await q.evaluate(`document.querySelector('[data-ovs-feed="games:new-worlds"]').click(), true`);assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');-- assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });++ await eventually(async () => assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] }));await q.close();console.log('menu-feeds-chrome: every feed by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');} finally {@@@ -9,6 +9,7 @@// one row of names, Show more when there is more, and the composer only when Community says the person can post.// Nothing runs off a 390 px screen. Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -100,11 +101,11 @@ const server = http.createServer((req, res) => {assert.strictEqual(first.length, 13, `thirteen on Home by default (${first})`);assert.deepStrictEqual(first.slice(-3), ['folder:g-play', 'folder:build', 'folder:tools'], 'the final group, Build and Quick tools folders stay at the end');assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');-- assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');-- assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');-- assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches');-- assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many');-- assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip');++ await eventually(async () => assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is'));++ await eventually(async () => assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab'));++ await eventually(async () => assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches'));++ await eventually(async () => assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many'));++ await eventually(async () => assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip'));// A long-press starts Edit, and the same press drags the first app onto the third's place.await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring@@@ -113,23 +114,23 @@ const server = http.createServer((req, res) => {await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: a0.x, y: a0.y, button: 'left', clickCount: 1 });await wait(650);-- assert.ok(await p.evaluate(`!!document.querySelector('.ovh.is-edit')`), 'a long-press starts Edit');++ await eventually(async () => assert.ok(await p.evaluate(`!!document.querySelector('.ovh.is-edit')`), 'a long-press starts Edit'));for (let k = 1; k <= 6; k++) { await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x + ((a2.x - a0.x) * k) / 6, y: a0.y + ((a2.y - a0.y) * k) / 6, button: 'left' }); await wait(40); }await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: a2.x, y: a2.y, button: 'left', clickCount: 1 });await wait(450); // a person's pause after a drop (a tap within 300 ms of it is taken as part of the drag)const dragged = await order(p);assert.strictEqual(dragged.indexOf(first[0]), 2, `the app moved to where it was dropped (${dragged})`);-- assert.ok(await p.evaluate(`location.pathname === '/'`), 'the long-press did not open the app');++ await eventually(async () => assert.ok(await p.evaluate(`location.pathname === '/'`), 'the long-press did not open the app'));const stored = await p.evaluate(`JSON.parse(localStorage.getItem('ovs-home')).home`);assert.deepStrictEqual(stored, dragged, 'the arrangement is kept on the device');-- assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Your home', 'once arranged it is yours');++ await eventually(async () => assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Your home', 'once arranged it is yours'));// Keys: the focused app moves; Delete takes it off Home.await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${dragged[0]}"]').focus(), true`);await p.evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })), true`);const keyed = await order(p);assert.strictEqual(keyed.indexOf(dragged[0]), 1, 'ArrowRight moves the focused app one later');-- assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus'));await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);const removed = await order(p);assert.ok(!removed.includes(keyed[0]) && removed.length === 12, '− takes it off Home');@@@ -148,7 +149,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(folder.n, 24, 'the folder shows two dozen tools');assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');assert.strictEqual(folder.more, 'All 30 tools →', 'and says how many there are in all');-- assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on You');++ await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on You'));// ── Feed, signed out: today, the wall, went live in one row, text only, Show more; no composer.await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);@@@ -169,7 +170,7 @@ const server = http.createServer((req, res) => {assert.match(news.head, /What's new \(2 today\)/, 'the recently updated stay on the Feed, counted (library bumps left out)');assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);-- assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed');++ await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed'));await p.close();// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.@@@ -178,38 +179,38 @@ const server = http.createServer((req, res) => {assert.ok(await until(s, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');await openTab(s, 'you');assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');-- assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step');++ await eventually(async () => assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step'));// You on top: the name, Display (the Look panel) and Settings beside it; then your home, Most used and Jump back in.// 4.195.0 (the owner, the old block crossed out: "get rid of this thing … we didn't really need [it] here, other than// like adding your account"): one slim row of its own, and the old account block is not set in the menu at all.-- assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top');-- assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu');-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order');-- assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me > a.ovh-ib').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row');-- assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more');++ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top'));++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu'));++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order'));++ await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me > a.ovh-ib').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row'));++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more'));assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']);-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site');-- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report');++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']));++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site'));++ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report'));// The owner, 2026-11-10: "Your home should be above Jump back in… the thing at the top a little more compact… links// to whatever the user uses the most". Your home comes first, then what you use most, then where you were last;// the bar with your name is short; each most-used app is a way in (its icon, a full touch target), not a legend.-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were');-- assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 64`), 'the row with your name is one short row');-- assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets');-- assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me > *')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'all of it on the screen');++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were'));++ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 64`), 'the row with your name is one short row'));++ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets'));++ await eventually(async () => assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me > *')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'all of it on the screen'));assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => [k.getAttribute('href'), k.getAttribute('aria-label'), !!k.querySelector('.ovh-ic > *')].join('|'))`),['https://openvibe.live/|Live, opened 5 times|true', 'https://openvibe.pics/|Pics, opened 2 times|true'], 'each most-used app is a link with its icon and says how often');-- assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target');-- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is');-- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');++ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target'));++ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is'));++ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start'));++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons'));await s.evaluate(`document.querySelector('.ovh-sec .ovh-edit').click(), true`);await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');-- assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?");++ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?"));// Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each// uploads through Network and the post names them with that text.await s.evaluate(`(async () => {@@@ -220,9 +221,9 @@ const server = http.createServer((req, res) => {const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));})(), true`);assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');-- assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows');++ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows'));await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);-- assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on');++ await eventually(async () => assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on'));await s.evaluate(`document.querySelector('.ovh-post').click(), true`);assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);@@@ -252,7 +253,7 @@ const server = http.createServer((req, res) => {const closing = await s.evaluate(`(() => { window.OpenVibeDock.close(); const dock = document.querySelector('.ovdk'); return { hidden: dock.hidden, out: dock.classList.contains('is-out'), back: dock.querySelector('.ovdk-orbx').classList.contains('is-from'), navIsDock: !!document.querySelector('.ovnav-orb.is-dock') }; })()`);assert.ok(!closing.hidden && closing.out && closing.back && !closing.navIsDock, `closing folds it back into the orb first (${JSON.stringify(closing)})`);assert.ok(await until(s, `document.querySelector('.ovdk').hidden`), 'then the sheet is gone');-- assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was');++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was'));const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');await s.close();@@@ -9,6 +9,7 @@// (defaultPrevented) draws nothing.// Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -73,8 +74,8 @@ const server = http.createServer((req, res) => {await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Camera').click(), true`);assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 1500), 'the menu closed when a page was chosen');await wait(300);-- assert.strictEqual(await p.evaluate('location.pathname'), '/camera', 'the page moved, and the menu did not step Back over it');-- assert.ok(await p.evaluate('history.length') >= before, 'no history entry was taken back');++ await eventually(async () => assert.strictEqual(await p.evaluate('location.pathname'), '/camera', 'the page moved, and the menu did not step Back over it'));++ await eventually(async () => assert.ok(await p.evaluate('history.length') >= before, 'no history entry was taken back'));// Open again on the new page: Camera is marked; swap back to / in place while it is open: Home is marked.await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);@@@ -88,7 +89,7 @@ const server = http.createServer((req, res) => {// A click the page handled draws no bar; a link that leaves draws it at once, while the next page loads.await p.evaluate(`document.getElementById('handled').click(), true`);await wait(100);-- assert.strictEqual(await p.evaluate(`!!document.querySelector('#ovnav-leave.is-run')`), false, 'a handled click draws nothing');++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('#ovnav-leave.is-run')`), false, 'a handled click draws nothing'));await p.evaluate(`document.getElementById('plain').click(), true`);assert.ok(await until(p, `window.__left === true`, 1500), 'the plain link was clicked');assert.ok(await until(p, `(() => { const b = document.getElementById('ovnav-leave'); return !!b && b.classList.contains('is-run') && getComputedStyle(b).position === 'fixed' && parseFloat(getComputedStyle(b).height) === 3; })()`, 1500), 'the top bar runs as the page is left');@@@ -10,6 +10,7 @@ const os = require('node:os');const path = require('node:path');const http = require('node:http');const h = require('../browser-harness');++const { eventually } = h; // a check retries until the page is there (5 s ceiling), never a fixed sleepif (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-places-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }const root = path.join(__dirname, '..');const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title><link rel="icon" href="data:,"></head>@@@ -49,39 +50,39 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maawait page.evaluate('OpenVibeSearch.close()'); await wait(300);await page.evaluate(OPEN(true).replace("service: 'chat'", "service: 'design'"));assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places on another site`);-- assert.deepEqual((await page.evaluate(ROW)).slice(2, 4).map((t) => t.label), ['Chat', 'Vibe'], `${name}: on another site, Chat and Vibe`);++ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).slice(2, 4).map((t) => t.label), ['Chat', 'Vibe'], `${name}: on another site, Chat and Vibe`));await page.evaluate('OpenVibeSearch.close()'); await wait(300);await page.evaluate(OPEN(true)); await wait(350);}assert.ok(row.every((t) => t.top === row[0].top), `${name}: one row (${row.map((t) => t.top).join(',')})`);assert.ok(row.every((t) => Math.abs(t.w - row[0].w) <= 1 && t.h >= 42), `${name}: the same width each, and tall enough to press (${row.map((t) => `${t.w}x${t.h}`).join(' ')})`);assert.ok(row.every((t) => t.role === 'tab') && row[row.length - 1].right <= width, `${name}: all on the screen`);-- assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).map((t) => t.dataset.tab)`), ['here', 'feed', 'chats', 'vibe', 'you'], `${name}: stable place ids`);-- assert.equal(await page.evaluate('OpenVibeSearch._state().from === document.body'), true, `${name}: opening source retained`);-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`);-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`);++ await eventually(async () => assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).map((t) => t.dataset.tab)`), ['here', 'feed', 'chats', 'vibe', 'you'], `${name}: stable place ids`));++ await eventually(async () => assert.equal(await page.evaluate('OpenVibeSearch._state().from === document.body'), true, `${name}: opening source retained`));++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`));++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`));await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);-- assert.equal(await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"] .ovs-tabnew-red').textContent`), '7', `${name}: You has the red unread badge`);-- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-notifications-host [data-role="list"]')?.textContent`), 'five newest', `${name}: You starts with the notifications preview`);-- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more')?.textContent`), 'Show all 12', `${name}: notification expansion count, in the section's head`);-- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead b')?.textContent`), 'Notifications', `${name}: the section is named`);++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"] .ovs-tabnew-red').textContent`), '7', `${name}: You has the red unread badge`));++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-notifications-host [data-role="list"]')?.textContent`), 'five newest', `${name}: You starts with the notifications preview`));++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more')?.textContent`), 'Show all 12', `${name}: notification expansion count, in the section's head`));++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead b')?.textContent`), 'Notifications', `${name}: the section is named`));await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more').click()`);-- assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`);++ await eventually(async () => assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`));await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);if (width <= 640) {await page.evaluate(`document.querySelector('.ovs-list > li > .ovs-dockyou-b').click()`);-- assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-dock-you button')).map((b) => b.textContent)`), ['Start a timer', 'Keep the Dock button on every page', 'Open the Dock in its own window'], `${name}: The Dock row opens its three actions in place`);++ await eventually(async () => assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-dock-you button')).map((b) => b.textContent)`), ['Start a timer', 'Keep the Dock button on every page', 'Open the Dock in its own window'], `${name}: The Dock row opens its three actions in place`));await page.evaluate(`document.querySelector('.ovs-dock-you button').click()`);-- assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: You Dock timer action`);++ await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: You Dock timer action`));}await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="here"]').click()`);if (width > 640) {// A computer opens the floating Dock beside the menu.await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);-- assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: Vibe opens the Dock's Vibe`);++ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: Vibe opens the Dock's Vibe`));await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`);-- assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`);-- assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`);++ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`));++ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`));}await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);@@@ -97,32 +98,32 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).machosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).join(' ') })`);await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);if (width <= 640) {-- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`);-- assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`);++ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`));++ await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);-- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`);++ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`));await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);-- assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`);++ await eventually(async () => assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`));await page.evaluate(`document.querySelector('.ovs-ai-timer').click()`);-- assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: AI timer entry`);++ await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: AI timer entry`));await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);-- assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`);++ await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`));await page.evaluate(`document.querySelector('.ovs-input').value = ''; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`);await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);-- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`);++ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`));await page.evaluate(`document.querySelector('.ovs-input').value = 'sara'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(180);-- assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`);++ await eventually(async () => assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`));await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));`);-- assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`);++ await eventually(async () => assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`));await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);-- assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`);++ await eventually(async () => assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`));await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);await page.evaluate('OpenVibeSearch.close()'); await wait(300);-- assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`);++ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`));} else {-- assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`);-- assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`);++ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`));++ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`));await page.evaluate('OpenVibeSearch.close()'); await wait(300);}await page.evaluate('delete window.OpenVibeDock');@@@ -141,7 +142,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma// A visitor: three places, as before.await page.evaluate(OPEN(false));assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 3`), `${name}: a visitor has this site, Feed and You`);-- assert.deepEqual((await page.evaluate(ROW)).map((t) => t.label), ['Chat', 'Feed', 'You'].map((x, i) => x), `${name}: in that order`);++ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).map((t) => t.label), ['Chat', 'Feed', 'You'].map((x, i) => x), `${name}: in that order`));await page.close();}for (const width of [390, 1280]) {@@@ -154,13 +155,13 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma: new Response('{}', { status: 404 }); localStorage.setItem('ovs-feed-seen', '1');`);const endBadge = Date.now() + 7000;while (!await page.evaluate("!!document.querySelector('.ovnav-avatar-count:not([hidden])')") && Date.now() < endBadge) await wait(80);-- assert.equal(await page.evaluate("!!document.querySelector('.openvibe-bell, #openvibe-bell-mount')"), false, `${width}px: no bar bell or bell mount`);-- assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`);++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-bell, #openvibe-bell-mount')"), false, `${width}px: no bar bell or bell mount`));++ await eventually(async () => assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`));await page.evaluate("document.querySelector('#openvibe-avatar-btn').click()"); await wait(500);-- assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`);-- assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications]')"), true, `${width}px avatar opens at Notifications`);-- assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`);-- assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`);++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`));++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications]')"), true, `${width}px avatar opens at Notifications`));++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`));++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`));await page.close();}// Two pages in one browser context share the Feed seen time; the receiving open menu redraws its badge at once.@@@ -5,6 +5,7 @@// draws nothing there; Close stops and forgets; on a phone it is a bar across the bottom; a page's own video starting// closes it.const assert = require('node:assert/strict');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('node:fs');const os = require('node:os');const path = require('node:path');@@@ -80,7 +81,7 @@ const server = http.createServer((req, res) => {await page.evaluate(`document.querySelector('.ovmini-text').click()`);assert.ok(await until(`location.pathname === '/watch' && !!document.getElementById('v') && document.getElementById('v').readyState >= 1`), 'back on its page');assert.ok(await until(`document.getElementById('v').currentTime >= ${at - 0.05}`), 'the page takes the second back');-- assert.deepEqual(await page.evaluate(`[!!document.querySelector('.ovmini'), OpenVibeMini.record()]`), [false, null], 'no small player on the video\'s own page, and nothing left written down');++ await eventually(async () => assert.deepEqual(await page.evaluate(`[!!document.querySelector('.ovmini'), OpenVibeMini.record()]`), [false, null], 'no small player on the video\'s own page, and nothing left written down'));// Close stops and forgets.await take();@@@ -89,13 +90,13 @@ const server = http.createServer((req, res) => {await page.evaluate(`document.querySelector('.ovmini [data-m="close"]').click()`);assert.ok(await until(`!document.querySelector('.ovmini') && OpenVibeMini.record() === null`), 'closed and forgotten');await page.goto(`${base}/second`, { maxMs: 6000 }); await wait(300);-- assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page');++ await eventually(async () => assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page'));// A file that cannot be played here: the card says so and keeps the way back; nothing follows to the next page.await page.goto(`${base}/second`, { maxMs: 6000 });await page.evaluate(`OpenVibeMini.open({ page: '/watch', src: '/no-such-file.webm', title: 'The harbour at night', by: 'Kim', t: 3, playing: true })`);assert.ok(await until(`!!document.querySelector('.ovmini.is-broken')`), 'a file that cannot play is said, not hidden');-- assert.deepEqual(await page.evaluate(`(() => { const e = document.querySelector('.ovmini'); return [e.querySelector('.ovmini-by').textContent, e.querySelector('[data-m="play"]').hidden, e.querySelector('.ovmini-text').getAttribute('href'), OpenVibeMini.record()]; })()`), ['Open its page to keep watching', true, '/watch', null]);++ await eventually(async () => assert.deepEqual(await page.evaluate(`(() => { const e = document.querySelector('.ovmini'); return [e.querySelector('.ovmini-by').textContent, e.querySelector('[data-m="play"]').hidden, e.querySelector('.ovmini-text').getAttribute('href'), OpenVibeMini.record()]; })()`), ['Open its page to keep watching', true, '/watch', null]));await page.evaluate(`OpenVibeMini.close()`);// A phone: one bar across the bottom, the picture at its left, 44 px buttons.@@@ -4,6 +4,7 @@// the reader touches the scene; and under reduced motion there is no cursor at all. Skipped (exit 0) without Chrome or// with OV_SKIP_BROWSER=1.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -46,17 +47,17 @@ const server = http.createServer((req, res) => {if (now && now !== seen[seen.length - 1]) seen.push(now);}assert.deepStrictEqual(seen, ['First', 'Second', 'Third'], 'the steps in their numbered order, one chosen at a time');-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ov-ghost[aria-hidden=true]")'), true, 'the cursor is there, hidden from assistive tech');-- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'the page stays its width');++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ov-ghost[aria-hidden=true]")'), true, 'the cursor is there, hidden from assistive tech'));++ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'the page stays its width'));await p.evaluate('document.querySelector(".row").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); 1');await wait(200);-- assert.strictEqual(await p.evaluate(`!document.querySelector(".ov-ghost") && ${chosen} === ""`), true, 'a touch ends it for good');++ await eventually(async () => assert.strictEqual(await p.evaluate(`!document.querySelector(".ov-ghost") && ${chosen} === ""`), true, 'a touch ends it for good'));const still = await h.openPage(browser, context, { width: 390 });await still.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });await still.goto(`${base}/still`, { maxMs: 5000 });await wait(1500);-- assert.strictEqual(await still.evaluate('!document.querySelector(".ov-ghost") && document.querySelectorAll("[data-ov-ghost-on]").length === 0'), true, 'motion off: no cursor at all');++ await eventually(async () => assert.strictEqual(await still.evaluate('!document.querySelector(".ov-ghost") && document.querySelectorAll("[data-ov-ghost-on]").length === 0'), true, 'motion off: no cursor at all'));const errors = p.state.errors.concat(still.state.errors).filter((e) => !/Failed to load resource/.test(e.text));assert.deepStrictEqual(errors, [], JSON.stringify(errors));console.log('motion-ghost-chrome: all checks passed');@@@ -5,6 +5,7 @@// the magnifier or the drawer's "All sites". Runs in a real headless Chrome (skipped without Chrome or with// OV_SKIP_BROWSER=1), with the Frame answer served through a stubbed fetch.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -61,7 +62,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(first.display, false, 'this site\'s tab is its pages and every site');await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click(), true`);await wait(150);-- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs-displayrow .ovnav-display')`), true, 'Display is on the You tab');++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs-displayrow .ovnav-display')`), true, 'Display is on the You tab'));assert.strictEqual(first.expanded, 'true', 'the burger says the menu is open');// The drawer's "All sites" (kept for sites that ask for the legacy drawer) opens the list of every site.await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);@@@ -4,6 +4,7 @@// panel back, and a wide table never makes a phone's page scroll sideways.// node test/ov-canvas-chrome.test.jsconst assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -61,42 +62,42 @@ const server = http.createServer((req, res) => {await p.goto(`${base}/`, { maxMs: 5000 });await wait(200);const C = "document.getElementById('c')";-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November');-- assert.strictEqual(await p.evaluate(`${C}.querySelector('h3').textContent`), 'Lisbon, 14–17 November', 'a heading');-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-metric-v').textContent`), '€612', 'a metric');-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-when').textContent`), '5 min ago', 'when it changed, in words');-- assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-check input')].map((i) => i.checked)`), [true, false], 'a checklist');-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('.ovc-table tbody tr').length`), 2, 'a table');-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-table td.is-num').textContent`), '118', 'numbers align as numbers');-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg').length`), 2, 'two charts');-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg polyline').length`), 1, 'a line');-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg rect').length`), 2, 'grouped bars');-- assert.ok(await p.evaluate(`${C}.querySelector('table.ovc-sr').textContent.includes('240€')`), "the chart's numbers are a table for a screen reader");++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('h3').textContent`), 'Lisbon, 14–17 November', 'a heading'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-metric-v').textContent`), '€612', 'a metric'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-when').textContent`), '5 min ago', 'when it changed, in words'));++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-check input')].map((i) => i.checked)`), [true, false], 'a checklist'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('.ovc-table tbody tr').length`), 2, 'a table'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-table td.is-num').textContent`), '118', 'numbers align as numbers'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg').length`), 2, 'two charts'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg polyline').length`), 1, 'a line'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg rect').length`), 2, 'grouped bars'));++ await eventually(async () => assert.ok(await p.evaluate(`${C}.querySelector('table.ovc-sr').textContent.includes('240€')`), "the chart's numbers are a table for a screen reader"));// Only openvibe.media pictures, only https links, never markup, never a block it does not know.-- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('img')].every((i) => i.getAttribute('src').startsWith('https://openvibe.media/'))"), true, 'only openvibe.media pictures (one that fails to load is removed)');++ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('img')].every((i) => i.getAttribute('src').startsWith('https://openvibe.media/'))"), true, 'only openvibe.media pictures (one that fails to load is removed)'));assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('a')].map((a) => [a.getAttribute('href'), a.target, a.rel, a.querySelector('span').textContent])`),[['https://openvibe.video/watch?v=AbCdEfGhIjK', '_blank', 'noopener noreferrer nofollow', 'openvibe.video']], 'a link shows its host; a javascript: link is not drawn');-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('script, iframe, style:not(#ov-canvas-css)').length`), 0, 'no script, frame or style inside');-- assert.strictEqual(await p.evaluate('window.__xss === undefined'), true, 'text is text');-- assert.match(await p.evaluate(`${C}.querySelector('.ovc-text').textContent`), /^<script>window.__xss = 1<\/script>/, 'shown as written');-- assert.strictEqual(await p.evaluate(`${C}.textContent.includes('never')`), false, 'an html block and a running button are left out');++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('script, iframe, style:not(#ov-canvas-css)').length`), 0, 'no script, frame or style inside'));++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__xss === undefined'), true, 'text is text'));++ await eventually(async () => assert.match(await p.evaluate(`${C}.querySelector('.ovc-text').textContent`), /^<script>window.__xss = 1<\/script>/, 'shown as written'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.textContent.includes('never')`), false, 'an html block and a running button are left out'));// A button only asks; a tick tells; a canvas without the callbacks only shows.-- assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-btn')].map((b) => b.textContent)`), ['Book Casa A']);++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-btn')].map((b) => b.textContent)`), ['Book Casa A']));await p.evaluate(`${C}.querySelector('.ovc-btn').click()`);-- assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Book Casa A'], "the label goes to the agent as the person's message");-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-btn').textContent`), 'Sent');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Book Casa A'], "the label goes to the agent as the person's message"));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-btn').textContent`), 'Sent'));await p.evaluate(`${C}.querySelectorAll('.ovc-check input')[1].click()`);-- assert.deepStrictEqual(await p.evaluate('window.__checked'), [['todo', 'hotel', true]], 'the tick names the block and the item');-- assert.strictEqual(await p.evaluate("document.querySelector('#ro .ovc-btn').disabled && document.querySelector('#ro .ovc-check input').disabled"), true, 'no callbacks, nothing to press');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__checked'), [['todo', 'hotel', true]], 'the tick names the block and the item'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#ro .ovc-btn').disabled && document.querySelector('#ro .ovc-check input').disabled"), true, 'no callbacks, nothing to press'));// An older version never rolls the panel back; a newer one replaces it.-- assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 2, title: 'Old' })`), 3);-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November');-- assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 4, title: 'Newer' })`), 4);-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Newer');-- assert.strictEqual(await p.evaluate("OVCanvas.valid({ canvas: 'something-else', version: 1, blocks: [] })"), false);++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 2, title: 'Old' })`), 3));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 4, title: 'Newer' })`), 4));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Newer'));++ await eventually(async () => assert.strictEqual(await p.evaluate("OVCanvas.valid({ canvas: 'something-else', version: 1, blocks: [] })"), false));// A phone: the wide table scrolls in its own box; the page never scrolls sideways.-- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'no sideways scroll at 390 px');-- assert.ok(await p.evaluate(`${C}.querySelector('.ovc-table').scrollWidth > ${C}.querySelector('.ovc-table').clientWidth`), 'the table scrolls inside');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'no sideways scroll at 390 px'));++ await eventually(async () => assert.ok(await p.evaluate(`${C}.querySelector('.ovc-table').scrollWidth > ${C}.querySelector('.ovc-table').clientWidth`), 'the table scrolls inside'));if (process.env.OV_SHOT) {const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });fs.writeFileSync(process.env.OV_SHOT, Buffer.from(data, 'base64'));@@@ -4,6 +4,7 @@// is followed, the keyboard works, options say their state, and data-ov-native keeps the browser's own. Skipped// without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -63,68 +64,68 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEventawait p.goto(`${base}/`, { maxMs: 5000 });await wait(250);const btn = "document.querySelector('#agent + .ov-sel')";-- assert.strictEqual(await p.evaluate("document.getElementById('agent').classList.contains('ov-sel-native')"), true, 'the select is enhanced');-- assert.strictEqual(await p.evaluate(`${btn}.textContent.trim()`), 'Choose…');-- assert.strictEqual(await p.evaluate("!!document.querySelector('#raw + .ov-sel')"), false, 'data-ov-native keeps the browser\'s own');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('agent').classList.contains('ov-sel-native')"), true, 'the select is enhanced'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.textContent.trim()`), 'Choose…'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('#raw + .ov-sel')"), false, 'data-ov-native keeps the browser\'s own'));assert.deepStrictEqual(await p.evaluate("(() => { const b = document.querySelector('#bare + .ov-sel'); const cs = getComputedStyle(b); return [b.classList.contains('is-bare'), cs.borderTopWidth, cs.backgroundColor, getComputedStyle(b.querySelector('.ov-sel-chev')).display]; })()"),[true, '0px', 'rgba(0, 0, 0, 0)', 'none'], 'a bare select keeps its chip\'s look: no box, no chevron');-- assert.strictEqual(await p.evaluate(`${btn}.getAttribute('aria-labelledby').split(' ').some((id) => document.getElementById(id) && document.getElementById(id).tagName === 'LABEL')`), true, 'the button is named by the select\'s label');-- assert.strictEqual(await p.evaluate(`${btn}.getBoundingClientRect().width === document.getElementById('f').getBoundingClientRect().width`), true, 'a full-width select gives a full-width button');++ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.getAttribute('aria-labelledby').split(' ').some((id) => document.getElementById(id) && document.getElementById(id).tagName === 'LABEL')`), true, 'the button is named by the select\'s label'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.getBoundingClientRect().width === document.getElementById('f').getBoundingClientRect().width`), true, 'a full-width select gives a full-width button'));await p.evaluate(`${btn}.click()`);await wait(80);-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop.is-open')"), true, 'a click opens the menu');-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop [role=option]').length"), 4);-- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop [role=option][aria-disabled=true] a.ov-sel-link').getAttribute('href')"), '/setup/opencode', 'a choice that is not ready links to its setup');-- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-desc').textContent"), 'Your own Claude login, on your machine');-- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-badge.is-warn').textContent"), '91% used');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop.is-open')"), true, 'a click opens the menu'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop [role=option]').length"), 4));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop [role=option][aria-disabled=true] a.ov-sel-link').getAttribute('href')"), '/setup/opencode', 'a choice that is not ready links to its setup'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-desc').textContent"), 'Your own Claude login, on your machine'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-badge.is-warn').textContent"), '91% used'));await p.evaluate(key('ArrowDown'));await p.evaluate(key('ArrowDown'));await p.evaluate(key('ArrowDown')); // past the end: OpenCode is disabled, Codex stays the lastawait p.evaluate(key('Enter'));await wait(60);-- assert.strictEqual(await p.evaluate("document.getElementById('agent').value"), 'codex', 'the keyboard chooses, skipping a disabled choice');-- assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and the select says change, as a person\'s choice would');-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'and the menu closes');-- assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'with the focus back on the button');-- assert.match(await p.evaluate(`${btn}.textContent`), /Codex.*91% used/, 'the button shows the choice and its badge');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('agent').value"), 'codex', 'the keyboard chooses, skipping a disabled choice'));++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and the select says change, as a person\'s choice would'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'and the menu closes'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'with the focus back on the button'));++ await eventually(async () => assert.match(await p.evaluate(`${btn}.textContent`), /Codex.*91% used/, 'the button shows the choice and its badge'));await p.evaluate("document.getElementById('agent').value = 'claude-code'");-- assert.match(await p.evaluate(`${btn}.textContent`), /Claude Code/, 'a script that sets the value is followed');++ await eventually(async () => assert.match(await p.evaluate(`${btn}.textContent`), /Claude Code/, 'a script that sets the value is followed'));// A long list has a search box, and groups.await p.evaluate("document.querySelector('#long + .ov-sel').click()");await wait(80);-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-group').length"), 2);++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-group').length"), 2));await p.evaluate("(() => { const i = document.querySelector('.ov-sel-pop .ov-sel-search input'); i.value = 'tw'; i.dispatchEvent(new Event('input', { bubbles: true })); })()");-- assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ov-sel-pop .ov-sel-label')].map((x) => x.textContent)"), ['two', 'twelve'], 'typing narrows the list');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ov-sel-pop .ov-sel-label')].map((x) => x.textContent)"), ['two', 'twelve'], 'typing narrows the list'));await p.evaluate(key('Enter', "document.querySelector('.ov-sel-pop .ov-sel-search input')"));await wait(60);-- assert.strictEqual(await p.evaluate("document.getElementById('long').value"), 'two');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('long').value"), 'two'));// A multiple select keeps its menu open and counts.await p.evaluate("document.querySelector('#many + .ov-sel').click()");await wait(80);await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[0].click()");await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[2].click()");-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), true, 'the menu stays open while choosing several');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), true, 'the menu stays open while choosing several'));await p.evaluate(key('Escape', "document.querySelector('.ov-sel-pop')"));await wait(30);-- assert.strictEqual(await p.evaluate("document.querySelector('#many + .ov-sel').textContent.trim()"), 'a, c');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#many + .ov-sel').textContent.trim()"), 'a, c'));// A click elsewhere closes the menu; the form posts what was chosen.await p.evaluate("document.querySelector('#agent + .ov-sel').click()");await wait(60);await p.evaluate("document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'a click elsewhere closes it');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'a click elsewhere closes it'));await p.evaluate("document.getElementById('send').click()");-- assert.deepStrictEqual(await p.evaluate('window.__sent'), { agent: 'claude-code', many: ['a', 'c'], long: 'two' }, 'the form posts the hidden selects\' values');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__sent'), { agent: 'claude-code', many: ['a', 'c'], long: 'two' }, 'the form posts the hidden selects\' values'));// A required select left empty: the button says so, not a bubble on a hidden control.await p.evaluate("document.getElementById('agent').value = ''");await p.evaluate("document.getElementById('f').requestSubmit()");-- assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button');-- assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus');++ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus'));// A select inside a modal dialog opens over it (the top layer), and can be chosen from.await p.evaluate("document.getElementById('dlg').showModal()");@@@ -135,15 +136,15 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEventassert.strictEqual(seen, true, `the list is seen over a modal dialog: ${seen}`);await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[1].click()");await wait(60);-- assert.strictEqual(await p.evaluate("document.getElementById('indlg').value"), 'two', 'and a choice in it lands');-- assert.strictEqual(await p.evaluate("document.getElementById('dlg').open"), true, 'without closing the dialog');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('indlg').value"), 'two', 'and a choice in it lands'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('dlg').open"), true, 'without closing the dialog'));await p.evaluate("document.getElementById('dlg').close()");// Markup added later is enhanced too.await p.evaluate("document.body.insertAdjacentHTML('beforeend', '<select id=\"late\"><option>x</option><option>y</option></select>')");await wait(200);-- assert.strictEqual(await p.evaluate("!!document.querySelector('#late + .ov-sel')"), true, 'a select added later is enhanced');-- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('sw')).appearance"), 'none', 'a checkbox with role=switch is a switch');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('#late + .ov-sel')"), true, 'a select added later is enhanced'));++ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('sw')).appearance"), 'none', 'a checkbox with role=switch is a switch'));await p.close();// ── A phone: the sheet ──@@@ -152,17 +153,17 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEventawait wait(250);await phone.evaluate("document.querySelector('#agent + .ov-sel').click()");await wait(320);-- assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet.is-open') && !!document.querySelector('.ov-sel-scrim.is-open')"), true, 'a phone gets a sheet over a scrim');-- assert.strictEqual(await phone.evaluate("document.querySelector('.ov-sel-sheet-title').textContent"), 'Agent', 'titled with the select\'s label');-- assert.strictEqual(await phone.evaluate("document.documentElement.classList.contains('ov-sel-locked')"), true, 'the page under it does not scroll');-- assert.strictEqual(await phone.evaluate('document.body.style.position'), 'fixed', 'the shared helper locks the page in place');-- assert.ok(await phone.evaluate("document.querySelector('.ov-sel-sheet .ov-sel-opt').getBoundingClientRect().height") >= 44, 'rows are a finger tall');++ await eventually(async () => assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet.is-open') && !!document.querySelector('.ov-sel-scrim.is-open')"), true, 'a phone gets a sheet over a scrim'));++ await eventually(async () => assert.strictEqual(await phone.evaluate("document.querySelector('.ov-sel-sheet-title').textContent"), 'Agent', 'titled with the select\'s label'));++ await eventually(async () => assert.strictEqual(await phone.evaluate("document.documentElement.classList.contains('ov-sel-locked')"), true, 'the page under it does not scroll'));++ await eventually(async () => assert.strictEqual(await phone.evaluate('document.body.style.position'), 'fixed', 'the shared helper locks the page in place'));++ await eventually(async () => assert.ok(await phone.evaluate("document.querySelector('.ov-sel-sheet .ov-sel-opt').getBoundingClientRect().height") >= 44, 'rows are a finger tall'));await phone.evaluate("document.querySelectorAll('.ov-sel-sheet .ov-sel-opt')[1].click()");await wait(320);-- assert.strictEqual(await phone.evaluate("document.getElementById('agent').value"), 'claude-code');-- assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet') || document.documentElement.classList.contains('ov-sel-locked')"), false, 'choosing closes the sheet and frees the page');-- assert.strictEqual(await phone.evaluate('document.body.style.position'), '', 'closing restores the page');-- assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390, 'nothing is wider than the phone');++ await eventually(async () => assert.strictEqual(await phone.evaluate("document.getElementById('agent').value"), 'claude-code'));++ await eventually(async () => assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet') || document.documentElement.classList.contains('ov-sel-locked')"), false, 'choosing closes the sheet and frees the page'));++ await eventually(async () => assert.strictEqual(await phone.evaluate('document.body.style.position'), '', 'closing restores the page'));++ await eventually(async () => assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390, 'nothing is wider than the phone'));await phone.close();console.log('ov-forms-chrome: the menu, the sheet, the keyboard, search, several choices, the form\'s values and native opt-outs ok');} finally {@@@ -5,6 +5,7 @@// locker; making a locker refuses a weak or mismatched passphrase, shows 24 words once and checks two of them; and,// the point of all of it, the passphrase and the recovery words never leave the page, and nothing is stored.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -84,51 +85,51 @@ const server = http.createServer((req, res) => {// A password lock: only its password opens it; the card says so plainly.await p.evaluate(openItem('password:hunter22:the notes'));await wait(150);-- assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /is locked/);++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /is locked/));await p.evaluate(type('item-pass', 'wrong')); await p.evaluate(submit('item-open')); await wait(120);-- assert.match(await p.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/);-- assert.deepStrictEqual(await p.evaluate('window.__opened'), []);++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/));++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), []));await p.evaluate(type('item-pass', 'hunter22')); await p.evaluate(submit('item-open')); await wait(120);-- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes'], 'the right password opens it');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes'], 'the right password opens it'));// A link lock without its key, then with it.await p.evaluate(openItem('link::linked'));await wait(120);-- assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /missing its key/);++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /missing its key/));const key = 'A'.repeat(43);await p.evaluate(openItem('link::linked', `, link: 'https://x.test/p/1#k=${key}'`));await wait(120);-- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked'], 'the key after #k= opens it by itself');-- assert.strictEqual(await p.evaluate("OVLocker.keyFromLink('https://x.test/#k=short')"), null, 'a key that is not 32 bytes is no key');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked'], 'the key after #k= opens it by itself'));++ await eventually(async () => assert.strictEqual(await p.evaluate("OVLocker.keyFromLink('https://x.test/#k=short')"), null, 'a key that is not 32 bytes is no key'));// A people lock: unlock the locker (wrong passphrase first), then it opens.await p.evaluate(openItem('people::for you'));await wait(120);await p.evaluate(submit('item-unlock')); await wait(300);await p.evaluate(type('pass', 'nope')); await p.evaluate(submit('unlock')); await wait(150);-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not open your locker/);++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not open your locker/));await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(400);-- assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), true);-- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked', 'for you'], 'unlocked, it opens');++ await eventually(async () => assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), true));++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked', 'for you'], 'unlocked, it opens'));await p.evaluate('OVLocker.forget()');-- assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), false, 'forget() drops the keys');++ await eventually(async () => assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), false, 'forget() drops the keys'));// Making a locker: weak, then mismatched, then good; 24 words; two checked; saved locked.await p.evaluate('(() => { OVLocker.setup(); })()');await wait(300);await p.evaluate(type('pass', 'short')); await p.evaluate(type('pass2', 'short')); await p.evaluate(submit('next')); await wait(80);-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /longer/, 'a weak passphrase is refused');++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /longer/, 'a weak passphrase is refused'));await p.evaluate(type('pass', 'violet lantern river cobalt')); await p.evaluate(type('pass2', 'violet lantern river cobalT')); await p.evaluate(submit('next')); await wait(80);-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /do not match/);++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /do not match/));await p.evaluate(type('pass2', 'violet lantern river cobalt')); await p.evaluate(submit('next')); await wait(300);const words = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");assert.strictEqual(words.length, 24, 'the kit is 24 words');const labels = await p.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");await p.evaluate(type('w1', 'wrong')); await p.evaluate(type('w2', 'words')); await p.evaluate(submit('make')); await wait(100);-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /not the words/);++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /not the words/));assert.ok(!sent.some((s) => s.startsWith('PUT')), 'nothing is saved before the kit is checked');await p.evaluate(type('w1', words[labels[0] - 1])); await p.evaluate(type('w2', words[labels[1] - 1])); await p.evaluate(submit('make')); await wait(400);-- assert.strictEqual(await p.evaluate(`${q('fingerprint')}.textContent`), 'cedar ember lumen onyx fable maple gecko iris', 'the fingerprint, eight words');++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('fingerprint')}.textContent`), 'cedar ember lumen onyx fable maple gecko iris', 'the fingerprint, eight words'));const put = sent.find((s) => s.startsWith('PUT /api/v1/me/keys'));assert.ok(put, 'the locked copies are saved');assert.ok(!put.includes('violet') && !put.includes('lantern'), 'the passphrase never leaves the page');@@@ -138,7 +139,7 @@ const server = http.createServer((req, res) => {assert.ok(!put.includes('"d":"private"') && !put.includes('private'), 'nor the private keys in the clear');assert.match(put, /"backups":\{"passphrase":\{"format":"locker.envelope.v1"/, 'what is saved is two Locker envelopes');assert.match(put, /"self_signature":"sig"/, 'with the keys\u2019 self-signature (the SDK\u2019s fingerprint_signature)');-- assert.strictEqual(await p.evaluate('localStorage.length + sessionStorage.length'), 0, 'nothing is stored in the browser');++ await eventually(async () => assert.strictEqual(await p.evaluate('localStorage.length + sessionStorage.length'), 0, 'nothing is stored in the browser'));// Lost both: a plain warning, a checkbox that gates the button, then a new locker saved with reset: true.await p.evaluate('OVLocker.forget()');@@@ -146,8 +147,8 @@ const server = http.createServer((req, res) => {await p.evaluate('(() => { OVLocker.unlock(); })()'); await wait(300);await p.evaluate(submit('other')); await wait(80);await p.evaluate(submit('lost')); await wait(80);-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-body').textContent"), /cannot be opened again/, 'it says what is lost');-- assert.strictEqual(await p.evaluate(`${q('lost-go')}.disabled`), true, 'nothing starts until the person says they understand');++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-body').textContent"), /cannot be opened again/, 'it says what is lost'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('lost-go')}.disabled`), true, 'nothing starts until the person says they understand'));await p.evaluate(`${q('lost-ok')}.click()`); await p.evaluate(submit('lost-go')); await wait(400);await p.evaluate(type('pass', 'amber window tide falcon')); await p.evaluate(type('pass2', 'amber window tide falcon')); await p.evaluate(submit('next')); await wait(300);const w2 = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");@@@ -173,7 +174,7 @@ const server = http.createServer((req, res) => {await wait(300);authError = { error: 'locker.proof_required' };await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(150);-- assert.strictEqual(new URL(await p.evaluate("document.querySelector('dialog .ovl-msg a').href")).searchParams.get('reauth'), '1');++ await eventually(async () => assert.strictEqual(new URL(await p.evaluate("document.querySelector('dialog .ovl-msg a').href")).searchParams.get('reauth'), '1'));authError = null;// A phone: the kit fits, no sideways scroll.@@@ -182,8 +183,8 @@ const server = http.createServer((req, res) => {await phone.evaluate('(() => { OVLocker.setup(); })()');await wait(300);await phone.evaluate(type('pass', 'violet lantern river cobalt')); await phone.evaluate(type('pass2', 'violet lantern river cobalt')); await phone.evaluate(submit('next')); await wait(300);-- assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390);-- assert.ok(await phone.evaluate("(() => { const d = document.querySelector('dialog').getBoundingClientRect(); return [...document.querySelectorAll('dialog input')].every((i) => i.getBoundingClientRect().right <= d.right + 0.5); })()"), 'every field fits inside the sheet');++ await eventually(async () => assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390));++ await eventually(async () => assert.ok(await phone.evaluate("(() => { const d = document.querySelector('dialog').getBoundingClientRect(); return [...document.querySelectorAll('dialog input')].every((i) => i.getBoundingClientRect().right <= d.right + 0.5); })()"), 'every field fits inside the sheet'));await phone.close();console.log('ov-locker-chrome: password, link and people locks; making and unlocking a locker; nothing secret leaves the page or is stored; the phone ok');} finally {@@@ -6,6 +6,7 @@// an item locked in Node for that person's public key opens in the browser; a stranger's locker cannot open it;// a password-locked item opens with its password only; the recovery words open the locker too.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -93,16 +94,16 @@ const server = http.createServer((req, res) => {await b.evaluate(type('pass', PASS)); await b.evaluate(press('unlock'));await wait(5000);if (process.env.LOCKER_E2E_DEBUG) console.log('DEBUG', await b.evaluate("JSON.stringify({ card: (document.querySelector('.ovl-card') || {}).textContent, msg: (document.querySelector('dialog .ovl-msg') || {}).textContent, state: OVLocker.state() })"));-- assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only'], 'a new device unlocks with the passphrase and opens what was locked for this person');++ await eventually(async () => assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only'], 'a new device unlocks with the passphrase and opens what was locked for this person'));await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/theirs' }); })()`);await wait(800);-- assert.match(await b.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /not locked for you/, 'someone else’s item says so');++ await eventually(async () => assert.match(await b.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /not locked for you/, 'someone else’s item says so'));await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/pw', onOpen: (x) => window.__opened.push(new TextDecoder().decode(x)) }); })()`);await wait(300);await b.evaluate(type('item-pass', 'wrong one')); await b.evaluate(press('item-open')); await wait(3500);-- assert.match(await b.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/);++ await eventually(async () => assert.match(await b.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/));await b.evaluate(type('item-pass', 'hunter22 hunter22')); await b.evaluate(press('item-open')); await wait(3500);-- assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only', 'with a password'], 'the password item opens with its password');++ await eventually(async () => assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only', 'with a password'], 'the password item opens with its password'));// Device three: the recovery words open the locker too.const c = await h.openPage(browser, await browser.newContext(), { width: 1280 });@@@ -111,7 +112,7 @@ const server = http.createServer((req, res) => {await c.evaluate(press('other')); await wait(80);await c.evaluate(type('words', words.join(' ').toUpperCase())); await c.evaluate(press('unlock'));await wait(5000);-- assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker');++ await eventually(async () => assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker'));// A PRF-capable authenticator is stubbed at the browser boundary; sealing and opening remain real WebCrypto.// Hardware availability varies, so the unsupported case must leave setup usable too.const d = await h.openPage(browser, await browser.newContext(), { width: 390 });@@@ -130,7 +131,7 @@ const server = http.createServer((req, res) => {await wait(300); await d.evaluate(press('setup-passkey')); await wait(1500);const recovery = await d.evaluate("[...document.querySelectorAll('.ovl-words li')].map(x => x.textContent)");assert.strictEqual(recovery.length, 24);-- assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=print]')"), true);++ await eventually(async () => assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=print]')"), true));const ask = await d.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map(x => Number(x.textContent.replace(/\\D/g, '')))");await d.evaluate(type('w1', recovery[ask[0] - 1])); await d.evaluate(type('w2', recovery[ask[1] - 1])); await d.evaluate(press('make')); await wait(9000);assert.ok(stored.backups.passkey && stored.backups.recovery && !stored.backups.passphrase);@@@ -140,18 +141,18 @@ const server = http.createServer((req, res) => {await d.evaluate(press('done'));await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);await d.evaluate(press('unlock-passkey')); await wait(5000);-- assert.strictEqual(await d.evaluate('OVLocker.state().fingerprint'), pkFp, 'PRF opens the same SDK-protected keys');++ await eventually(async () => assert.strictEqual(await d.evaluate('OVLocker.state().fingerprint'), pkFp, 'PRF opens the same SDK-protected keys'));items.stream = (await locker.seal('streamed on device', { lock: 'people', keys: [{ subject: { type: 'user', id: 'usr_x' }, publicKey: stored.ecdh_public }] })).bytes;-- assert.strictEqual(await d.evaluate("(async () => new Response(await OVLocker.openStream((await fetch('/item/stream')).body)).text())()"), 'streamed on device');++ await eventually(async () => assert.strictEqual(await d.evaluate("(async () => new Response(await OVLocker.openStream((await fetch('/item/stream')).body)).text())()"), 'streamed on device'));await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);await d.evaluate(type('pass', PASS)); await d.evaluate(press('unlock')); await wait(300);-- assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /recovery kit/);-- assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=make-instead]')"), false, 'an existing passkey locker is never described as missing');++ await eventually(async () => assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /recovery kit/));++ await eventually(async () => assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=make-instead]')"), false, 'an existing passkey locker is never described as missing'));await d.evaluate("document.querySelector('dialog button[aria-label=Close]').click()");await d.evaluate(`(() => { OVLocker.forget(); navigator.credentials.create = async () => ({ getClientExtensionResults: () => ({}) }); OVLocker.setup(); })()`);await wait(300); await d.evaluate(press('setup-passkey')); await wait(300);-- assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not support Locker/);-- assert.strictEqual(await d.evaluate("document.querySelector('[data-k=setup-passkey]').disabled"), false);++ await eventually(async () => assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not support Locker/));++ await eventually(async () => assert.strictEqual(await d.evaluate("document.querySelector('[data-k=setup-passkey]').disabled"), false));console.log('ov-locker-e2e-chrome: real crypto: make a locker, unlock it on a new device by passphrase and by recovery words, open what was locked for you, refuse what was not');} finally {await browser.close();@@@ -6,6 +6,7 @@// and the recipient's banner and the not-on-the-list gate call the right routes. Names and titles are text, never// markup. A phone gets a sheet no wider than the phone. Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -99,22 +100,22 @@ const server = http.createServer((req, res) => {await p.evaluate("document.getElementById('sh').click()");await wait(300);-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'a [data-ov-share-sheet] button opens the sheet as a modal dialog');-- assert.strictEqual(await p.evaluate("document.getElementById('ov-share-title').textContent"), 'Share “<b>notes</b>”', 'the title is text, never markup');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'a [data-ov-share-sheet] button opens the sheet as a modal dialog'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ov-share-title').textContent"), 'Share “<b>notes</b>”', 'the title is text, never markup'));// A site's own h2 rule (Wiki: h2 { border-bottom; padding-bottom; margin }) never reaches the sheet's title.await p.evaluate("(() => { const st = document.createElement('style'); st.textContent = 'h2 { border-bottom: 3px solid red; padding-bottom: 9px; text-transform: uppercase; }'; document.head.appendChild(st); })()");-- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).borderBottomWidth"), '0px', 'a page h2 border does not reach the sheet title');-- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).paddingBottom"), '0px', 'nor its padding');-- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).textTransform"), 'none', 'nor its text-transform');-- assert.strictEqual(await p.evaluate(`${q('aud-private')}.checked`), true, 'the service\'s audience is chosen');-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share [data-k=\"done\"]').textContent"), 'Done', 'nothing changed: Done, not Save');++ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).borderBottomWidth"), '0px', 'a page h2 border does not reach the sheet title'));++ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).paddingBottom"), '0px', 'nor its padding'));++ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).textTransform"), 'none', 'nor its text-transform'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('aud-private')}.checked`), true, 'the service\'s audience is chosen'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share [data-k=\"done\"]').textContent"), 'Done', 'nothing changed: Done, not Save'));// A name nobody has: said plainly, nothing added, nothing saved.await p.evaluate(typeIn('add', '@nobody'));await p.evaluate(`${q('add-btn')}.click()`);await wait(150);-- assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /No OpenVibe account is called @nobody/);-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-list').children.length"), 1, 'only the owner');++ await eventually(async () => assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /No OpenVibe account is called @nobody/));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-list').children.length"), 1, 'only the owner'));// A name that is not a name is refused before anyone is asked.await p.evaluate(typeIn('add', 'a b!'));await p.evaluate(`${q('add-btn')}.click()`);@@@ -124,23 +125,23 @@ const server = http.createServer((req, res) => {await p.evaluate(typeIn('add', 'slow'));await p.evaluate(`${q('add-btn')}.click()`);await wait(150);-- assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /Too many name checks/);++ await eventually(async () => assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /Too many name checks/));// A real name: added, Only me becomes Specific people, the display name stays text.await p.evaluate(typeIn('add', '@Quinn'));await p.evaluate(`${q('add-btn')}.click()`);await wait(150);-- assert.strictEqual(await p.evaluate(`${q('aud-people')}.checked`), true, 'adding someone to Only me switches to Specific people');-- assert.strictEqual(await p.evaluate('window.__xss'), undefined, 'a display name is never markup');-- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('dialog.ov-share .ov-share-display')].map((n) => n.textContent)[1]"), PEOPLE.quinn.display_name);-- assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Save', 'a change asks to be saved');++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('aud-people')}.checked`), true, 'adding someone to Only me switches to Specific people'));++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__xss'), undefined, 'a display name is never markup'));++ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('dialog.ov-share .ov-share-display')].map((n) => n.textContent)[1]"), PEOPLE.quinn.display_name));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Save', 'a change asks to be saved'));assert.ok(!calls.some((c) => c.startsWith('PUT')), 'nothing is saved before Save');// Leaving with unsaved changes asks first; Keep editing keeps them.await p.evaluate(`${q('close')}.click()`);await wait(80);-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'unsaved changes keep the sheet open');-- assert.ok(await p.evaluate(`!!${q('discard')}`), 'and ask: Discard or Keep editing');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'unsaved changes keep the sheet open'));++ await eventually(async () => assert.ok(await p.evaluate(`!!${q('discard')}`), 'and ask: Discard or Keep editing'));await p.evaluate(`${q('keep-editing')}.click()`);await wait(50);@@@ -152,62 +153,62 @@ const server = http.createServer((req, res) => {assert.deepStrictEqual(S.lastPut, { audience: 'people', members: [{ role: 'viewer', subject: PEOPLE.quinn.subject }, { role: 'viewer', subject: PEOPLE.maticus.subject }], notify: true },'Save sends the audience and the members by subject (never by display name), with no end date when there is none');assert.strictEqual(S.lastIfMatch, '"7"', 'and only over the version it was shown (If-Match)');-- assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and tells the page (ov-share:change)');-- assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Done');++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and tells the page (ov-share:change)'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Done'));// Transfer: to a person on the list, removing my access by default, bound to their subject.await p.evaluate(`${q('give')}.click()`);await wait(80);-- assert.strictEqual(await p.evaluate(`${q('keep-false')}.checked`), true, '"Remove my access" is the default');++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('keep-false')}.checked`), true, '"Remove my access" is the default'));await p.evaluate(`${q('give-maticus')}.click()`);await wait(50);await p.evaluate(`${q('give-send')}.click()`);await wait(200);assert.deepStrictEqual(S.lastTransfer, { to: { subject: PEOPLE.maticus.subject }, keep_access: false });-- assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-banner').textContent"), /Waiting for @maticus to accept/);-- assert.strictEqual(await p.evaluate(`!!${q('give')}`), false, 'one offer at a time');++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-banner').textContent"), /Waiting for @maticus to accept/));++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('give')}`), false, 'one offer at a time'));await p.evaluate(`${q('cancel-give')}.click()`);await wait(200);assert.strictEqual(S.transfer, null, 'the owner can cancel');assert.deepStrictEqual(S.lastCancel, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'naming the offer it cancels');await p.evaluate(`${q('close')}.click()`);await wait(320);-- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog').length + (document.documentElement.classList.contains('ov-share-locked') ? 1 : 0)"), 0, 'closing removes the sheet and frees the page');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog').length + (document.documentElement.classList.contains('ov-share-locked') ? 1 : 0)"), 0, 'closing removes the sheet and frees the page'));// A gate in markup starts by itself, once, with its values as plain attributes (a quote or </script> in one is// just text); signed out, it offers the sign-in link it was given.-- assert.ok(await p.evaluate("!!document.querySelector('#gate2 .ov-share-gate')"), 'a gate in markup starts when ov-share.js loads');-- assert.strictEqual(await p.evaluate("document.querySelector('#gate2 a').getAttribute('href')"), '/login?next=%2Fp%2F"</script>', 'its sign-in link is the attribute, as text');-- assert.strictEqual(await p.evaluate('OVShare.startGates()'), 0, 'and it starts only once');++ await eventually(async () => assert.ok(await p.evaluate("!!document.querySelector('#gate2 .ov-share-gate')"), 'a gate in markup starts when ov-share.js loads'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#gate2 a').getAttribute('href')"), '/login?next=%2Fp%2F"</script>', 'its sign-in link is the attribute, as text'));++ await eventually(async () => assert.strictEqual(await p.evaluate('OVShare.startGates()'), 0, 'and it starts only once'));// The recipient's banner and the gate.await p.evaluate("OVShare.offer(document.getElementById('offer'), { kind: 'paste', id: 'abc', transfer_id: '00000000-0000-4000-8000-0000000000aa', from: { username: 'goosely' }, title: 'notes' })");await p.evaluate("[...document.querySelectorAll('#offer button')].find((b) => b.textContent === 'Accept').click()");await wait(200);assert.ok(calls.includes('POST /api/v1/paste/abc/share/transfer/accept'));assert.deepStrictEqual(S.lastAccept, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'the answer names the offer');-- assert.match(await p.evaluate("document.getElementById('offer').textContent"), /It is yours now/);++ await eventually(async () => assert.match(await p.evaluate("document.getElementById('offer').textContent"), /It is yours now/));await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signedInAs: 'quinn' })");-- assert.ok(!/notes/.test(await p.evaluate("document.getElementById('gate').textContent")), 'the gate never shows the title');++ await eventually(async () => assert.ok(!/notes/.test(await p.evaluate("document.getElementById('gate').textContent")), 'the gate never shows the title'));await p.evaluate("document.querySelector('#gate .ov-share-btn').click()");await wait(200);assert.ok(calls.includes('POST /api/v1/paste/abc/share/requests'));-- assert.match(await p.evaluate("document.getElementById('gate').textContent"), /Asked/);++ await eventually(async () => assert.match(await p.evaluate("document.getElementById('gate').textContent"), /Asked/));await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signInUrl: '/login' })");-- assert.strictEqual(await p.evaluate("document.querySelector('#gate a').getAttribute('href')"), '/login', 'signed out: sign in');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#gate a').getAttribute('href')"), '/login', 'signed out: sign in'));// Send it: for anyone, with no kind or id; the link, Copy, the places, "Start at", embed code.await p.evaluate("Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__clip = t; return Promise.resolve(); } }, configurable: true })"); // headless Chrome refuses the real one without a grantawait p.evaluate("document.getElementById('send').click()");await wait(300);-- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud').length"), 0, 'no "who can open it" without kind and id');-- assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1', 'the link, shown without https://');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud').length"), 0, 'no "who can open it" without kind and id'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1', 'the link, shown without https://'));await p.evaluate(`${q('copy')}.click()`);await wait(120);-- assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link');-- assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)');-- assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied');++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link'));++ await eventually(async () => assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied'));// No Dock on this page (signed out): no OpenVibe Chat tile.-- assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat without the Dock');++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat without the Dock'));// OpenVibe Community is offered to everyone (4.179.0): a link to its new-topic form, the title and the link filled in.const comm = await p.evaluate(`(() => { const a = ${q('to-ovcommunity')}; return a ? { href: a.getAttribute('href'), text: a.textContent, first: document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile') === a } : null; })()`);assert.ok(comm, 'the Community tile');@@@ -217,20 +218,20 @@ const server = http.createServer((req, res) => {assert.ok(decodeURIComponent(comm.href.split('&body=')[1]).includes('http'), 'the body carries the link');const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');-- assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer');++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer'));assert.ok(!/utm_|ref=/.test(x), 'no tracking is added to a link');await p.evaluate(`${q('at-time')}.click()`);await wait(60);-- assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1?t=83', '"Start at 1:23" adds the time');++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1?t=83', '"Start at 1:23" adds the time'));await p.evaluate(`${q('more')} && ${q('more')}.click()`);await wait(60);await p.evaluate(`${q('to-embed')}.click()`);await wait(60);-- assert.match(await p.evaluate(`${q('embed-code')}.value`), /^<iframe src="https:\/\/video\.test\/embed\/1\?t=83" [^>]*title="A robot & a wave"/, 'embed code, with the time and an escaped title');++ await eventually(async () => assert.match(await p.evaluate(`${q('embed-code')}.value`), /^<iframe src="https:\/\/video\.test\/embed\/1\?t=83" [^>]*title="A robot & a wave"/, 'embed code, with the time and an escaped title'));await p.evaluate(`${q('to-mastodon')}.click()`);await wait(60);await p.evaluate(`(() => { const i = ${q('masto')}; i.value = 'not a server'; i.form.requestSubmit(); })()`);-- assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address');++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address'));await p.evaluate(`${q('close')}.click()`);await wait(320);// Signed in (the Dock is on the page): OpenVibe Chat comes first; it closes the sheet and hands the Dock the title@@@ -238,25 +239,25 @@ const server = http.createServer((req, res) => {await p.evaluate("window.__docked = []; window.OpenVibeDock = { share: (o) => { window.__docked.push(o); }, isOpen: () => false }");await p.evaluate("document.getElementById('send').click()");await wait(300);-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile').getAttribute('data-k')"), 'to-ovchat', 'OpenVibe Chat is the first place');-- assert.strictEqual(await p.evaluate(`${q('to-ovchat')}.textContent`), 'OpenVibe Chat');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile').getAttribute('data-k')"), 'to-ovchat', 'OpenVibe Chat is the first place'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('to-ovchat')}.textContent`), 'OpenVibe Chat'));await p.evaluate(`${q('to-ovchat')}.click()`);await wait(320);-- assert.deepStrictEqual(await p.evaluate('window.__docked'), [{ text: 'A robot & a wave https://video.test/v/1', title: 'A robot & a wave' }], 'the Dock gets the title and the link');-- assert.strictEqual(await p.evaluate("!!document.querySelector('dialog.ov-share')"), false, 'the sheet closes');++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__docked'), [{ text: 'A robot & a wave https://video.test/v/1', title: 'A robot & a wave' }], 'the Dock gets the title and the link'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('dialog.ov-share')"), false, 'the sheet closes'));// A Dock that may not send (a read-only Dock token): no OpenVibe Chat.await p.evaluate("window.OpenVibeDock = { share: () => {}, isOpen: () => false, canSend: () => false }");await p.evaluate("document.getElementById('send').click()");await wait(300);-- assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat when the Dock cannot send');++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat when the Dock cannot send'));await p.evaluate(`${q('close')}.click()`);await wait(320);await p.evaluate("delete window.OpenVibeDock");// Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.await p.evaluate("document.getElementById('theirs').click()");await wait(300);-- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud, dialog.ov-share .ov-share-error').length"), 0, 'a 404 from the share API leaves the send half alone');-- assert.ok(await p.evaluate(`!!${q('copy')}`));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud, dialog.ov-share .ov-share-error').length"), 0, 'a 404 from the share API leaves the send half alone'));++ await eventually(async () => assert.ok(await p.evaluate(`!!${q('copy')}`)));await p.evaluate(`${q('close')}.click()`);await wait(320);@@@ -272,15 +273,15 @@ const server = http.createServer((req, res) => {await wait(400);await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"device\"]').click()");await wait(100);-- assert.deepStrictEqual(await phone.evaluate('window.__shared'), { title: 'A robot & a wave', url: 'https://video.test/v/1' }, 'the phone\'s sheet gets the title and the link');-- assert.ok(await phone.evaluate("!!document.querySelector('dialog.ov-share [data-k=\"to-sms\"]') || !!document.querySelector('dialog.ov-share [data-k=\"more\"]')"), 'Messages is offered on a phone');++ await eventually(async () => assert.deepStrictEqual(await phone.evaluate('window.__shared'), { title: 'A robot & a wave', url: 'https://video.test/v/1' }, 'the phone\'s sheet gets the title and the link'));++ await eventually(async () => assert.ok(await phone.evaluate("!!document.querySelector('dialog.ov-share [data-k=\"to-sms\"]') || !!document.querySelector('dialog.ov-share [data-k=\"more\"]')"), 'Messages is offered on a phone'));await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"close\"]').click()");await wait(320);await phone.evaluate("document.getElementById('sh').click()");await wait(500);const r = await phone.evaluate("(() => { const d = document.querySelector('dialog.ov-share').getBoundingClientRect(); return [Math.round(d.width), Math.round(innerHeight - d.bottom), document.documentElement.scrollWidth]; })()");assert.deepStrictEqual(r, [390, 0, 390], 'the sheet sits on the bottom edge, the phone\'s width, and nothing is wider');-- assert.ok(await phone.evaluate("document.querySelector('dialog.ov-share .ov-share-addfield').getBoundingClientRect().height") >= 44, 'the name field is a finger tall');++ await eventually(async () => assert.ok(await phone.evaluate("document.querySelector('dialog.ov-share .ov-share-addfield').getBoundingClientRect().height") >= 44, 'the name field is a finger tall'));await phone.close();console.log('ov-share-chrome: send (copy, X, start at, embed, Mastodon, the phone sheet), not-the-owner, loading, @names (unknown, invalid, rate-limited, found), Only me → people, save by subject, leave-unsaved, transfer and cancel, the offer, the gate, the phone ok');} finally {@@@ -7,6 +7,7 @@// removing @maticus seals the content again under a fresh key: the new bytes open for quinn, not for maticus;// a link-locked paste's link keeps the key after #k=, and says so when the key is missing.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -147,30 +148,30 @@ const server = http.createServer((req, res) => {// The sheet itself loads no Locker: a fresh tab proves it, and that the first press of the part does.const fresh = await h.openPage(browser, await browser.newContext(), { width: 390 });await fresh.goto(`${base}/`, { maxMs: 5000 });-- assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker');++ await eventually(async () => assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker'));await fresh.evaluate("document.getElementById('sh').click()");await wait(400);-- assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked');++ await eventually(async () => assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked'));await fresh.evaluate(press('lock-open'));await wait(600);-- assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js');++ await eventually(async () => assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js'));await fresh.evaluate(press('close'));// 1. The Locked part says what the lock means, in plain words, and whose keys it is wrapped for.await page.evaluate("document.getElementById('sh').click()");await wait(400);-- assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked');-- assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words');++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked'));++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words'));await page.evaluate(press('lock-open'));await wait(500);-- assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means');-- assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown');++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means'));++ await eventually(async () => assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown'));// 2. Add @quinn: their public key is wrapped for them here.await page.evaluate(type('add', 'quinn'));await page.evaluate(press('add-btn'));await wait(600);-- assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list');++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list'));await page.evaluate(press('done'));await wait(2500); // the re-wrapassert.strictEqual(puts.length, 1, 'one save');@@@ -189,7 +190,7 @@ const server = http.createServer((req, res) => {await wait(400);await page.evaluate(press('rm-user:usr_mat'));await wait(200);-- assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened');++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened'));await page.evaluate(press('done'));await wait(3000);assert.strictEqual(puts.length, 2, 'the removal saved');@@@ -222,14 +223,14 @@ const server = http.createServer((req, res) => {items['/p/locked/raw'] = linkSealed.bytes;await linkPage.evaluate(`(() => { OVShare.open({ kind: 'paste', id: 'locked', title: 'Locked note' }); })()`);await wait(500);-- assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #');++ await eventually(async () => assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #'));await linkPage.evaluate(press('lock-open'));await wait(400);await linkPage.evaluate('history.replaceState(null, "", location.pathname)');await linkPage.evaluate(press('lock-open'));await linkPage.evaluate(press('lock-open'));await wait(500);-- assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so');++ await eventually(async () => assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so'));console.log('ov-share-lock-chrome: the Locked part with real crypto: lazy load, fingerprints, add wraps for them, removal re-seals, the link lock keeps its key');} finally {@@@ -4,6 +4,7 @@// shows it at once; a press, Escape or leaving hides it; an opened button shows none; it is hidden from assistive// technology (the element keeps its own name).const assert = require('node:assert/strict');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('node:fs');const os = require('node:os');const path = require('node:path');@@@ -37,7 +38,7 @@ const server = http.createServer((req, res) => {// A mouse resting on the button at the right edge: the tooltip, after a moment, under it and on the screen.await move(782, 26);-- assert.equal(await page.evaluate(ON), false, 'not at once: a pointer passing over shows nothing');++ await eventually(async () => assert.equal(await page.evaluate(ON), false, 'not at once: a pointer passing over shows nothing'));assert.ok(await until(ON), 'after a short rest it shows');await wait(260);const a = await page.evaluate(READ('a'));@@@ -67,7 +68,7 @@ const server = http.createServer((req, res) => {await move(400, 300); await wait(600);// An opened button shows none.await move(38, 26); await wait(700);-- assert.equal(await page.evaluate(ON), false, 'a button whose panel is open shows no tooltip');++ await eventually(async () => assert.equal(await page.evaluate(ON), false, 'a button whose panel is open shows no tooltip'));await move(400, 300);// The keyboard: focus shows it at once; Escape hides it.await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });@@@ -5,6 +5,7 @@// opens again where it was left; on a phone every kind is a sheet that a swipe down closes; the window style is one// attribute for every window; content given as text is set as text.const assert = require('node:assert/strict');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('node:fs');const os = require('node:os');const path = require('node:path');@@@ -42,22 +43,22 @@ window.mk = (o) => { const f = document.createElement('div'); const a = documentassert.ok(v.popX !== '', 'it grows out of its button');// The focus stays inside: Tab from the last control goes to the first (×), Shift+Tab from the first to the last.await page.evaluate(`document.getElementById('last').focus()`); await key(page, 'Tab');-- assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)');++ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)'));await key(page, 'Tab', true);-- assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last');++ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last'));// A second window on top: Escape closes the top one only.await page.evaluate(`window.w2 = mk({ title: 'Send', from: document.getElementById('last') })`);-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2);++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2));await key(page, 'Escape'); await wait(350);-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window');-- assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it');++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window'));++ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it'));await key(page, 'Escape'); await wait(350);v = await page.evaluate(`({ n: document.querySelectorAll('.ovw').length, scrims: document.querySelectorAll('.ovw-scrim').length, focus: document.activeElement.id, locked: document.documentElement.classList.contains('ovw-locked'), shut: window.shut })`);assert.deepEqual(v, { n: 0, scrims: 0, focus: 'open', locked: false, shut: ['escape', 'escape'] }, 'both gone, the page free, the focus back on Open');// A press outside a dialog closes it.await page.evaluate(`mk({})`); await wait(80);await page.evaluate(`document.querySelector('.ovw-scrim').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`); await wait(350);-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog');++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog'));// Text is text.await page.evaluate(`OVWindow.open({ title: '<b>x</b>', body: '<img src=x onerror=alert(1)>' })`);v = await page.evaluate(`({ t: document.querySelector('.ovw-t').textContent, imgs: document.querySelectorAll('.ovw img').length })`);@@@ -75,18 +76,18 @@ window.mk = (o) => { const f = document.createElement('div'); const a = documentawait page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 200, y: hb.y + 150, button: 'left' });await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: hb.x + 200, y: hb.y + 150, button: 'left', clickCount: 1 });const moved = await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`);-- assert.deepEqual(await page.evaluate(`JSON.parse(localStorage.getItem('ov:window:dock-test'))`), moved, 'where it was left is kept');++ await eventually(async () => assert.deepEqual(await page.evaluate(`JSON.parse(localStorage.getItem('ov:window:dock-test'))`), moved, 'where it was left is kept'));await page.evaluate('p1.close()'); await wait(300);await page.evaluate(`mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);await wait(400);-- assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again');++ await eventually(async () => assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again'));await page.evaluate('OVWindow.closeAll()'); await wait(300);// The window style: one attribute, every window.-- assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline');-- assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline');-- assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default');-- assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null);++ await eventually(async () => assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline'));++ await eventually(async () => assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline'));++ await eventually(async () => assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default'));++ await eventually(async () => assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null));await page.close();// A phone: every kind is a sheet; a swipe down on its head closes it.@@@ -101,8 +102,8 @@ window.mk = (o) => { const f = document.createElement('div'); const a = documentawait ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 260, button: 'left' });await ph.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sh.x, y: sh.y + 260, button: 'left', clickCount: 1 });await wait(400);-- assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet');-- assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']);++ await eventually(async () => assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet'));++ await eventually(async () => assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']));await ph.close();console.log('ov-window-chrome: a named modal dialog from its button, focus kept and given back, stacked Escape, outside press, text as text, a panel beside its button that keeps its place, one window style, a phone sheet closed by a swipe');} finally {@@@ -5,6 +5,7 @@// another OpenVibe site carries it in #ovo=; the next page opens it again and takes the hash out of the address; a// source that is not first-party is never opened; a reload brings it back. Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -65,30 +66,30 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) reassert.ok(await until(p, `getComputedStyle(document.querySelector('.ovo-side')).transform === 'none'`), 'the sheet has finished opening');const side = await p.evaluate(`(() => { const s = document.querySelector('.ovo-side'); const r = s.getBoundingClientRect(); return { open: s.classList.contains('is-open'), bottom: Math.round(r.bottom), src: s.querySelector('iframe').getAttribute('src') }; })()`);assert.deepStrictEqual(side, { open: true, bottom: 844 - 58 - 10, src: `${base}/frame-chat` }, 'the chat sheet, above the app bar');-- assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the chat sheet locks the page');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the chat sheet locks the page'));await p.evaluate(`document.querySelector('[data-ovo="side"]').click(), true`);-- assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing the chat sheet unlocks the page');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing the chat sheet unlocks the page'));// A link to another OpenVibe site carries it; one on this site does not.const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);-- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash');-- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://example.com/x')`), 'https://example.com/x', 'not an OpenVibe site: no hash');++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash'));++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://example.com/x')`), 'https://example.com/x', 'not an OpenVibe site: no hash'));// Arriving with the hash: the card opens and the hash leaves the address.const data = away.split('#ovo=')[1];const q = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 900 });await q.goto(`${base}/b#ovo=${data}`, { maxMs: 5000 });assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'the carried card opens');-- assert.strictEqual(await q.evaluate(`location.hash`), '', 'the hash is gone from the address');-- assert.strictEqual(await q.evaluate(`document.querySelector('.ovo-card .ovo-name').textContent`), 'maticus');++ await eventually(async () => assert.strictEqual(await q.evaluate(`location.hash`), '', 'the hash is gone from the address'));++ await eventually(async () => assert.strictEqual(await q.evaluate(`document.querySelector('.ovo-card .ovo-name').textContent`), 'maticus'));// Reload: it comes back (this site's session).await q.goto(`${base}/b`, { maxMs: 5000 });assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'a reload brings it back');await q.evaluate(`OVOverlay.close('live'), true`);-- assert.strictEqual(await q.evaluate(`sessionStorage.getItem('ov-overlay')`), null, 'closed: nothing kept');++ await eventually(async () => assert.strictEqual(await q.evaluate(`sessionStorage.getItem('ov-overlay')`), null, 'closed: nothing kept'));// A source that is not first-party is never opened, from the hash or from a call.const evil = Buffer.from(JSON.stringify([{ id: 'x', src: 'https://evil.example/steal', title: 'x' }])).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');@@@ -96,15 +97,15 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) reawait e.goto(`${base}/c#ovo=${evil}`, { maxMs: 5000 });await until(e, `!!window.OVOverlay`);await wait(200);-- assert.strictEqual(await e.evaluate(`document.querySelectorAll('.ovo-card').length`), 0, 'a foreign source is refused');-- assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'javascript:alert(1)' })`), null);++ await eventually(async () => assert.strictEqual(await e.evaluate(`document.querySelectorAll('.ovo-card').length`), 0, 'a foreign source is refused'));++ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'javascript:alert(1)' })`), null));for (const host of ['portal--alex.openvibe.link', 'portal--alex.openvibe.website', 'nested.portal--alex.openvibe.link', 'unknown.openvibe.link']) {-- assert.strictEqual(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), null, host);-- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://${host}/')`), `https://${host}/`, 'no card carried to a share');++ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), null, host));++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://${host}/')`), `https://${host}/`, 'no card carried to a share'));}for (const host of ['openvibe.link', 'connect.openvibe.link']) assert.ok(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), host);-- assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'http://openvibe.live/embed/x' })`), null, 'plain http to another site is refused');-- assert.ok(await e.evaluate(`!!OVOverlay.safeSrc('https://openvibe.live/embed/maticus')`), "Live's embed is first-party");++ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'http://openvibe.live/embed/x' })`), null, 'plain http to another site is refused'));++ await eventually(async () => assert.ok(await e.evaluate(`!!OVOverlay.safeSrc('https://openvibe.live/embed/maticus')`), "Live's embed is first-party"));console.log('overlay-chrome: ok');} finally {await browser.close();nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const vm = require('vm');++const { parseHTML } = require('linkedom');++const { window, document } = parseHTML('<html><body><a data-ov-player-hls="https://media.test/master.m3u8">Watch</a></body></html>');++const calls = [], connection = { effectiveType: '4g', addEventListener() {}, removeEventListener() {} };++const bodies = {++ 'https://media.test/master.m3u8': '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=2000000\nhigh.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=300000\nlow.m3u8',++ 'https://media.test/low.m3u8': '#EXTM3U\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:4,\nfirst.m4s\n#EXTINF:4,\nsecond.m4s\n#EXT-X-ENDLIST',++ 'https://media.test/init.mp4': 'init', 'https://media.test/first.m4s': 'segment',++};++const context = { window, document, navigator: { connection }, URL, TextDecoder, Uint8Array, AbortController,++ console, setTimeout, clearTimeout, fetch: async (url) => { calls.push(url); return new Response(bodies[url] || '', {status:bodies[url] ? 200 : 404}); } };++vm.runInNewContext(fs.readFileSync(require.resolve('../player.js'), 'utf8'), context);++const P = window.OpenVibePlayer;++(async () => {++ for (const [type, expected] of [['slow-2g','none'],['2g','none'],['3g','metadata'],['4g','auto'],[undefined,'auto']])++ assert.strictEqual(P.playbackPolicy({effectiveType:type}).preload, expected);++ assert.strictEqual(P.playbackPolicy({saveData:true,effectiveType:'4g'}).preload,'none');++ const low = P.playbackPolicy({}, {level:0.1,charging:false});++ assert.strictEqual(low.hls.maxBufferLength,15); assert.strictEqual(low.hls.backBufferLength,0); assert.strictEqual(low.warm,false);++ assert.strictEqual(P.playbackPolicy({}, {level:0.1,charging:true}).hls.maxBufferLength,60);++ const video = document.createElement('video'); video.paused=true;++ class Hls {++ static isSupported() { return true; }++ static Events = { DESTROYING: 'destroy' };++ constructor(config) { this.config=config; this.events={}; this.starts=0; }++ on(e, fn) { this.events[e]=fn; }++ startLoad() { this.starts++; }++ loadSource(url) { this.url=url; }++ attachMedia(v) { this.video=v; }++ }++ connection.effectiveType='3g';++ const hls=P.attachHls(video,Hls,'https://media.test/master.m3u8',{enableWorker:true});++ assert.strictEqual(hls.config.autoStartLoad,false); assert.strictEqual(hls.config.startLevel,0);++ assert.strictEqual(hls.config.enableWorker,true); assert.strictEqual(hls.starts,0);++ video.dispatchEvent(new window.Event('play')); assert.strictEqual(hls.starts,1);++ hls.events.destroy(); video.dispatchEvent(new window.Event('play')); assert.strictEqual(hls.starts,1,'destroy removes play listener');++ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),false); assert.strictEqual(calls.length,0);++ connection.effectiveType='4g';++ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),true);++ assert.deepStrictEqual(calls,Object.keys(bodies).filter(u=>!u.endsWith('high.m3u8')));++ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),false,'deduplicated');++ assert.strictEqual(await P.warmHls('https://media.test/private.m3u8?sig=private'),false);++ assert.strictEqual(await P.warmHls('file:///x.m3u8'),false);++ bodies['https://media.test/live.m3u8']='#EXTM3U\n#EXTINF:4,\nfirst.m4s';++ assert.strictEqual(await P.warmHls('https://media.test/live.m3u8'),false,'live segments not prefetched');++ bodies['https://media.test/big.m3u8']='#EXTM3U\n#EXTINF:4,\nbig.m4s\n#EXT-X-ENDLIST';++ bodies['https://media.test/big.m4s']='x'.repeat(2*1024*1024+1);++ assert.strictEqual(await P.warmHls('https://media.test/big.m3u8'),false,'byte cap');++ bodies['https://media.test/range.m3u8']='#EXTM3U\n#EXT-X-MAP:URI="whole.mp4",BYTERANGE="100@0"\n#EXTINF:4,\nfirst.m4s\n#EXT-X-ENDLIST';++ assert.strictEqual(await P.warmHls('https://media.test/range.m3u8'),false,'ranged init never fetches a whole file');++ assert(!calls.includes('https://media.test/whole.mp4'));++ bodies['https://media.test/encrypted.m3u8']='#EXTM3U\n#EXT-X-SESSION-KEY:METHOD=AES-128,URI="key"\n#EXT-X-STREAM-INF:BANDWIDTH=300000\nlow.m3u8';++ const beforeEncrypted=calls.length;++ assert.strictEqual(await P.warmHls('https://media.test/encrypted.m3u8'),false);++ assert.strictEqual(calls.length,beforeEncrypted+1,'session encryption stops at the manifest');++ const card = document.querySelector('[data-ov-player-hls]');++ card.setAttribute('data-ov-player-hls','https://media.test/focus.m3u8');++ bodies['https://media.test/focus.m3u8']=bodies['https://media.test/low.m3u8'];++ const beforeFocus=calls.length;++ card.dispatchEvent(new window.Event('focusin'));++ card.dispatchEvent(new window.Event('focusout'));++ await new Promise(resolve=>setTimeout(resolve,150));++ assert.strictEqual(calls.length,beforeFocus,'leaving before intent delay cancels warming');++ card.dispatchEvent(new window.Event('focusin'));++ await new Promise(resolve=>setTimeout(resolve,150));++ assert(calls.includes('https://media.test/focus.m3u8'),'keyboard focus warms a card');++ card.dispatchEvent(new window.Event('focusout'));++ console.log('player startup: preload, battery, deferred HLS, low rung, public bounded warming and teardown passed');++})().catch(e=>{console.error(e);process.exitCode=1});@@@ -4,6 +4,7 @@// { state: 'dnd', show_status: true }) and the dot follows; a status line saves; Escape closes the picker; a second// tab within the minute does not beat again; presence: false loads nothing. Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -65,7 +66,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(state, 'online', `${width}px: the dot says online`);const beats = await p.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`);assert.strictEqual(beats, 1, 'one heartbeat on load');-- assert.strictEqual(await p.evaluate(`document.getElementById('openvibe-avatar-btn').title`), 'Online');++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.getElementById('openvibe-avatar-btn').title`), 'Online'));await p.evaluate(`OpenVibePresence.open()`);assert.ok(await until(p, `!!document.querySelector('.ovp-pop')`), `${width}px: the picker opens`);@@@ -77,7 +78,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(await until(p, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state === 'dnd' && d.dataset.state; })()`), 'dnd', `${width}px: Do not disturb shows`);const put = await p.evaluate(`window.__calls.find((c) => c.method === 'PUT')`);assert.deepStrictEqual(put.body, { state: 'dnd', show_status: true });-- assert.strictEqual(await p.evaluate(`document.querySelector('.ovp-opt[aria-checked="true"]').dataset.value`), 'dnd', 'the picker marks it');++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ovp-opt[aria-checked="true"]').dataset.value`), 'dnd', 'the picker marks it'));await p.evaluate(`(() => { const i = document.querySelector('.ovp-text input'); i.value = 'Building a world'; i.form.requestSubmit(); })()`);assert.ok(await until(p, `/Building a world/.test(document.getElementById('openvibe-avatar-btn').title)`), 'the status line saves and shows on hover');await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);@@@ -88,12 +89,12 @@ const server = http.createServer((req, res) => {const again = await h.openPage(browser, context, { width: 1280 });await again.goto(`${base}/on`, { maxMs: 5000 });assert.strictEqual(await until(again, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state; })()`), 'dnd');-- assert.strictEqual(await again.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`), 0, 'a second tab within the minute does not beat again');++ await eventually(async () => assert.strictEqual(await again.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`), 0, 'a second tab within the minute does not beat again'));await again.close();const off = await h.openPage(browser, context, { width: 1280 });await off.goto(`${base}/off`, { maxMs: 5000 });await wait(400);-- assert.strictEqual(await off.evaluate(`!!window.OpenVibePresence || !!document.getElementById('ov-presence-loader')`), false, 'presence: false loads nothing');++ await eventually(async () => assert.strictEqual(await off.evaluate(`!!window.OpenVibePresence || !!document.getElementById('ov-presence-loader')`), false, 'presence: false loads nothing'));await off.close();console.log('presence-chrome: the dot, one heartbeat a minute across tabs, the picker (sheet on a phone), Do not disturb, a status line, Escape; presence: false ok');} finally {@@@ -4,6 +4,7 @@// where more is hidden and a position bar under them; data-ovk-scroll="drift" moves on its own while in view and stops for// good at the first touch; a strip with nothing hidden shows neither. Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -62,7 +63,7 @@ const server = http.createServer((req, res) => {await p.evaluate("document.getElementById('drift').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");const c = await p.evaluate("document.getElementById('drift').scrollLeft");await wait(700);-- assert.strictEqual(await p.evaluate("document.getElementById('drift').scrollLeft"), c, 'and stops at the first touch');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('drift').scrollLeft"), c, 'and stops at the first touch'));console.log('rails-scroll-chrome: edge fades, the position bar, drift that stops at a touch, nothing on a short strip');} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }})().catch((e) => { console.error(e); process.exit(1); });@@@ -5,6 +5,7 @@// "all of OpenVibe" and "Ask Vibe"; arrows and Escape work; titles are text, never markup. Loading: a poster-backed// overlay that steps, slows with actions, fails with Retry and fades out; skeleton rows. Skipped without Chrome.const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -80,8 +81,8 @@ const server = http.createServer((req, res) => {await p.goto(`${base}/`, { maxMs: 5000 });await wait(200);// ── Search ──-- assert.strictEqual(await p.evaluate('!!document.getElementById("openvibe-search-btn")'), true, 'the magnifier is in the bar');-- assert.strictEqual(await p.evaluate('typeof window.OpenVibeSearch'), 'undefined', 'search.js is not loaded until asked');++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.getElementById("openvibe-search-btn")'), true, 'the magnifier is in the bar'));++ await eventually(async () => assert.strictEqual(await p.evaluate('typeof window.OpenVibeSearch'), 'undefined', 'search.js is not loaded until asked'));await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');// search.js loads on first use: wait for its box, not a fixed time (macOS runners took longer than 400 ms, 4.28.0's CI).for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);@@@ -100,12 +101,12 @@ const server = http.createServer((req, res) => {assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);-- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text'));await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); 1');-- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it'));await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);await wait(100);-- assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-mini .ovs-tile") && !document.querySelector("[data-ovs-fold=all]")'), 'every site is in the tab itself, compact, with no fold');++ await eventually(async () => assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-mini .ovs-tile") && !document.querySelector("[data-ovs-fold=all]")'), 'every site is in the tab itself, compact, with no fold'));// Typing: one request for the latest text (the earlier one cancelled), this site's results first.await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);await wait(1200);@@@ -138,22 +139,22 @@ const server = http.createServer((req, res) => {assert.ok(/^Searched OpenVibe in \d+ ms · the web through Wikipedia in /.test(v.route), `the route in words: ${v.route}`);assert.ok(asked.includes('query:network') && asked.includes('query:web'), 'two questions: OpenVibe quickly, the web on its own');await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'nigh'; i.dispatchEvent(new Event('input')); return 1; })()`);-- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-rtabs").hidden'), true, 'typing goes back to suggestions');++ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-rtabs").hidden'), true, 'typing goes back to suggestions'));await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'night'; i.dispatchEvent(new Event('input')); return 1; })()`);await wait(400);await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');-- assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move');++ await eventually(async () => assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move'));await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes');++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes'));// Back closes the menu and stays on the page (4.128.0, the owner 11-07: Back on a phone left the page).const before = await p.evaluate('location.href');await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');for (let i = 0; i < 30 && !(await p.evaluate('!!document.querySelector(".ovs.is-open")')); i++) await wait(100);-- assert.strictEqual(await p.evaluate('!!(history.state && history.state.ovsMenu)'), true, 'opening adds one history entry');++ await eventually(async () => assert.strictEqual(await p.evaluate('!!(history.state && history.state.ovsMenu)'), true, 'opening adds one history entry'));await p.evaluate('history.back(); 1');await wait(400);-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu');-- assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu'));++ await eventually(async () => assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page'));// ── The network's cards: a picture before the title, exactly as the entry sent it (Contracts 0.320.0) ──// The cards are the Feed tab's (4.149.0).@@@ -1,5 +1,6 @@'use strict';const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)const fs = require('fs');const os = require('os');const path = require('path');@@@ -34,15 +35,15 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));}const wasWorn = await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`);await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>/^(Equip|Unequip)$/.test(b.textContent)).click()`); await wait(150);-- assert.strictEqual(await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`), !wasWorn);++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`), !wasWorn));await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>b.textContent==='Buy Vibes').click()`);assert(await p.evaluate(`document.querySelector('.ov-wallet-checkout').innerText.includes('Continue to checkout')`));await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await wait(50);-- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false);-- assert.strictEqual(await p.evaluate(`document.documentElement.style.overflow`), '');++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false));++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.documentElement.style.overflow`), ''));await p.evaluate(`OpenVibeNavbar.openWallet()`); await wait(150);await p.evaluate(`OpenVibeNavbar.setUser(null,null)`);-- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false, 'signout closes private content');++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false, 'signout closes private content'));const exceptions = p.state.errors || [];assert.strictEqual(exceptions.length, 0, JSON.stringify(exceptions));await p.close(); await context.dispose();@@@ -309,65 +309,3 @@ html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }.ovt-composer > .ovt-c-note { position: absolute; left: 14px; right: 14px; top: calc(100% + 6px); margin: 0; text-align: center; font-size: 12px; line-height: 1.4; }.ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px)); }.ovt.is-start .ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: max(40px, env(safe-area-inset-bottom, 0px)); }----/* 4.201.0, the one composer (ov-composer.js; ask 349): files as chips above the box, a drop target, the slash and-- mention list, the microphone, the count, a compact box, and your settings sheet. */--.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }--.ovc-files[hidden] { display: none; }--.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;-- border: 1px solid var(--ovt-line); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }--.ovc-file.is-bad { border-color: color-mix(in srgb, var(--live-red, #ef4444) 70%, transparent); }--.ovc-file.is-up::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: calc(var(--p, 0) * 100%); background: var(--ovt-accent); transition: width .2s; }--.ovc-file-th { flex: none; width: 40px; height: 40px; border-radius: 10px; object-fit: cover; background: #0b1220; }--.ovc-file-ic { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-accent) 16%, transparent); color: var(--ovt-accent); font-size: 11px; font-weight: 750; letter-spacing: .02em; }--.ovc-file-t { min-width: 0; display: grid; line-height: 1.25; }--.ovc-file-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--text-primary, #e6edf7); }--.ovc-file-s { font-size: 12px; color: var(--text-muted, #7386a3); white-space: nowrap; }--.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }--.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }--.ovc-file-b { top: auto; bottom: 4px; }--.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }--.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }--.ovc-dropping { border-color: var(--ovt-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }--.ovc-dropping::before { content: "Drop to attach"; position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; border-radius: inherit; background: color-mix(in srgb, var(--bg-card, #131c2e) 86%, var(--ovt-accent)); color: var(--text-primary, #e6edf7); font-weight: 650; pointer-events: none; }--.ovc-pop { position: absolute; z-index: 40; left: 6px; right: 6px; bottom: calc(100% + 8px); max-height: min(320px, 50vh); overflow: auto; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line);-- background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }--.ovt.is-start .ovc-pop { bottom: auto; top: calc(100% + 8px); }--.ovc-pop[hidden] { display: none; }--.ovc-pop-i { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 6px 10px; box-sizing: border-box; border: 0; border-radius: 11px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }--.ovc-pop-i[aria-selected="true"], .ovc-pop-i:hover { background: color-mix(in srgb, var(--ovt-accent) 14%, transparent); }--.ovc-pop-ic, .ovc-pop-av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--ovt-accent); background: color-mix(in srgb, var(--ovt-accent) 12%, transparent); object-fit: cover; }--.ovc-pop-av { border-radius: 50%; }--.ovc-pop-ic svg { width: 17px; height: 17px; }--.ovc-pop-t { min-width: 0; display: grid; line-height: 1.3; }--.ovc-pop-t b { font-weight: 650; font-size: 14px; color: var(--text-primary, #e6edf7); }--.ovc-pop-t span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #7386a3); }--.ovc-pop-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: 999px; color: var(--ovt-accent); background: color-mix(in srgb, var(--ovt-accent) 12%, transparent); }--.ovc-mic[aria-pressed="true"] { color: var(--live-red, #ef4444); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); animation: ovc-listen 1.4s ease-in-out infinite; }--.ovc-count { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }--.ovc-count.is-low { color: var(--live-red, #ef4444); }--.ovc-count[hidden] { display: none; }--.ovt-composer.ovc-compact { padding: 6px 8px 6px 12px; border-radius: 18px; }--.ovt-composer.ovc-compact .ovt-c-row { margin-top: 0; }--.ovt-composer.ovc-compact .ovt-btn, .ovt-composer.ovc-compact .ovt-send { width: 36px; min-width: 36px; height: 36px; }--@keyframes ovc-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }--@keyframes ovc-listen { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 18%, transparent); } }--html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }--.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }--.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }--.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }--.ovc-set-in .ovc-set-f { padding: 0; }--.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }--.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }--.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }--.ovc-set-g legend { padding: 0 0 6px; font-weight: 650; font-size: 14px; }--.ovc-set label { display: flex; align-items: center; gap: 10px; min-height: 40px; font-size: 14.5px; cursor: pointer; }--.ovc-set small, .ovc-set-p { color: var(--text-muted, #7386a3); font-size: 12.5px; margin: 0; }--.ovc-set input[type=radio], .ovc-set input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }--.ovc-set-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }--.ovc-set-l li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 4px 4px 4px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }--.ovc-set-l span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #b4c0d4); font-size: 13px; }--.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }--.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }--.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }--@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }
@@ -1,5 +1,29 @@
@@ -1,5 +1,29 @@
# Changelog
# Changelog
+## 4.204.0 — 2026-11-11
+
+**Buttons that show their state, windows that resize, the one composer everywhere, players that start sooner, and one
+place for the network's CSP.**
+
+- **Buttons that show what they do and what is happening (`kit.css`, `kit.js`, `kit-web.js`).** Kinds by importance
+ (hero, soft, live beside primary, quiet, ghost, danger); a count badge, a status dot, a toggle's fill, a progress
+ fill; attention (the one next step glows now and then); busy → a check, or a shake and red
+ (`OpenVibeKitWeb.busy()`); hold-to-confirm for destructive buttons; icons that move by meaning. Every motion follows
+ the motion setting. docs/kit.md#buttons.
+- **A panel window can be resized (`ov-window.js`).** `resizable: true` adds a corner grip that a mouse, a finger or
+ a pen drags and the arrow keys move; the size is kept with the place per id.
+- **The composer carries its own look, and Vibe's Dock box is the same composer (`ov-composer.js`, `dock-vibe.js`).**
+ Its styles moved out of `thread.css` into the script; Vibe's box gets drafts, "/" commands, the marks and settings,
+ with `data-ovc-no-files` keeping its picture upload its own. (Was #458, never released.)
+- **Players start sooner (`player.js`, #454).** The poster stays until a frame is presented; preload follows the
+ connection; battery-aware HLS attachment with a low first rung. docs/player-startup.md.
+- **No more macOS flakes from fixed sleeps (tests).** A browser test now checks the page with `eventually()` from
+ browser-harness (it retries until the page is there, 5 s at most) instead of after a guessed sleep: 382 checks in 26
+ files, including menu-places-chrome, whose 180 ms sleep against a 140 ms debounce failed on the slow macOS runners
+ and held back 4.202.0 and 4.203.0. `test/fixed-waits.test.js` keeps the count of sleep-then-check places at zero.
+- **`openvibe-shared/csp` (#455).** One place for the network's Content-Security-Policy pieces; `connectSources()`
+ names Media, the composer's upload origin.
+
## 4.203.0 — 2026-11-11
## 4.203.0 — 2026-11-11
- Dock conversations update edited messages and link cards live; authors can edit their own messages.
- Dock conversations update edited messages and link cards live; authors can edit their own messages.
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.203.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.204.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.203.0",
+ "version": "4.204.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.147.0",
"sdk": "openvibe-sdk v0.147.0",
"features": [
"features": [
+ "buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)",
"the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",
"the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",
"the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)",
"the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)",
"the page-view beacon sends ?in=1 only for a signed-in browser and counts a guest at once, so a signed-in person is counted from presence and never also as a guest (4.191.0)",
"the page-view beacon sends ?in=1 only for a signed-in browser and counts a guest at once, so a signed-in person is counted from presence and never also as a guest (4.191.0)",
@@ -908,7 +908,23 @@ async function run(opts = {}) {
@@ -908,7 +908,23 @@ async function run(opts = {}) {
return report;
return report;
}
}
-module.exports = { textSmells,
+/**
+ * Retry an assertion until it passes (the browser tests' answer to a fixed wait before a check, 2026-11-11: the macOS
+ * runners are slow enough that a 140 ms debounce sometimes outlasted a 180 ms sleep, and Shared's releases kept failing
+ * on menu-places-chrome). check is an async function that throws while the page is not there yet; the last error is
+ * thrown after timeout ms. Waits are a ceiling, never a guess: a check that is already true returns at once.
+ */
+async function eventually(check, { timeout = 5000, every = 50 } = {}) {
+ const end = Date.now() + timeout;
+ let last;
+ for (;;) {
+ try { return await check(); } catch (e) { last = e; }
+ if (Date.now() >= end) throw last;
+ await new Promise((r) => setTimeout(r, every));
+ }
+}
+
+module.exports = { textSmells, eventually,
run, launch, openPage, format, summarize, findChrome, axeSource, checkUnreachable,
run, launch, openPage, format, summarize, findChrome, axeSource, checkUnreachable,
// pure helpers, exported for tests and for the Host CLI
// pure helpers, exported for tests and for the Host CLI
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * openvibe-shared/csp — the network's Content-Security-Policy pieces, in one place.
+ *
+ * Every OpenVibe site builds a CSP header. Before this module each built its own string, so the composer's upload to
+ * OpenVibe.Media was blocked on any site that had not named `https://openvibe.media` in connect-src (the browser sends
+ * the multipart POST there, and a CSP refusal is a network error with no status). A site that uses these helpers gets
+ * Network, Events and Media in connect-src by default; a site that needs more (its own websocket, Cloudflare's beacon)
+ * adds it with `sites` or `extra`.
+ *
+ * const csp = require('openvibe-shared/csp');
+ * res.setHeader('Content-Security-Policy', csp.policy({ nonce, sites: ['wss://openvibe.chat'], frames: ['https://openvibe.bot'] }));
+ *
+ * `connectSources()` is the list on its own, for a site that keeps its own policy but wants the network's origins.
+ */
+const NETWORK = 'https://openvibe.network';
+const EVENTS = 'https://openvibe.events';
+const MEDIA = 'https://openvibe.media';
+
+const uniq = (list) => [...new Set(list.filter(Boolean).map(String))];
+
+/**
+ * The connect-src value: this site, the Network (sign-in, the Frame, settings, mentions), Events (release and
+ * notification streams) and Media (composer attachments). `sites` and `extra` add more.
+ */
+function connectSources({ self = true, sites = [], extra = [] } = {}) {
+ return uniq([self ? "'self'" : null, NETWORK, `wss://${NETWORK.slice(8)}`, EVENTS, `wss://${EVENTS.slice(8)}`, MEDIA, ...sites, ...extra]).join(' ');
+}
+
+/**
+ * A full policy. Defaults suit a server-rendered site that runs only its own scripts, with inline styles and pictures
+ * and media from anywhere over https.
+ *
+ * nonce the response's script nonce (inline scripts need it)
+ * script extra script origins
+ * style extra style origins
+ * images the img-src value (default https:)
+ * media the media-src value (default https:)
+ * frames frame-src entries (default 'self')
+ * formAction the form-action value (default 'self' https://openvibe.network)
+ * sites extra connect-src origins
+ * connect a full connect-src value, instead of connectSources()
+ * connectExtra extra connect-src entries
+ * objectSrc object-src (default 'none')
+ * baseUri base-uri (default 'self')
+ * frameAncestors frame-ancestors (default 'self')
+ * extra { directive: value } appended (e.g. { 'worker-src': "'self' blob:" })
+ */
+function policy({ nonce, script = [], style = [], images = 'https:', media = 'https:', frames = ["'self'"], formAction = `'self' ${NETWORK}`, connect, sites = [], connectExtra = [], objectSrc = "'none'", baseUri = "'self'", frameAncestors = "'self'", extra = {} } = {}) {
+ const directives = [
+ ["default-src", "'self'"],
+ ["script-src", ["'self'", nonce ? `'nonce-${nonce}'` : null, ...script].filter(Boolean).join(' ')],
+ ["style-src", ["'self'", "'unsafe-inline'", ...style].filter(Boolean).join(' ')],
+ ["img-src", images],
+ ["media-src", media],
+ ["font-src", "'self' data:"],
+ ["connect-src", connect || connectSources({ sites, extra: connectExtra })],
+ ["frame-src", frames.join(' ')],
+ ["frame-ancestors", frameAncestors],
+ ["base-uri", baseUri],
+ ["object-src", objectSrc],
+ ["form-action", formAction],
+ ];
+ const seen = new Set(directives.map(([d]) => d));
+ for (const [d, v] of Object.entries(extra)) if (!seen.has(d) && v != null) directives.push([d, String(v)]);
+ return directives.map(([d, v]) => `${d} ${v}`).join('; ');
+}
+
+module.exports = { NETWORK, EVENTS, MEDIA, MEDIA_UPLOAD_ORIGIN: MEDIA, connectSources, policy };
@@ -55,6 +55,21 @@
@@ -55,6 +55,21 @@
.ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); }
.ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); }
`;
`;
let ctx = null, G = {};
let ctx = null, G = {};
+ // 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
+ // drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
+ const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
+ const VIBE_REV = { 'ov-composer.js': 'f52e2323e6e9' };
+ function joinComposer(form) {
+ if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
+ form.setAttribute('data-ovc-no-files', '');
+ const go = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch (e) { console.error(e); } };
+ if (root.OpenVibeComposer) { go(); return; }
+ const m = /^(https?:\/\/[^?#]*\/)dock-vibe(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
+ if (!m) return;
+ let sc = document.getElementById('ov-composer-script');
+ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js' + (VIBE_REV['ov-composer.js'] ? '?v=' + VIBE_REV['ov-composer.js'] : ''); document.head.appendChild(sc); }
+ sc.addEventListener('load', go, { once: true });
+ }
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
/** A round icon button with its name as label and tooltip. */
/** A round icon button with its name as label and tooltip. */
const iconButton = (tag, cls, path, label) => { const b = h(tag, cls); if (tag === 'button') b.type = 'button'; b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); b.title = label; b.innerHTML = svg(path); return b; };
const iconButton = (tag, cls, path, label) => { const b = h(tag, cls); if (tag === 'button') b.type = 'button'; b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); b.title = label; b.innerHTML = svg(path); return b; };
@@ -249,6 +264,7 @@
@@ -249,6 +264,7 @@
} finally { vibe.busy = false; go.disabled = false; box.focus(); }
} finally { vibe.busy = false; go.disabled = false; box.focus(); }
}
}
form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });
form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });
+ joinComposer(form);
async function pull(first) {
async function pull(first) {
let got;
let got;
@@ -89,7 +89,7 @@
@@ -89,7 +89,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '1bfa19b01e8e', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
+ const DOCK_REV = { 'dock-vibe.js': '81365d0289bb', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -362,6 +362,37 @@ kit.tool({
@@ -362,6 +362,37 @@ kit.tool({
The frame is part of Kit 2: its look is in `kit-data.css` and its behaviour (the clear button and the examples) in
The frame is part of Kit 2: its look is in `kit-data.css` and its behaviour (the clear button and the examples) in
`kit-data-web.js`. A page that draws no tool loads neither, so `kit.css` and `kit-web.js` weigh what they did.
`kit-data-web.js`. A page that draws no tool loads neither, so `kit.css` and `kit-web.js` weigh what they did.
+## Buttons (4.204.0)
+
+A button says what it does and shows what is happening. One look on every site; every motion follows the motion
+setting (Calm makes it smaller and slower, Off removes it).
+
+| Kind | When | Look |
+|---|---|---|
+| `hero` | the one thing the page is for (Go live, Start) | 52 px, the accent gradient, a glow |
+| `primary` | the main action of a section | filled accent |
+| `soft` | a second choice that still matters (Invite, Finish setup) | tinted accent |
+| `quiet` | everything else | panel with a line |
+| `ghost` | a link-like action inside text or a row | accent words only |
+| `danger` | destructive; with `hold` it asks for a press and hold | red line |
+| `live` | on air now | red, a breathing dot |
+
+Status, on any kind: `badge` (a count at the corner, 99+ at most), `dot` (`ok`, `warn`, `bad`, `live`; live
+breathes), `pressed` (a toggle: aria-pressed, tinted), `attention` (the one next step: a soft ring goes out now and
+then until it is pressed; one per screen), `nudge` (the icon moves by meaning on hover: `forward`, `back`, `add`,
+`open`, `down`, `refresh`), `iconEnd` (an icon after the words), and `style="--ovk-progress: .4"` (a fill inside it).
+
+```js
+kit.button({ label: 'Go live', icon: 'live', kind: 'hero', nudge: 'forward', iconEnd: 'chevron' })
+kit.button({ label: 'Messages', icon: 'chat', kind: 'quiet', badge: 3, type: 'button' })
+kit.button({ label: 'Delete', kind: 'danger', hold: 1200, type: 'button' })
+await OpenVibeKitWeb.busy(button, fetch('/api/save', { method: 'POST' })) // busy → a check, or a shake and red
+```
+
+`busy(button, work)` keeps the words, adds the mark's wait beside them and aria-busy, and disables the button; when the
+work resolves the button turns green with a check for a moment, and when it fails it shakes once and turns red. A
+`hold` button fires its click only after the hold has run (pointer, Enter or Space); letting go early cancels.
+
## The navbar's links
## The navbar's links
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+# Player starts — ask 288
+
+Measured 2026-11-10 UTC with system Chrome and fresh Playwright contexts, browser cache cold. No Live stream was online (`/api/streams`: `{"streams":[],"agents":[]}`). Live `/vod/6298` redirects to Video `/watch?v=sU4LkcV7PpM`; these are two entry paths to the same 341-second ROBOT recording, not independent encodes. Its joined HLS object is `med_01M729VRTW6E3NPTTM008H0GAA`.
+
+Phone: 1.6 Mbit/s down, 150 ms latency, 4x CPU slowdown, 390px. Computer: 10 Mbit/s down, 40 ms latency, native CPU, 1280px. Upload 0.75 Mbit/s. TTFF starts at the explicit muted play request (after the video element appears), and ends at requestVideoFrameCallback. Rebuffer ratio is waiting time divided by the following 20 seconds of wall time. Seek tests start from 220 seconds in a fresh context. One sample per entry/profile/position; these are diagnostic samples, not statistical estimates. The shared test host was busy (load 22–26 on 8 CPUs); reruns need the same caveat. Page loading and automatic preloading may precede the play request.
+
+## Before
+
+| Entry | Profile | Position | TTFF, s | Rebuffer | Observed tier |
+|---|---|---:|---:|---:|---|
+| live-recording | phone | 0s | 12.451 | 16.01% | B2 via Media |
+| live-recording | phone | 220s | 15.126 | 4.14% | B2 via Media |
+| live-recording | computer | 0s | 2.877 | 0.00% | B2 via Media |
+| live-recording | computer | 220s | 3.848 | 0.00% | B2 via Media |
+| video-recording | phone | 0s | 12.123 | 16.44% | B2 via Media |
+| video-recording | phone | 220s | 14.914 | 5.97% | B2 via Media |
+| video-recording | computer | 0s | 2.597 | 0.00% | B2 via Media |
+| video-recording | computer | 220s | 3.923 | 0.00% | B2 via Media |
+
+Both opening and later segments redirect to `s3.us-west-004.backblazeb2.com`; Media may proxy packed chunks. No R2 response was observed. The part-source master offers only 720p (BANDWIDTH 3403963, average 2383603): greater than the phone throughput, so buffer changes cannot make this source sustainable on that connection. A low rung must exist before it can be selected.
+
+Evidence: `player-starts-evidence/measure.cjs`, `before.json`. The script saves hostnames only, never presigned URL queries. Production source URLs and the public page are used directly; no database writes or credentials.
+
+## After
+
+Pending Shared review and Media deployment. Do not treat a local Shared override as a production rollout.
+
+## Next for the design
+
+No layout or control changes. Cards adopting Shared warming need a public HLS source attribute; the main session owns their markup and appearance.
+
+## Shared candidate, local script override against production media
+
+The browser replaced only Shared player.js and the consumer HLS constructor call with this branch. This is not deployed. Chrome used native HLS in these samples, so these numbers do not isolate hls.js buffer tuning or card warming. No consistent rebuffer improvement can be inferred from these single samples, especially under the changing host load (22–47).
+
+| Entry | Profile | Position | TTFF, s | Rebuffer | Tier |
+|---|---|---:|---:|---:|---|
+| live-recording | phone | 0s | 8.976 | 22.48% | B2 |
+| live-recording | phone | 220s | 17.388 | 3.43% | B2 |
+| live-recording | computer | 0s | 2.771 | 0.00% | B2 |
+| live-recording | computer | 220s | 5.357 | 0.00% | B2 |
+| video-recording | phone | 0s | 10.983 | 4.88% | B2 |
+| video-recording | phone | 220s | 19.946 | 0.97% | B2 |
+| video-recording | computer | 0s | 3.791 | 0.00% | B2 |
+| video-recording | computer | 220s | 5.541 | 0.00% | B2 |
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+# Connection-aware player startup
+
+`player.js` keeps the poster until a decoded frame is presented, and applies preload on enhancement: `none` for Save-Data/2g/slow-2g, `metadata` for 3g, `auto` otherwise. Set the same preload before assigning a source when creating a video dynamically: enhancement cannot undo bytes the HTML parser already requested. The Battery API is optional; its absence uses the connection policy.
+
+Pages that attach HLS use the same policy with their existing Hls constructor and error handling:
+
+```js
+const hls = OpenVibePlayer.attachHls(video, Hls, playlist, { enableWorker: true });
+// Keep the page's Hls.Events.ERROR fallback and destroy hls on navigation.
+```
+
+Native HLS uses the video's preload; hls.js loads the manifest but defers segments until play on constrained connections. The first level is 0 (hls.js orders the ladder by bitrate); automatic ABR then chooses subsequent levels. No rendition is invented if the source only has one. Forward/back buffers are 60/30 seconds normally, 30/15 on 3g, 30/0 on 2g or Save-Data, and 15/0 on an unplugged battery at 20% or lower. The hard forward limits are 120/60/30 seconds. Battery and connection changes update the attachment; destroy removes listeners.
+
+Cards opt in using a **public**, unsigned VOD HLS source:
+
+```html
+<a href="/watch?v=…" data-ov-player-hls="https://openvibe.media/o/…/master.m3u8">Watch</a>
+```
+
+Call `OpenVibePlayer.warmCards(container)` for cards added later. Hover or keyboard focus for 120 ms warms the lowest playlist, its init and first media segment into HTTP cache. It stops on leave/blur, avoids live, encrypted, signed and byte-range playlists, and makes no requests on Save-Data/2g/3g or low battery. Each card is capped at 2 MiB and each document at 20 starts per minute. Oversized first segments are cancelled; warming is best effort, never a prerequisite for playback. HTTP cache reuse depends on the source's cache/CORS headers and browser partitioning.
+
+These helpers do not replace a consumer's existing HLS constructor automatically. Consumer adoption and release pins are necessary to get the buffer policy. Next for the design: use the source attribute on existing cards; keep their appearance and controls with the main session.
@@ -18,6 +18,10 @@
@@ -18,6 +18,10 @@
* and a page move close it (document-level, so boost-swapped pages need no wiring)
* and a page move close it (document-level, so boost-swapped pages need no wiring)
* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
* under reduced motion; the file loads only then)
* under reduced motion; the file loads only then)
+ * hold .ovk-btn[data-hold="1200"]: press and hold to confirm (the fill runs; letting go early cancels; Enter or
+ * Space held works the same); the click goes through only after the hold
+ * busy(button, work) shows the work on the button: busy (the words stay, the wait beside them, aria-busy), then a
+ * check for a moment, or a shake and red when it failed; work is a promise or a function returning one
*
*
* Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
* Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
*/
*/
@@ -291,7 +295,68 @@
@@ -291,7 +295,68 @@
}
}
- const api = { enhance };
+ /** The work a button started, shown on the button. Resolves with the work's value; rethrows its error. */
+ async function busy(btn, work) {
+ if (!btn) return typeof work === 'function' ? work() : work;
+ const wait = document.createElement('span');
+ wait.className = 'ovk-btn-wait'; wait.setAttribute('aria-hidden', 'true'); wait.innerHTML = '<i></i><i></i><i></i>';
+ const wasDisabled = btn.disabled;
+ btn.dataset.state = 'busy'; btn.setAttribute('aria-busy', 'true'); btn.appendChild(wait);
+ if ('disabled' in btn) btn.disabled = true;
+ const settle = (state) => {
+ wait.remove(); btn.removeAttribute('aria-busy');
+ if ('disabled' in btn) btn.disabled = wasDisabled;
+ btn.dataset.state = state;
+ setTimeout(() => { if (btn.dataset.state === state) delete btn.dataset.state; }, state === 'done' ? 1200 : 1600);
+ };
+ try {
+ const out = await (typeof work === 'function' ? work() : work);
+ settle('done');
+ return out;
+ } catch (e) { settle('error'); throw e; }
+ }
+
+ // Hold to confirm: document-level, so buttons added later need no wiring.
+ function bindHold() {
+ if (typeof document === 'undefined' || root.__ovkHoldBound) return;
+ root.__ovkHoldBound = true;
+ let held = null;
+ const stop = (fire) => {
+ if (!held) return;
+ const { btn, raf, ok } = held; held = null;
+ cancelAnimationFrame(raf); btn.classList.remove('is-holding'); btn.style.removeProperty('--ovk-hold');
+ if (fire && ok) { btn.dataset.ovkHeld = '1'; btn.click(); }
+ };
+ const start = (btn) => {
+ const ms = Math.max(400, Math.min(4000, Number(btn.getAttribute('data-hold')) || 1200));
+ const t0 = performance.now();
+ held = { btn, raf: 0, ok: false };
+ btn.classList.add('is-holding');
+ const tick = (t) => {
+ if (!held || held.btn !== btn) return;
+ const f = Math.min(1, (t - t0) / ms);
+ btn.style.setProperty('--ovk-hold', `${Math.round(f * 100)}%`);
+ if (f >= 1) { held.ok = true; stop(true); return; }
+ held.raf = requestAnimationFrame(tick);
+ };
+ held.raf = requestAnimationFrame(tick);
+ };
+ document.addEventListener('pointerdown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !b.disabled && e.button === 0) start(b); });
+ document.addEventListener('pointerup', () => stop(false));
+ document.addEventListener('pointercancel', () => stop(false));
+ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !e.repeat && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); start(b); } });
+ document.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') stop(false); });
+ // A plain click on a hold button does nothing until the hold has run.
+ document.addEventListener('click', (e) => {
+ const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]');
+ if (!b) return;
+ if (b.dataset.ovkHeld === '1') { delete b.dataset.ovkHeld; return; }
+ e.preventDefault(); e.stopImmediatePropagation();
+ }, true);
+ }
+ bindHold();
+
+ const api = { enhance, busy };
root.OpenVibeKitWeb = api;
root.OpenVibeKitWeb = api;
if (typeof module === 'object' && module.exports) { module.exports = api; return; }
if (typeof module === 'object' && module.exports) { module.exports = api; return; }
if (typeof document !== 'undefined') {
if (typeof document !== 'undefined') {
@@ -118,6 +118,83 @@ html[data-ov-motion=reduced] { --ov-move: .35; --ov-dur-1: .15s; --ov-dur-2: .25
@@ -118,6 +118,83 @@ html[data-ov-motion=reduced] { --ov-move: .35; --ov-dur-1: .15s; --ov-dur-2: .25
.ovk-icon-btn[hidden] { display: none; }
.ovk-icon-btn[hidden] { display: none; }
.ovk-icon-btn.is-done { color: var(--ovk-ok-text); }
.ovk-icon-btn.is-done { color: var(--ovk-ok-text); }
.ovk-icon-btn--danger:hover { color: var(--ovk-bad-text); }
.ovk-icon-btn--danger:hover { color: var(--ovk-bad-text); }
+
+/* ── Buttons that show what they do and what is happening (4.204.0) ───────────────────────────────────────────────
+ The owner, 2026-11-11: "improve our buttons across the site and add better animations and effects for them, and
+ different things we can use them for, and little indicators or statuses … idle animation … more important buttons …
+ more intuitive and more modular … shared across everything through openvibe.design". docs/kit.md#buttons.
+ Every motion is scaled by --ov-move (Calm makes it smaller and slower, Off removes it; the durations follow). */
+.ovk-btn, .ovk-icon-btn { position: relative; -webkit-tap-highlight-color: transparent;
+ transition: transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease), box-shadow var(--ov-dur-2, .2s) var(--ov-ease-out, ease),
+ background-color var(--ov-dur-1, .12s), border-color var(--ov-dur-1, .12s), color var(--ov-dur-1, .12s), filter var(--ov-dur-1, .12s); }
+.ovk-btn:hover:not([disabled]):not([aria-disabled="true"]) { transform: translateY(calc(-1px * var(--ov-move, 1))); box-shadow: 0 8px 18px -12px rgba(0, 0, 0, .6); }
+.ovk-btn:active:not([disabled]), .ovk-icon-btn:active:not([disabled]) { transform: scale(calc(1 - .03 * var(--ov-move, 1))); box-shadow: none; transition-duration: .06s; }
+.ovk-btn .ovk-i, .ovk-icon-btn .ovk-i { transition: transform var(--ov-dur-2, .2s) var(--ov-ease-spring, var(--ov-ease-out, ease)); }
+/* The icon moves the way the button does (data-nudge): forward slides on, add turns, open lifts, down drops, refresh turns. */
+.ovk-btn[data-nudge="forward"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="forward"]:hover .ovk-i { transform: translateX(calc(3px * var(--ov-move, 1))); }
+.ovk-btn[data-nudge="back"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="back"]:hover .ovk-i { transform: translateX(calc(-3px * var(--ov-move, 1))); }
+.ovk-btn[data-nudge="add"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="add"]:hover .ovk-i { transform: rotate(calc(90deg * var(--ov-move, 1))); }
+.ovk-btn[data-nudge="open"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="open"]:hover .ovk-i { transform: translate(calc(2px * var(--ov-move, 1)), calc(-2px * var(--ov-move, 1))); }
+.ovk-btn[data-nudge="down"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="down"]:hover .ovk-i { transform: translateY(calc(2px * var(--ov-move, 1))); }
+.ovk-btn[data-nudge="refresh"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="refresh"]:hover .ovk-i { transform: rotate(calc(180deg * var(--ov-move, 1))); }
+
+/* Importance: hero (the one thing a page is for), soft (a tinted second choice), live (on air now). */
+.ovk-btn--hero { min-height: 52px; padding: 12px 24px; font-size: 1.02rem; color: var(--ovk-on-fill); border: 0;
+ background: linear-gradient(135deg, var(--ovk-accent), var(--ovk-fill)); box-shadow: 0 10px 26px -14px var(--ovk-fill); }
+.ovk-btn--hero:hover:not([disabled]) { box-shadow: 0 14px 30px -12px var(--ovk-fill); filter: brightness(1.06); }
+.ovk-btn--soft { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); border-color: color-mix(in srgb, var(--ovk-accent) 22%, transparent); }
+.ovk-btn--soft:hover { background: color-mix(in srgb, var(--ovk-accent) 20%, transparent); }
+.ovk-btn--live { color: #fff; background: var(--ovk-live); }
+.ovk-btn--live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #fff; flex: none; animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
+@keyframes ovkBreathe { 50% { opacity: calc(1 - .65 * var(--ov-move, 1)); transform: scale(calc(1 - .2 * var(--ov-move, 1))); } }
+
+/* A toggle says it is on (aria-pressed): filled tint and a check-weight border. */
+.ovk-btn[aria-pressed="true"], .ovk-icon-btn[aria-pressed="true"] { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 16%, transparent); border-color: color-mix(in srgb, var(--ovk-accent) 45%, transparent); }
+
+/* The one next step (data-attention): a soft ring goes out from it now and then, until it is pressed. */
+.ovk-btn[data-attention]:not([disabled])::after, .ovk-icon-btn[data-attention]:not([disabled])::after {
+ content: ""; position: absolute; inset: -1px; border-radius: inherit; pointer-events: none;
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovk-accent) 55%, transparent); animation: ovkAttention 3.2s var(--ov-ease-out, ease-out) infinite; }
+@keyframes ovkAttention { 0%, 55% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovk-accent) 55%, transparent); } 85%, 100% { box-shadow: 0 0 0 calc(10px * var(--ov-move, 1)) transparent; } }
+
+/* A count (data-badge="3") and a status dot (data-dot="ok|warn|bad|live") at the top right. */
+.ovk-btn[data-badge]:not([data-badge=""])::before, .ovk-icon-btn[data-badge]:not([data-badge=""])::before {
+ content: attr(data-badge); position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box;
+ border-radius: 999px; background: var(--ovk-bad); color: #fff; font-size: 11px; font-weight: 800; line-height: 18px; text-align: center;
+ box-shadow: 0 0 0 2px var(--ovk-bg, var(--bg-primary, #0b0f19)); animation: ovkPop var(--ov-dur-2, .2s) var(--ov-ease-spring, ease-out); }
+.ovk-btn--live[data-badge]::before { animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
+.ovk-btn[data-dot], .ovk-icon-btn[data-dot] { --ovk-dot: var(--ovk-muted); }
+.ovk-btn[data-dot="ok"], .ovk-icon-btn[data-dot="ok"] { --ovk-dot: var(--ovk-ok); }
+.ovk-btn[data-dot="warn"], .ovk-icon-btn[data-dot="warn"] { --ovk-dot: var(--warning, #f59e0b); }
+.ovk-btn[data-dot="bad"], .ovk-icon-btn[data-dot="bad"], .ovk-btn[data-dot="live"], .ovk-icon-btn[data-dot="live"] { --ovk-dot: var(--ovk-bad); }
+.ovk-btn[data-dot]:not([data-badge])::before, .ovk-icon-btn[data-dot]:not([data-badge])::before {
+ content: ""; position: absolute; top: 4px; right: 4px; width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-dot);
+ box-shadow: 0 0 0 2px var(--ovk-bg, var(--bg-primary, #0b0f19)); }
+.ovk-btn[data-dot="live"]:not([data-badge])::before, .ovk-icon-btn[data-dot="live"]:not([data-badge])::before { animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
+@keyframes ovkPop { from { transform: scale(calc(1 - .6 * var(--ov-move, 1))); opacity: 0; } }
+
+/* Progress inside the button (style="--ovk-progress: .4"): an upload, a countdown, a hold. */
+.ovk-btn[style*="--ovk-progress"] { background-image: linear-gradient(90deg, color-mix(in srgb, currentColor 18%, transparent) calc(var(--ovk-progress, 0) * 100%), transparent 0); }
+
+/* What is happening (data-state, set by OpenVibeKitWeb.busy): busy keeps the words and adds the mark's wait; done shows
+ a check for a moment; error shakes once and turns red. */
+.ovk-btn[data-state="busy"] { cursor: progress; pointer-events: none; }
+.ovk-btn[data-state="busy"] > :not(.ovk-btn-wait) { opacity: .62; }
+.ovk-btn-wait { display: inline-flex; gap: 3px; align-items: center; }
+.ovk-btn-wait i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: ovkWait 1.1s var(--ov-ease-in-out, ease-in-out) infinite; }
+.ovk-btn-wait i:nth-child(2) { animation-delay: .15s; } .ovk-btn-wait i:nth-child(3) { animation-delay: .3s; }
+@keyframes ovkWait { 0%, 80%, 100% { opacity: .25; transform: translateY(0); } 40% { opacity: 1; transform: translateY(calc(-3px * var(--ov-move, 1))); } }
+.ovk-btn[data-state="done"] { color: #fff; background: var(--ovk-ok); border-color: transparent; }
+.ovk-btn[data-state="done"]::after { content: ""; flex: none; width: 16px; height: 16px; background: currentColor; animation: ovkPop var(--ov-dur-2, .2s) var(--ov-ease-spring, ease-out);
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E") center / contain no-repeat;
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E") center / contain no-repeat; }
+.ovk-btn[data-state="error"] { color: var(--ovk-bad-text); border-color: var(--ovk-bad); background: color-mix(in srgb, var(--ovk-bad) 10%, transparent); animation: ovkShake .36s var(--ov-ease-out, ease-out); }
+@keyframes ovkShake { 25% { transform: translateX(calc(-4px * var(--ov-move, 1))); } 50% { transform: translateX(calc(4px * var(--ov-move, 1))); } 75% { transform: translateX(calc(-2px * var(--ov-move, 1))); } }
+
+/* Hold to confirm (data-hold="1200" on a destructive button): the fill runs while it is held; letting go early cancels. */
+.ovk-btn[data-hold] { background-image: linear-gradient(90deg, color-mix(in srgb, var(--ovk-bad) 26%, transparent) var(--ovk-hold, 0%), transparent 0); }
+.ovk-btn[data-hold].is-holding { transition: none; }
+@media (hover: none) { .ovk-btn:hover:not([disabled]):not([aria-disabled="true"]) { transform: none; box-shadow: none; } }
.ovk-icon-btn .ovk-i { width: 20px; height: 20px; }
.ovk-icon-btn .ovk-i { width: 20px; height: 20px; }
@media (pointer: coarse) { .ovk-icon-btn { width: var(--ovk-tap); height: var(--ovk-tap); } }
@media (pointer: coarse) { .ovk-icon-btn { width: var(--ovk-tap); height: var(--ovk-tap); } }
.ovk-badge { display: inline-flex; align-items: center; flex: none; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ovk-line); font-size: .75rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }
.ovk-badge { display: inline-flex; align-items: center; flex: none; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ovk-line); font-size: .75rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }
@@ -158,9 +158,22 @@
@@ -158,9 +158,22 @@
// ── Controls ──
// ── Controls ──
/** A button or a link that looks like one. kind: 'primary' (default), 'quiet', 'danger', 'ghost'; small: a pill. */
/** A button or a link that looks like one. kind: 'primary' (default), 'quiet', 'danger', 'ghost'; small: a pill. */
- function button({ href = null, type = 'submit', label = '', icon: ic = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null } = {}) {
+ // 4.204.0, buttons that show what they do and what is happening (docs/kit.md#buttons): kind 'hero' | 'soft' | 'live'
+ // beside primary/quiet/ghost/danger; nudge (forward, back, add, open, down, refresh) moves the icon by meaning;
+ // iconEnd puts it after the words; attention marks the one next step; badge a count; dot ok|warn|bad|live; hold
+ // (ms) makes a destructive button hold-to-confirm; pressed makes it a toggle.
+ function button({ href = null, type = 'submit', label = '', icon: ic = '', iconEnd = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null,
+ nudge = null, attention = false, badge = null, dot = null, hold = null, pressed = null } = {}) {
const cls = `ovk-btn ovk-btn--${esc(kind)}${small ? ' ovk-btn--small' : ''}`;
const cls = `ovk-btn ovk-btn--${esc(kind)}${small ? ' ovk-btn--small' : ''}`;
- const inner = `${ic ? icon(ic) : ''}<span>${esc(label)}</span>`;
+ const more = { ...(extra || {}) };
+ if (nudge) more['data-nudge'] = nudge;
+ if (attention) more['data-attention'] = '';
+ if (badge != null && badge !== '' && badge !== 0) more['data-badge'] = Number(badge) > 99 ? '99+' : String(badge);
+ if (dot) more['data-dot'] = dot;
+ if (hold) more['data-hold'] = String(Math.max(400, Math.min(4000, Number(hold) || 1200)));
+ if (pressed != null) more['aria-pressed'] = pressed ? 'true' : 'false';
+ extra = Object.keys(more).length ? more : null;
+ const inner = `${ic ? icon(ic) : ''}<span>${esc(label)}</span>${iconEnd ? icon(iconEnd) : ''}`;
if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;
if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;
return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;
return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;
}
}
@@ -607,7 +607,7 @@
@@ -607,7 +607,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '1e63c27fc63c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'cca1c50e1cce', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '31d1cac08de5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -26,10 +26,72 @@
@@ -26,10 +26,72 @@
'use strict';
'use strict';
if (root.OpenVibeComposer || typeof document === 'undefined') return;
if (root.OpenVibeComposer || typeof document === 'undefined') return;
+ // 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's
+ // Vibe and Code boxes included. Injected once, before the first box is joined.
+ const CSS = `.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
+.ovc-files[hidden] { display: none; }
+.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
+ border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
+.ovc-file.is-bad { border-color: color-mix(in srgb, var(--live-red, #ef4444) 70%, transparent); }
+.ovc-file.is-up::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: calc(var(--p, 0) * 100%); background: var(--ovt-accent, var(--accent, #3b82f6)); transition: width .2s; }
+.ovc-file-th { flex: none; width: 40px; height: 40px; border-radius: 10px; object-fit: cover; background: #0b1220; }
+.ovc-file-ic { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); color: var(--ovt-accent, var(--accent, #3b82f6)); font-size: 11px; font-weight: 750; letter-spacing: .02em; }
+.ovc-file-t { min-width: 0; display: grid; line-height: 1.25; }
+.ovc-file-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--text-primary, #e6edf7); }
+.ovc-file-s { font-size: 12px; color: var(--text-muted, #7386a3); white-space: nowrap; }
+.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }
+.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }
+.ovc-file-b { top: auto; bottom: 4px; }
+.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }
+.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }
+.ovc-dropping { border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }
+.ovc-dropping::before { content: "Drop to attach"; position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; border-radius: inherit; background: color-mix(in srgb, var(--bg-card, #131c2e) 86%, var(--ovt-accent, var(--accent, #3b82f6))); color: var(--text-primary, #e6edf7); font-weight: 650; pointer-events: none; }
+.ovc-pop { position: absolute; z-index: 40; left: 6px; right: 6px; bottom: calc(100% + 8px); max-height: min(320px, 50vh); overflow: auto; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18)));
+ background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
+.ovt.is-start .ovc-pop { bottom: auto; top: calc(100% + 8px); }
+.ovc-pop[hidden] { display: none; }
+.ovc-pop-i { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 6px 10px; box-sizing: border-box; border: 0; border-radius: 11px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
+.ovc-pop-i[aria-selected="true"], .ovc-pop-i:hover { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
+.ovc-pop-ic, .ovc-pop-av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); object-fit: cover; }
+.ovc-pop-av { border-radius: 50%; }
+.ovc-pop-ic svg { width: 17px; height: 17px; }
+.ovc-pop-t { min-width: 0; display: grid; line-height: 1.3; }
+.ovc-pop-t b { font-weight: 650; font-size: 14px; color: var(--text-primary, #e6edf7); }
+.ovc-pop-t span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #7386a3); }
+.ovc-pop-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: 999px; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); }
+.ovc-mic[aria-pressed="true"] { color: var(--live-red, #ef4444); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); animation: ovc-listen 1.4s ease-in-out infinite; }
+.ovc-count { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }
+.ovc-count.is-low { color: var(--live-red, #ef4444); }
+.ovc-count[hidden] { display: none; }
+.ovt-composer.ovc-compact { padding: 6px 8px 6px 12px; border-radius: 18px; }
+.ovt-composer.ovc-compact .ovt-c-row { margin-top: 0; }
+.ovt-composer.ovc-compact .ovt-btn, .ovt-composer.ovc-compact .ovt-send { width: 36px; min-width: 36px; height: 36px; }
+@keyframes ovc-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }
+@keyframes ovc-listen { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 18%, transparent); } }
+html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
+.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
+.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
+.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }
+.ovc-set-in .ovc-set-f { padding: 0; }
+.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
+.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
+.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }
+.ovc-set-g legend { padding: 0 0 6px; font-weight: 650; font-size: 14px; }
+.ovc-set label { display: flex; align-items: center; gap: 10px; min-height: 40px; font-size: 14.5px; cursor: pointer; }
+.ovc-set small, .ovc-set-p { color: var(--text-muted, #7386a3); font-size: 12.5px; margin: 0; }
+.ovc-set input[type=radio], .ovc-set input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }
+.ovc-set-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
+.ovc-set-l li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 4px 4px 4px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }
+.ovc-set-l span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #b4c0d4); font-size: 13px; }
+.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }
+.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }
+.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
+@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }`;
+ function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
const mods = [];
const mods = [];
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
// from beside this file; without it, a plain <dialog>.
// from beside this file; without it, a plain <dialog>.
- const COMPOSER_REV = { 'ov-window.js': 'c882d11d85ff' };
+ const COMPOSER_REV = { 'ov-window.js': '625783527b4e' };
const selfSrc = (document.currentScript && document.currentScript.src) || '';
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
function needWindow() {
return new Promise((ok) => {
return new Promise((ok) => {
@@ -102,6 +164,7 @@
@@ -102,6 +164,7 @@
// ── One box.
// ── One box.
function enhance(form) {
function enhance(form) {
if (!form || form.__ovc) return form && form.__ovc;
if (!form || form.__ovc) return form && form.__ovc;
+ styles();
const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
if (!text) return null;
if (!text) return null;
const row = form.querySelector('.ovt-c-row');
const row = form.querySelector('.ovt-c-row');
@@ -111,11 +174,14 @@
@@ -111,11 +174,14 @@
const inputs = () => [...form.querySelectorAll('input[type="file"]')];
const inputs = () => [...form.querySelectorAll('input[type="file"]')];
const mine = () => mods.filter((m) => !m.match || m.match(form));
const mine = () => mods.filter((m) => !m.match || m.match(form));
const uploader = () => { const m = mine().find((x) => typeof x.upload === 'function'); return m ? m.upload : null; };
const uploader = () => { const m = mine().find((x) => typeof x.upload === 'function'); return m ? m.upload : null; };
- const canFile = () => !!(inputs().length || uploader());
+ // data-ovc-no-files: the box handles its own files (the Dock's Vibe box uploads a picture as it is picked).
+ const noFiles = form.hasAttribute('data-ovc-no-files');
+ const canFile = () => !noFiles && !!(inputs().length || uploader());
const maxFiles = Number(form.getAttribute('data-ovc-max-files')) || 10;
const maxFiles = Number(form.getAttribute('data-ovc-max-files')) || 10;
const maxBytes = (Number(form.getAttribute('data-ovc-max-mb')) || 25) * 1048576;
const maxBytes = (Number(form.getAttribute('data-ovc-max-mb')) || 25) * 1048576;
const files = [];
const files = [];
let quiet = '';
let quiet = '';
+ const wasRequired = !!text.required; // a box that was not required stays so (the Dock's Vibe box)
const say = (s) => { if (!note) return; if (!quiet) quiet = note.textContent; note.textContent = s; clearTimeout(say.t); say.t = setTimeout(() => { note.textContent = quiet; quiet = ''; }, 5000); };
const say = (s) => { if (!note) return; if (!quiet) quiet = note.textContent; note.textContent = s; clearTimeout(say.t); say.t = setTimeout(() => { note.textContent = quiet; quiet = ''; }, 5000); };
const fire = () => text.dispatchEvent(new Event('input', { bubbles: true }));
const fire = () => text.dispatchEvent(new Event('input', { bubbles: true }));
@@ -143,6 +209,7 @@
@@ -143,6 +209,7 @@
return acc.split(',').map((x) => x.trim().toLowerCase()).some((a) => !a || (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));
return acc.split(',').map((x) => x.trim().toLowerCase()).some((a) => !a || (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));
};
};
function syncInputs() {
function syncInputs() {
+ if (noFiles) return;
if (uploader()) { inputs().forEach((i) => { i.value = ''; }); return; }
if (uploader()) { inputs().forEach((i) => { i.value = ''; }); return; }
const [first, ...rest] = inputs();
const [first, ...rest] = inputs();
if (!first || typeof DataTransfer === 'undefined') return;
if (!first || typeof DataTransfer === 'undefined') return;
@@ -210,7 +277,7 @@
@@ -210,7 +277,7 @@
else if (e.target.closest('[data-ovc-back]')) { const t = f.text; remove(f); ctx.insert(t); }
else if (e.target.closest('[data-ovc-back]')) { const t = f.text; remove(f); ctx.insert(t); }
});
});
form.addEventListener('change', (e) => {
form.addEventListener('change', (e) => {
- const i = e.target; if (!i || i.type !== 'file' || i.__ovcSync) return;
+ const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
const picked = [...(i.files || [])];
const picked = [...(i.files || [])];
i.__ovcSync = true; i.value = ''; i.__ovcSync = false;
i.__ovcSync = true; i.value = ''; i.__ovcSync = false;
add(picked);
add(picked);
@@ -243,8 +310,8 @@
@@ -243,8 +310,8 @@
function syncSend() {
function syncSend() {
if (!send || form.classList.contains('is-busy')) return;
if (!send || form.classList.contains('is-busy')) return;
send.disabled = busy() || (!text.value.trim() && !files.length);
send.disabled = busy() || (!text.value.trim() && !files.length);
- if (text.required && files.length) text.required = false;
- else if (!files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;
+ if (wasRequired && text.required && files.length) text.required = false;
+ else if (wasRequired && !files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;
}
}
text.addEventListener('input', syncSend);
text.addEventListener('input', syncSend);
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': 'bab301d99cb6', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': 'f52e2323e6e9', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
'use strict';
'use strict';
const NETWORK = 'https://openvibe.network';
const NETWORK = 'https://openvibe.network';
- const USER_REV = { 'ov-window.js': 'c882d11d85ff' };
+ const USER_REV = { 'ov-window.js': '625783527b4e' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }
const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }
.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }
.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }
@@ -33,7 +33,7 @@
@@ -33,7 +33,7 @@
// 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the
// 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the
// same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing.
// same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing.
- const WALLET_REV = { 'ov-window.js': 'c882d11d85ff' };
+ const WALLET_REV = { 'ov-window.js': '625783527b4e' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
function needWindow() {
function needWindow() {
return new Promise((ok) => {
return new Promise((ok) => {
@@ -15,6 +15,9 @@
@@ -15,6 +15,9 @@
* label, onClose, bare, width }); bare: the content draws its own head (the title is still
* label, onClose, bare, width }); bare: the content draws its own head (the title is still
* read); width: px, for content that wants its own
* read); width: px, for content that wants its own
* w.el, w.body, w.foot, w.setTitle(text), w.close()
* w.el, w.body, w.foot, w.setTitle(text), w.close()
+ * A panel can be resizable (4.204.0, the owner: a robot's controls "can be popped out … and resized"): { resizable: true,
+ * minWidth, minHeight, onResize({ width, height }) } adds a grip at its corner that a mouse, a finger or a pen drags and
+ * the arrow keys move (Shift for bigger steps, Enter for the first size); the size is kept with its place per id.
* A site's content goes in as nodes (or text, set as text): the window never parses markup it is given.
* A site's content goes in as nodes (or text, set as text): the window never parses markup it is given.
*/
*/
(function (root) {
(function (root) {
@@ -52,6 +55,12 @@ html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blu
@@ -52,6 +55,12 @@ html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blu
.ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; }
.ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; }
.ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); }
.ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); }
.ovw.is-sheet.is-dragging { animation: none; }
.ovw.is-sheet.is-dragging { animation: none; }
+.ovw.is-resizable { max-height: none; }
+.ovw-grip { position: absolute; right: 2px; bottom: 2px; z-index: 2; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 8px; background: none; cursor: nwse-resize; touch-action: none; color: var(--text-secondary, #a8b3c4); }
+.ovw-grip::before { content: ""; position: absolute; right: 6px; bottom: 6px; width: 10px; height: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; border-radius: 0 0 3px 0; opacity: .55; }
+.ovw-grip:hover::before, .ovw-grip:focus-visible::before, .ovw.is-resizing .ovw-grip::before { opacity: 1; color: var(--accent, #3b82f6); }
+.ovw-grip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
+.ovw.is-resizing { animation: none; transition: none; user-select: none; }
.ovw { --ovw-w: 520px; }
.ovw { --ovw-w: 520px; }
.ovw.is-sm { --ovw-w: 380px; }
.ovw.is-sm { --ovw-w: 380px; }
.ovw.is-lg { --ovw-w: 760px; }
.ovw.is-lg { --ovw-w: 760px; }
@@ -177,7 +186,7 @@ html.ovw-locked { overflow: hidden; }
@@ -177,7 +186,7 @@ html.ovw-locked { overflow: hidden; }
const up = (ev) => {
const up = (ev) => {
head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up);
head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up);
el.classList.remove('is-dragging');
el.classList.remove('is-dragging');
- if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, { x: Math.round(b.left), y: Math.round(b.top) }); } return; }
+ if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(b.left), y: Math.round(b.top) })); } return; }
const dy = ev.clientY - sy;
const dy = ev.clientY - sy;
if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe');
if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe');
else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); }
else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); }
@@ -186,11 +195,56 @@ html.ovw-locked { overflow: hidden; }
@@ -186,11 +195,56 @@ html.ovw-locked { overflow: hidden; }
});
});
}
}
+ if (kind === 'panel' && o.resizable) resizable(el, o);
+
const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0];
const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0];
(first || x).focus({ preventScroll: true });
(first || x).focus({ preventScroll: true });
return w;
return w;
}
}
+ /** A panel's corner grip: pointer drag (mouse, finger, pen) and the arrow keys; the size is kept with its place. */
+ function resizable(el, o) {
+ el.classList.add('is-resizable');
+ const minW = Math.max(200, Number(o.minWidth) || 260), minH = Math.max(120, Number(o.minHeight) || 160);
+ const grip = document.createElement('button');
+ grip.type = 'button'; grip.className = 'ovw-grip';
+ grip.setAttribute('aria-label', 'Resize: drag, or use the arrow keys');
+ el.append(grip);
+ const first = { w: el.offsetWidth, h: el.offsetHeight };
+ const set = (w, h, save) => {
+ const r = el.getBoundingClientRect();
+ const W = Math.round(Math.max(minW, Math.min(w, root.innerWidth - r.left - 8)));
+ const H = Math.round(Math.max(minH, Math.min(h, root.innerHeight - r.top - 8)));
+ el.style.width = `${W}px`; el.style.height = `${H}px`;
+ if (save && o.id) place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(r.left), y: Math.round(r.top), w: W, h: H }));
+ if (typeof o.onResize === 'function') { try { o.onResize({ width: W, height: H }); } catch { /* the page's own */ } }
+ };
+ const saved = o.id ? place.get(o.id) : null;
+ if (saved && saved.w && saved.h) set(saved.w, saved.h, false);
+ grip.addEventListener('pointerdown', (e) => {
+ if (e.button !== undefined && e.button !== 0) return;
+ e.preventDefault();
+ const sx = e.clientX, sy = e.clientY, w0 = el.offsetWidth, h0 = el.offsetHeight;
+ el.classList.add('is-resizing');
+ try { grip.setPointerCapture(e.pointerId); } catch { /* */ }
+ const move = (ev) => set(w0 + ev.clientX - sx, h0 + ev.clientY - sy, false);
+ const up = () => {
+ grip.removeEventListener('pointermove', move); grip.removeEventListener('pointerup', up); grip.removeEventListener('pointercancel', up);
+ el.classList.remove('is-resizing');
+ set(el.offsetWidth, el.offsetHeight, true);
+ };
+ grip.addEventListener('pointermove', move); grip.addEventListener('pointerup', up); grip.addEventListener('pointercancel', up);
+ });
+ grip.addEventListener('keydown', (e) => {
+ const step = e.shiftKey ? 80 : 24;
+ const d = { ArrowRight: [step, 0], ArrowLeft: [-step, 0], ArrowDown: [0, step], ArrowUp: [0, -step] }[e.key];
+ if (e.key === 'Enter' || e.key === 'Home') { e.preventDefault(); set(first.w, first.h, true); return; }
+ if (!d) return;
+ e.preventDefault();
+ set(el.offsetWidth + d[0], el.offsetHeight + d[1], true);
+ });
+ }
+
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return;
if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return;
e.preventDefault();
e.preventDefault();
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.203.0",
+ "version": "4.204.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.203.0",
+ "version": "4.204.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.203.0",
+ "version": "4.204.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -127,6 +127,7 @@
@@ -127,6 +127,7 @@
"./player": "./player.js",
"./player": "./player.js",
"./vibes-pay": "./vibes-pay.js",
"./vibes-pay": "./vibes-pay.js",
"./trace": "./trace.js",
"./trace": "./trace.js",
+ "./csp": "./csp.js",
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
"./ov-user": "./ov-user.js",
"./ov-user": "./ov-user.js",
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
* video players across all our sites and vods … for a more unified look").
* video players across all our sites and vods … for a more unified look").
*
*
* <link rel="stylesheet" href="/shared/player.css">
* <link rel="stylesheet" href="/shared/player.css">
- * <video data-ov-player controls src="…" poster="…" preload="metadata" playsinline></video>
+ * <video data-ov-player controls src="…" poster="…" preload="none" playsinline></video>
* <script src="/shared/player.js" defer></script>
* <script src="/shared/player.js" defer></script>
*
*
* Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped
* Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped
@@ -207,8 +207,131 @@
@@ -207,8 +207,131 @@
} catch { /* an action this browser does not know */ }
} catch { /* an action this browser does not know */ }
}
}
+ // Startup policy is shared with pages that own their HLS attachment. Unknown connections use the fast policy.
+ const nav = typeof navigator === 'undefined' ? {} : navigator;
+ let battery = null;
+ if (nav.getBattery) Promise.resolve().then(() => nav.getBattery()).then((b) => { battery = b; }).catch(() => {});
+ function playbackPolicy(connection = nav.connection || {}, power = battery) {
+ const slow = connection.saveData || /^(slow-)?2g$/.test(connection.effectiveType || '');
+ const limited = slow || connection.effectiveType === '3g';
+ const lowBattery = !!power && !power.charging && power.level <= 0.2;
+ return {
+ preload: slow ? 'none' : limited ? 'metadata' : 'auto',
+ warm: !limited && !lowBattery,
+ hls: { startLevel: 0, maxBufferLength: lowBattery ? 15 : limited ? 30 : 60,
+ maxMaxBufferLength: lowBattery ? 30 : limited ? 60 : 120,
+ backBufferLength: lowBattery || slow ? 0 : limited ? 15 : 30 },
+ };
+ }
+ /** Attach using the page's Hls constructor; native HLS keeps the same connection-aware preload. */
+ function attachHls(video, Hls, url, options = {}) {
+ const policy = playbackPolicy();
+ video.preload = policy.preload;
+ if (!Hls || !Hls.isSupported()) {
+ if (video.canPlayType('application/vnd.apple.mpegurl')) video.src = url;
+ return null;
+ }
+ const hls = new Hls({ ...options, ...policy.hls, autoStartLoad: policy.preload === 'auto' });
+ let loading = policy.preload === 'auto';
+ const start = () => { if (!loading) { loading = true; hls.startLoad(); } };
+ const update = () => {
+ video.preload = playbackPolicy().preload;
+ Object.assign(hls.config, playbackPolicy().hls);
+ if (video.paused && video.preload !== 'auto' && loading) { hls.stopLoad(); loading = false; }
+ };
+ video.addEventListener('play', start);
+ if (nav.connection && nav.connection.addEventListener) nav.connection.addEventListener('change', update);
+ // getBattery is optional and async; register only while this attachment is alive.
+ let disposed = false, power = null;
+ if (nav.getBattery) Promise.resolve().then(() => nav.getBattery()).then((b) => {
+ if (disposed) return;
+ power = battery = b; update();
+ b.addEventListener('levelchange', update); b.addEventListener('chargingchange', update);
+ }).catch(() => {});
+ hls.on(Hls.Events.DESTROYING, () => {
+ disposed = true; video.removeEventListener('play', start);
+ if (nav.connection && nav.connection.removeEventListener) nav.connection.removeEventListener('change', update);
+ if (power) { power.removeEventListener('levelchange', update); power.removeEventListener('chargingchange', update); }
+ });
+ hls.loadSource(url); hls.attachMedia(video);
+ if (!video.paused) start();
+ return hls;
+ }
+ // Warm only explicit public HLS card sources, at most 20 per minute and 2 MiB per card. No whole-file prefetch.
+ const warmed = new Map();
+ async function warmHls(url, signal) {
+ if (!playbackPolicy().warm || typeof fetch !== 'function') return false;
+ const now = Date.now();
+ for (const [key, at] of warmed) if (now - at >= 60000) warmed.delete(key);
+ const safe = (value, base) => {
+ const u = new URL(value, base || undefined);
+ if (!/^https?:$/.test(u.protocol) || u.username || u.password || /(?:sig|token|key|exp|signature)/i.test(u.search)) throw new Error('Only public playlists are warmed');
+ return u.href;
+ };
+ let source;
+ try { source = safe(url, document.baseURI); } catch { return false; }
+ if (warmed.has(source) || warmed.size >= 20) return false;
+ warmed.set(source, now);
+ let remaining = 2 * 1024 * 1024;
+ const read = async (u, manifest = false) => {
+ const response = await fetch(u, { signal, credentials: 'same-origin', cache: 'force-cache' });
+ if (!response.ok || !response.body) throw new Error('Not cacheable');
+ const reader = response.body.getReader(), chunks = [];
+ let size = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read(); if (done) break;
+ size += value.byteLength; remaining -= value.byteLength;
+ if (remaining < 0 || (manifest && size > 65536)) throw new Error('Warm budget');
+ if (manifest) chunks.push(value);
+ }
+ } finally { await reader.cancel().catch(() => {}); }
+ if (!manifest) return '';
+ const bytes = new Uint8Array(size); let offset = 0;
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
+ return new TextDecoder().decode(bytes);
+ };
+ try {
+ let body = await read(source, true);
+ if (!body.startsWith('#EXTM3U') || /#EXT-X-(?:KEY|SESSION-KEY):|BYTERANGE[=:]/.test(body)) return false;
+ if (body.includes('#EXT-X-STREAM-INF:')) {
+ const lines = body.split(/\r?\n/), variants = [];
+ lines.forEach((line, i) => {
+ if (line.startsWith('#EXT-X-STREAM-INF:') && lines[i + 1] && !lines[i + 1].startsWith('#'))
+ variants.push({ bandwidth: Number((line.match(/(?:^|,)BANDWIDTH=(\d+)/) || line.match(/:BANDWIDTH=(\d+)/) || [])[1]) || Infinity, uri: lines[i + 1] });
+ });
+ variants.sort((a, b) => a.bandwidth - b.bandwidth);
+ if (!variants.length) return false;
+ source = safe(variants[0].uri, source); body = await read(source, true);
+ }
+ // Live playlists, encrypted media and byte-range playlists need their player's own loader.
+ if (!body.includes('#EXT-X-ENDLIST') || /#EXT-X-(?:KEY|SESSION-KEY):|BYTERANGE[=:]/.test(body)) return false;
+ const init = body.match(/#EXT-X-MAP:URI="([^"]+)"/);
+ const segment = body.split(/\r?\n/).find((line) => line && !line.startsWith('#'));
+ if (init) await read(safe(init[1], source));
+ if (segment) await read(safe(segment, source));
+ return !!segment;
+ } catch { return false; }
+ }
+ function warmCards(scope = document) {
+ scope.querySelectorAll('[data-ov-player-hls]').forEach((card) => {
+ if (card.__ovWarm) return; card.__ovWarm = true;
+ let controller = null, timer = null;
+ const cancel = () => { clearTimeout(timer); if (controller) controller.abort(); };
+ const begin = () => {
+ cancel();
+ if (!playbackPolicy().warm || typeof AbortController !== 'function') return;
+ controller = new AbortController();
+ timer = setTimeout(() => warmHls(card.getAttribute('data-ov-player-hls'), controller.signal), 120);
+ };
+ card.addEventListener('pointerenter', begin); card.addEventListener('focusin', begin);
+ card.addEventListener('pointerleave', cancel); card.addEventListener('focusout', cancel);
+ });
+ }
+
function enhance(video, options = {}) {
function enhance(video, options = {}) {
if (!video || video.__ovPlayer) return video && video.__ovPlayer;
if (!video || video.__ovPlayer) return video && video.__ovPlayer;
+ video.preload = playbackPolicy().preload;
const idleMs = options.idleMs || 2500;
const idleMs = options.idleMs || 2500;
const root = el('div', 'ov-player is-paused is-start', { tabindex: '0', role: 'region', 'aria-label': options.label || video.getAttribute('aria-label') || 'Video player' });
const root = el('div', 'ov-player is-paused is-start', { tabindex: '0', role: 'region', 'aria-label': options.label || video.getAttribute('aria-label') || 'Video player' });
video.parentNode.insertBefore(root, video);
video.parentNode.insertBefore(root, video);
@@ -236,7 +359,14 @@
@@ -236,7 +359,14 @@
root.insertBefore(posterLayer, video);
root.insertBefore(posterLayer, video);
root.insertBefore(audioLayer, video);
root.insertBefore(audioLayer, video);
const shown = () => root.classList.add('has-frame');
const shown = () => root.classList.add('has-frame');
- if (video.readyState >= 2) shown(); else video.addEventListener('loadeddata', shown, { once: true });
+ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(shown);
+ else if (video.readyState >= 2) shown();
+ else video.addEventListener('loadeddata', shown, { once: true });
+ video.addEventListener('emptied', () => {
+ root.classList.remove('has-frame');
+ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(shown);
+ else video.addEventListener('loadeddata', shown, { once: true });
+ });
// ── Build ────────────────────────────────────────────
// ── Build ────────────────────────────────────────────
const big = button('ov-player-big', 'Play', ICON.play);
const big = button('ov-player-big', 'Play', ICON.play);
@@ -605,9 +735,10 @@
@@ -605,9 +735,10 @@
function enhanceAll(scope) {
function enhanceAll(scope) {
(scope || document).querySelectorAll('video[data-ov-player]').forEach((v) => enhance(v));
(scope || document).querySelectorAll('video[data-ov-player]').forEach((v) => enhance(v));
+ warmCards(scope || document);
}
}
- window.OpenVibePlayer = { enhance, enhanceAll, format: fmt };
+ window.OpenVibePlayer = { enhance, enhanceAll, format: fmt, playbackPolicy, attachHls, warmHls, warmCards };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhanceAll());
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhanceAll());
else enhanceAll();
else enhanceAll();
})();
})();
@@ -27,7 +27,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -27,7 +27,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
- 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
+ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
function writeOwn() {
function writeOwn() {
for (const [file, { name, files }] of Object.entries(OWN)) {
for (const [file, { name, files }] of Object.entries(OWN)) {
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// closes it, a changed picker shows as a chip whose × puts the default back, the placeholder walks through the examples,
// closes it, a changed picker shows as a chip whose × puts the default back, the placeholder walks through the examples,
// and a kit sidebar's button moves into the navbar on a phone, where the empty head row goes away. Skipped without Chrome.
// and a kit sidebar's button moves into the navbar on a phone, where the empty head row goes away. Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -51,8 +52,8 @@ const server = http.createServer((req, res) => {
@@ -51,8 +52,8 @@ const server = http.createServer((req, res) => {
await p.goto(`${base}/`, { maxMs: 6000 });
await p.goto(`${base}/`, { maxMs: 6000 });
await until(p, "!!document.querySelector('[data-ovt-opts]')", 'the Options button');
await until(p, "!!document.querySelector('[data-ovt-opts]')", 'the Options button');
// Only the button and Send in the bar; the pickers are behind it.
// Only the button and Send in the bar; the pickers are behind it.
- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-panel').checkVisibility()"), false, 'the pickers start closed');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), true, 'no dot while everything is the default');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-panel').checkVisibility()"), false, 'the pickers start closed'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), true, 'no dot while everything is the default'));
// Open: a sheet at the bottom of a phone, the page behind does not scroll.
// Open: a sheet at the bottom of a phone, the page behind does not scroll.
await p.evaluate("document.querySelector('.ovt-opts > summary').click()");
await p.evaluate("document.querySelector('.ovt-opts > summary').click()");
await until(p, "document.querySelector('[data-ovt-opts]').open", 'it opens');
await until(p, "document.querySelector('[data-ovt-opts]').open", 'it opens');
@@ -63,16 +64,16 @@ const server = http.createServer((req, res) => {
@@ -63,16 +64,16 @@ const server = http.createServer((req, res) => {
assert.ok(sheet.locked, 'the page behind stays put');
assert.ok(sheet.locked, 'the page behind stays put');
// Change a picker: a chip shows beside the button, and the dot.
// Change a picker: a chip shows beside the button, and the dot.
await p.evaluate("(() => { const s = document.querySelector('select[name=who]'); s.value = 'codex'; s.dispatchEvent(new Event('change', { bubbles: true })); })()");
await p.evaluate("(() => { const s = document.querySelector('select[name=who]'); s.value = 'codex'; s.dispatchEvent(new Event('change', { bubbles: true })); })()");
- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovt-opt-chip > span')].map((s) => s.textContent).join()"), 'Codex', 'the changed one is a chip');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), false, 'and the button wears a dot');
+ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovt-opt-chip > span')].map((s) => s.textContent).join()"), 'Codex', 'the changed one is a chip'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), false, 'and the button wears a dot'));
// A tap outside closes it and unlocks the page.
// A tap outside closes it and unlocks the page.
await p.evaluate("(() => { const e = new PointerEvent('pointerdown', { bubbles: true }); document.querySelector('h1').dispatchEvent(e); })()");
await p.evaluate("(() => { const e = new PointerEvent('pointerdown', { bubbles: true }); document.querySelector('h1').dispatchEvent(e); })()");
await until(p, "!document.querySelector('[data-ovt-opts]').open", 'a tap outside closes it');
await until(p, "!document.querySelector('[data-ovt-opts]').open", 'a tap outside closes it');
- assert.strictEqual(await p.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page scrolls again');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page scrolls again'));
// The chip's × puts the default back.
// The chip's × puts the default back.
await p.evaluate("document.querySelector('.ovt-opt-chip-x').click()");
await p.evaluate("document.querySelector('.ovt-opt-chip-x').click()");
- assert.strictEqual(await p.evaluate("document.querySelector('select[name=who]').value"), '', 'the default is back');
- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovt-opt-chip').length"), 0, 'and the chip is gone');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('select[name=who]').value"), '', 'the default is back'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovt-opt-chip').length"), 0, 'and the chip is gone'));
// The examples walk through the placeholder while the box is empty.
// The examples walk through the placeholder while the box is empty.
await until(p, "document.querySelector('[data-ovt-text]').placeholder !== 'What should change?'", 'the placeholder moves to an example', 6000);
await until(p, "document.querySelector('[data-ovt-text]').placeholder !== 'What should change?'", 'the placeholder moves to an example', 6000);
// The sidebar button: in the navbar before the brand, and the head row with nothing else in it is gone.
// The sidebar button: in the navbar before the brand, and the head row with nothing else in it is gone.
@@ -83,23 +84,23 @@ const server = http.createServer((req, res) => {
@@ -83,23 +84,23 @@ const server = http.createServer((req, res) => {
assert.strictEqual(lead.head, 'none', 'the empty head row goes');
assert.strictEqual(lead.head, 'none', 'the empty head row goes');
await p.evaluate("document.getElementById('ovnav-lead').click()");
await p.evaluate("document.getElementById('ovnav-lead').click()");
await until(p, "document.getElementById('ch-rail').classList.contains('is-open')", 'the slot opens the drawer');
await until(p, "document.getElementById('ch-rail').classList.contains('is-open')", 'the slot opens the drawer');
- assert.strictEqual(await p.evaluate("document.getElementById('ovnav-lead').getAttribute('aria-expanded')"), 'true', 'and says it is open');
- assert.ok(await p.evaluate('document.documentElement.scrollWidth <= 390'), 'no sideways scroll');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovnav-lead').getAttribute('aria-expanded')"), 'true', 'and says it is open'));
+ await eventually(async () => assert.ok(await p.evaluate('document.documentElement.scrollWidth <= 390'), 'no sideways scroll'));
// A site's own action on the right, by the API.
// A site's own action on the right, by the API.
await p.evaluate("OpenVibeNavbar.action({ id: 'help', icon: 'info', label: 'Help', badge: 2, onClick: () => { window.__help = (window.__help || 0) + 1; } })");
await p.evaluate("OpenVibeNavbar.action({ id: 'help', icon: 'info', label: 'Help', badge: 2, onClick: () => { window.__help = (window.__help || 0) + 1; } })");
await p.evaluate("document.getElementById('ovnav-act-help').click()");
await p.evaluate("document.getElementById('ovnav-act-help').click()");
- assert.strictEqual(await p.evaluate('window.__help'), 1, 'an action button calls its handler');
- assert.strictEqual(await p.evaluate("document.querySelector('#ovnav-act-help b').textContent"), '2', 'and shows its count');
+ await eventually(async () => assert.strictEqual(await p.evaluate('window.__help'), 1, 'an action button calls its handler'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#ovnav-act-help b').textContent"), '2', 'and shows its count'));
// On a computer: the panel opens beside the button, not as a sheet, and the navbar slot is not shown.
// On a computer: the panel opens beside the button, not as a sheet, and the navbar slot is not shown.
const d = await h.openPage(browser, context, { width: 1280, height: 900, block: ['*openvibe.network*', '*openvibe.tools*'] });
const d = await h.openPage(browser, context, { width: 1280, height: 900, block: ['*openvibe.network*', '*openvibe.tools*'] });
await d.goto(`${base}/`, { maxMs: 6000 });
await d.goto(`${base}/`, { maxMs: 6000 });
await until(d, "!!document.querySelector('[data-ovt-opts]')", 'the Options button on a computer');
await until(d, "!!document.querySelector('[data-ovt-opts]')", 'the Options button on a computer');
await d.evaluate("document.querySelector('.ovt-opts > summary').click()");
await d.evaluate("document.querySelector('.ovt-opts > summary').click()");
await wait(250);
await wait(250);
- assert.strictEqual(await d.evaluate("getComputedStyle(document.querySelector('.ovt-opts-panel')).position"), 'absolute', 'a panel on a computer');
- assert.strictEqual(await d.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page is not locked for a panel');
+ await eventually(async () => assert.strictEqual(await d.evaluate("getComputedStyle(document.querySelector('.ovt-opts-panel')).position"), 'absolute', 'a panel on a computer'));
+ await eventually(async () => assert.strictEqual(await d.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page is not locked for a panel'));
await until(d, "!!document.getElementById('ovnav-lead')", 'the slot exists');
await until(d, "!!document.getElementById('ovnav-lead')", 'the slot exists');
- assert.strictEqual(await d.evaluate("getComputedStyle(document.getElementById('ovnav-lead')).display"), 'none', 'but hides where the sidebar is always shown');
+ await eventually(async () => assert.strictEqual(await d.evaluate("getComputedStyle(document.getElementById('ovnav-lead')).display"), 'none', 'but hides where the sidebar is always shown'));
console.log('composer-options-chrome: one Options button, a sheet on a phone, chips for changes, examples, the sidebar button in the navbar, actions by API');
console.log('composer-options-chrome: one Options button, a sheet on a phone, chips for changes, examples, the sidebar button in the navbar, actions by API');
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+// openvibe-shared/csp: connect-src names Media (the composer's upload origin) and Network by default, a site can add
+// its own origins, and policy() is a complete CSP string.
+const assert = require('assert');
+const csp = require('../csp');
+
+const src = csp.connectSources();
+assert.ok(src.startsWith("'self'"));
+for (const origin of ['https://openvibe.network', 'wss://openvibe.network', 'https://openvibe.events', 'wss://openvibe.events', 'https://openvibe.media']) {
+ assert.ok(src.split(' ').includes(origin), `connect-src names ${origin}`);
+}
+assert.deepStrictEqual(csp.connectSources({ self: false, sites: ['wss://openvibe.chat'], extra: ['https://cloudflareinsights.com'] }).split(' '), [
+ 'https://openvibe.network', 'wss://openvibe.network', 'https://openvibe.events', 'wss://openvibe.events', 'https://openvibe.media', 'wss://openvibe.chat', 'https://cloudflareinsights.com',
+]);
+assert.strictEqual(csp.MEDIA_UPLOAD_ORIGIN, 'https://openvibe.media');
+
+const policy = csp.policy({ nonce: 'abc123', sites: ['wss://openvibe.chat'], frames: ["'self'", 'https://openvibe.bot'] });
+for (const part of ["default-src 'self'", "script-src 'self' 'nonce-abc123'", "style-src 'self' 'unsafe-inline'", "img-src https:", "media-src https:", "font-src 'self' data:", "frame-src 'self' https://openvibe.bot", "frame-ancestors 'self'", "base-uri 'self'", "object-src 'none'", "form-action 'self' https://openvibe.network"]) {
+ assert.ok(policy.includes(part), `policy has ${part}`);
+}
+assert.ok(policy.includes('https://openvibe.media') && policy.includes('wss://openvibe.chat'), 'policy connect-src has Media and the site');
+assert.ok(csp.policy({ extra: { 'worker-src': "'self' blob:" } }).includes("worker-src 'self' blob:"));
+console.log('csp: all checks passed');
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
// Dock went, and closing the window brings the panel back; the Network is told.
// Dock went, and closing the window brings the panel back; the Network is told.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -319,7 +320,7 @@ async function drag(p, from, to) {
@@ -319,7 +320,7 @@ async function drag(p, from, to) {
const p = await fresh(390);
const p = await fresh(390);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
await wait(700);
await wait(700);
- assert.strictEqual(await p.evaluate(PILL), null, 'no pill while nothing runs or waits (unread alone is the bell\'s)');
+ await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill while nothing runs or waits (unread alone is the bell\'s)'));
scenario.state = { ...defaultState(), pinned: true };
scenario.state = { ...defaultState(), pinned: true };
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
const pin = await until(p, PILL, 'the pinned pill');
const pin = await until(p, PILL, 'the pinned pill');
@@ -343,7 +344,7 @@ async function drag(p, from, to) {
@@ -343,7 +344,7 @@ async function drag(p, from, to) {
await new Promise((ok) => setTimeout(ok, 450)); // the sheet has finished rising
await new Promise((ok) => setTimeout(ok, 450)); // the sheet has finished rising
const placed = await p.evaluate(CARD);
const placed = await p.evaluate(CARD);
assert.ok(placed.w === 390 && placed.t === 0 && placed.h === 780, `the whole phone, over the bar (owner, 11-09: "all the way to the top"; ${placed.t}, ${placed.h})`);
assert.ok(placed.w === 390 && placed.t === 0 && placed.h === 780, `the whole phone, over the bar (owner, 11-09: "all the way to the top"; ${placed.t}, ${placed.h})`);
- assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open'));
await p.evaluate("document.querySelector('.ovdk .ovac-item[data-bucket=waiting] button[data-do=approve]').click()");
await p.evaluate("document.querySelector('.ovdk .ovac-item[data-bucket=waiting] button[data-do=approve]').click()");
await until(p, "document.querySelector('.ovdk .ovac-item[data-bucket=waiting] .primary').textContent === 'Approved'", 'approved in place');
await until(p, "document.querySelector('.ovdk .ovac-item[data-bucket=waiting] .primary').textContent === 'Approved'", 'approved in place');
assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'the approval goes to the Network for that item');
assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'the approval goes to the Network for that item');
@@ -351,7 +352,7 @@ async function drag(p, from, to) {
@@ -351,7 +352,7 @@ async function drag(p, from, to) {
await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'activity' && c.notes === 2; })()`, 'the Activity tab');
await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'activity' && c.notes === 2; })()`, 'the Activity tab');
await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').focus(); document.querySelector('.ovdk').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))");
await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').focus(); document.querySelector('.ovdk').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))");
await until(p, `!(${CARD})`, 'Escape closes');
await until(p, `!(${CARD})`, 'Escape closes');
- assert.strictEqual(await p.evaluate('document.activeElement && document.activeElement.id'), 'ovdk-pill', 'the focus goes back to the pill');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.activeElement && document.activeElement.id'), 'ovdk-pill', 'the focus goes back to the pill'));
await wait(900);
await wait(900);
const all = Object.assign({}, ...puts);
const all = Object.assign({}, ...puts);
assert.deepStrictEqual([all.open, all.tab], [false, 'activity'], `the Network heard where the Dock was left (${JSON.stringify(puts)})`);
assert.deepStrictEqual([all.open, all.tab], [false, 'activity'], `the Network heard where the Dock was left (${JSON.stringify(puts)})`);
@@ -361,7 +362,7 @@ async function drag(p, from, to) {
@@ -361,7 +362,7 @@ async function drag(p, from, to) {
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, PILL, 'the pill again');
await until(p, PILL, 'the pill again');
await wait(400);
await wait(400);
- assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked');
+ await eventually(async () => assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked'));
await p.close();
await p.close();
}
}
@@ -384,12 +385,12 @@ async function drag(p, from, to) {
@@ -384,12 +385,12 @@ async function drag(p, from, to) {
const cards = await until(p, "(() => { const c = [...document.querySelectorAll('.ovdk .ovdk-msg .ovdk-card')]; return c.length === 2 ? c.map((x) => { const t = x.querySelector('.ovdk-card-t'), g = x.querySelector('.ovdk-card-go'), i = x.querySelector('img'); return [t.tagName, t.textContent, t.getAttribute('href'), i ? i.getAttribute('src') : null, g ? g.textContent + ' ' + g.getAttribute('href') : null]; }) : null; })()", 'the link cards');
const cards = await until(p, "(() => { const c = [...document.querySelectorAll('.ovdk .ovdk-msg .ovdk-card')]; return c.length === 2 ? c.map((x) => { const t = x.querySelector('.ovdk-card-t'), g = x.querySelector('.ovdk-card-go'), i = x.querySelector('img'); return [t.tagName, t.textContent, t.getAttribute('href'), i ? i.getAttribute('src') : null, g ? g.textContent + ' ' + g.getAttribute('href') : null]; }) : null; })()", 'the link cards');
assert.deepStrictEqual(cards[0], ['A', 'The studio tour', 'https://openvibe.video/v/42', 'https://openvibe.media/t/abc?w=320', 'Watch together https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42'], 'a card: its picture, its title as a link, its one move');
assert.deepStrictEqual(cards[0], ['A', 'The studio tour', 'https://openvibe.video/v/42', 'https://openvibe.media/t/abc?w=320', 'Watch together https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42'], 'a card: its picture, its title as a link, its one move');
assert.deepStrictEqual(cards[1], ['B', '<img src=x>', null, null, null], 'a card is data: no script link, no picture from elsewhere, no script button, the title as text');
assert.deepStrictEqual(cards[1], ['B', '<img src=x>', null, null, null], 'a card is data: no script link, no picture from elsewhere, no script button, the title as text');
- assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows'));
await until(p, "!!document.querySelector('.ovdk .ovdk-edit')", 'the edit action');
await until(p, "!!document.querySelector('.ovdk .ovdk-edit')", 'the edit action');
await p.evaluate("(() => { window.prompt = () => 'updated by me'; document.querySelector('.ovdk .ovdk-edit').click(); })()");
await p.evaluate("(() => { window.prompt = () => 'updated by me'; document.querySelector('.ovdk .ovdk-edit').click(); })()");
await until(p, "document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text').textContent === 'updated by me'", 'the edited message');
await until(p, "document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text').textContent === 'updated by me'", 'the edited message');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-edited').textContent.includes('edited')"), true);
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-edited').textContent.includes('edited')"), true));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet'));
await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");
await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');
assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');
assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');
@@ -412,12 +413,12 @@ async function drag(p, from, to) {
@@ -412,12 +413,12 @@ async function drag(p, from, to) {
const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);
const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);
await share('Friday film');
await share('Friday film');
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the Send to… bar');
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the Send to… bar');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), 'Friday film', 'what is being sent, by its title');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), 'Friday film', 'what is being sent, by its title'));
await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'your conversations under it');
await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'your conversations under it');
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
await until(p, "document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value.length > 0", 'the message in the box');
await until(p, "document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value.length > 0", 'the message in the box');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-send textarea').value"), TEXT, 'the link waits in the box');
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk-ready')"), true, 'and says it is ready to send');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-send textarea').value"), TEXT, 'the link waits in the box'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk-ready')"), true, 'and says it is ready to send'));
await wait(300);
await wait(300);
assert.deepStrictEqual(said, [], 'nothing is sent until the person sends it');
assert.deepStrictEqual(said, [], 'nothing is sent until the person sends it');
// Someone new, by their name: a name nobody has says so; the right one starts the conversation.
// Someone new, by their name: a name nobody has says so; the right one starts the conversation.
@@ -425,7 +426,7 @@ async function drag(p, from, to) {
@@ -425,7 +426,7 @@ async function drag(p, from, to) {
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');
await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = '@zz'; document.querySelector('.ovdk-to').requestSubmit(); })()");
await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = '@zz'; document.querySelector('.ovdk-to').requestSubmit(); })()");
await until(p, "!document.querySelector('.ovdk-share .ovdk-err').hidden", 'a name nobody has');
await until(p, "!document.querySelector('.ovdk-share .ovdk-err').hidden", 'a name nobody has');
- assert.match(await p.evaluate("document.querySelector('.ovdk-share .ovdk-err').textContent"), /Nobody is called @zz\. Did you mean @mia\?/);
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovdk-share .ovdk-err').textContent"), /Nobody is called @zz\. Did you mean @mia\?/));
await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = 'mia'; document.querySelector('.ovdk-to').requestSubmit(); })()");
await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = 'mia'; document.querySelector('.ovdk-to').requestSubmit(); })()");
await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');
await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');
assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');
assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');
@@ -436,7 +437,7 @@ async function drag(p, from, to) {
@@ -436,7 +437,7 @@ async function drag(p, from, to) {
await p.evaluate("document.getElementById('pagesend').click()");
await p.evaluate("document.getElementById('pagesend').click()");
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar, opened by a tap on the page');
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar, opened by a tap on the page');
await wait(300);
await wait(300);
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet'));
await p.evaluate("document.body.click()");
await p.evaluate("document.body.click()");
// A phone's sheet folds back into the navbar's orb before it goes (4.185.0: 240 ms), so it is gone a moment
// A phone's sheet folds back into the navbar's orb before it goes (4.185.0: 240 ms), so it is gone a moment
// after the tap, not in the same instant.
// after the tap, not in the same instant.
@@ -473,15 +474,15 @@ async function drag(p, from, to) {
@@ -473,15 +474,15 @@ async function drag(p, from, to) {
// The stream: Live's embed, the task, the numbers.
// The stream: Live's embed, the task, the numbers.
const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');
const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');
assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');
assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min');
- assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['pause', 'curtain', 'mute', 'end', 'float'], 'the owner\'s four controls (S5), and Float while the screen plays (4.154.0)');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min'));
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['pause', 'curtain', 'mute', 'end', 'float'], 'the owner\'s four controls (S5), and Float while the screen plays (4.154.0)'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list'));
// Curtain now: Live is asked, and the line says so.
// Curtain now: Live is asked, and the line says so.
await p.evaluate("document.querySelector('.ovda-acts button[data-act=curtain]').click()");
await p.evaluate("document.querySelector('.ovda-acts button[data-act=curtain]').click()");
await until(p, "document.querySelector('.ovda-meta').textContent.includes('Private screen')", 'the curtain in the line');
await until(p, "document.querySelector('.ovda-meta').textContent.includes('Private screen')", 'the curtain in the line');
assert.deepStrictEqual(controls, ['curtain'], 'Curtain now reached Codes, which puts it to Live');
assert.deepStrictEqual(controls, ['curtain'], 'Curtain now reached Codes, which puts it to Live');
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda-acts button[data-act=curtain]')"), false, 'no Curtain now while the curtain is up');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda-acts button[data-act=curtain]')"), false, 'no Curtain now while the curtain is up'));
// S5: Pause and Mute reach Codes, which puts them to Live; the line says whose pause it is.
// S5: Pause and Mute reach Codes, which puts them to Live; the line says whose pause it is.
await p.evaluate("document.querySelector('.ovda-acts button[data-act=pause]').click()");
await p.evaluate("document.querySelector('.ovda-acts button[data-act=pause]').click()");
await until(p, "document.querySelector('.ovda-acts button[data-act=pause]').textContent.includes('Resume')", 'the pause in the button');
await until(p, "document.querySelector('.ovda-acts button[data-act=pause]').textContent.includes('Resume')", 'the pause in the button');
@@ -492,9 +493,9 @@ async function drag(p, from, to) {
@@ -492,9 +493,9 @@ async function drag(p, from, to) {
const stillSrc = await until(p, "document.querySelector('.ovda img') && document.querySelector('.ovda img').getAttribute('src')", 'the still after End');
const stillSrc = await until(p, "document.querySelector('.ovda img') && document.querySelector('.ovda img').getAttribute('src')", 'the still after End');
assert.ok(stillSrc.startsWith('blob:'), 'the still is read with the token, never a bare <img> URL');
assert.ok(stillSrc.startsWith('blob:'), 'the still is read with the token, never a bare <img> URL');
assert.deepStrictEqual(controls, ['curtain', 'pause', 'mute', 'end'], 'Pause, Mute and End reached Codes, which puts them to Live');
assert.deepStrictEqual(controls, ['curtain', 'pause', 'mute', 'end'], 'Pause, Mute and End reached Codes, which puts them to Live');
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min');
- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream'));
// No stream at all: the view hides itself and the tab is just the list.
// No stream at all: the view hides itself and the tab is just the list.
agentView = null;
agentView = null;
await p.goto(`${base}/page`, { maxMs: 8000 });
await p.goto(`${base}/page`, { maxMs: 8000 });
@@ -502,8 +503,8 @@ async function drag(p, from, to) {
@@ -502,8 +503,8 @@ async function drag(p, from, to) {
await p.evaluate("document.getElementById('ovdk-pill').click()");
await p.evaluate("document.getElementById('ovdk-pill').click()");
await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab again');
await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab again');
await wait(400);
await wait(400);
- assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it');
- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone'));
await p.close();
await p.close();
}
}
@@ -516,7 +517,7 @@ async function drag(p, from, to) {
@@ -516,7 +517,7 @@ async function drag(p, from, to) {
assert.strictEqual(pill.text, 'Working');
assert.strictEqual(pill.text, 'Working');
await p.evaluate("document.getElementById('ovdk-pill').click()");
await p.evaluate("document.getElementById('ovdk-pill').click()");
await until(p, CARD, 'the sheet at 320');
await until(p, CARD, 'the sheet at 320');
- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll'));
await p.close();
await p.close();
}
}
@@ -566,8 +567,8 @@ async function drag(p, from, to) {
@@ -566,8 +567,8 @@ async function drag(p, from, to) {
await p.evaluate("document.getElementById('ovdk-pill').click()");
await p.evaluate("document.getElementById('ovdk-pill').click()");
await until(p, CARD, 'the card');
await until(p, CARD, 'the card');
const POP = "document.querySelector('.ovdk [data-act=pop]')";
const POP = "document.querySelector('.ovdk [data-act=pop]')";
- assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button');
- assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own'));
// The browser can: the button appears.
// The browser can: the button appears.
await p.evaluate(PIP);
await p.evaluate(PIP);
await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");
await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");
@@ -577,10 +578,10 @@ async function drag(p, from, to) {
@@ -577,10 +578,10 @@ async function drag(p, from, to) {
// The panel moves into the window: the same DOM, the same tabs, the styles come with it.
// The panel moves into the window: the same DOM, the same tabs, the styles come with it.
await p.evaluate(`${POP}.click()`);
await p.evaluate(`${POP}.click()`);
await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');
await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more');
- assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 5, 'the same five tabs (Vibe, Code, Agents, Chat, Activity)');
- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it");
- assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 5, 'the same five tabs (Vibe, Code, Agents, Chat, Activity)'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it"));
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520'));
const inWindow = await until(p, PILL, 'the pill stays');
const inWindow = await until(p, PILL, 'the pill stays');
assert.strictEqual(inWindow.text, 'In a window', 'the page keeps the pill, and it says where the Dock went');
assert.strictEqual(inWindow.text, 'In a window', 'the page keeps the pill, and it says where the Dock went');
// The state write is debounced; a slow runner (macOS CI) needs more than a fixed wait.
// The state write is debounced; a slow runner (macOS CI) needs more than a fixed wait.
@@ -590,7 +591,7 @@ async function drag(p, from, to) {
@@ -590,7 +591,7 @@ async function drag(p, from, to) {
// The window is closed: the panel comes back to the page.
// The window is closed: the panel comes back to the page.
await p.evaluate("window.__pip.close()");
await p.evaluate("window.__pip.close()");
await until(p, "!!document.querySelector('.ovdk')", 'the panel back on the page');
await until(p, "!!document.querySelector('.ovdk')", 'the panel back on the page');
- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window'));
await wait(900);
await wait(900);
assert.ok(!puts.some((b) => 'popped' in b), `nor that it is back (${JSON.stringify(puts)})`);
assert.ok(!puts.some((b) => 'popped' in b), `nor that it is back (${JSON.stringify(puts)})`);
await p.close();
await p.close();
@@ -619,12 +620,12 @@ async function drag(p, from, to) {
@@ -619,12 +620,12 @@ async function drag(p, from, to) {
await p.evaluate("window.__pip.win.document.querySelector('#ovdk-tab-agent').click()");
await p.evaluate("window.__pip.win.document.querySelector('#ovdk-tab-agent').click()");
const src = await until(p, "(() => { const w = window.__pip.win; const f = w.document.querySelector('.ovda iframe'); return f && f.getAttribute('src'); })()", 'the stream in the window');
const src = await until(p, "(() => { const w = window.__pip.win; const f = w.document.querySelector('.ovda iframe'); return f && f.getAttribute('src'); })()", 'the stream in the window');
assert.strictEqual(src, agentView.embed_url, 'the same ticket and stream, in the window');
assert.strictEqual(src, agentView.embed_url, 'the same ticket and stream, in the window');
- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovda-styles')"), true, 'the agent view keeps its styles in the window');
- assert.strictEqual(await p.evaluate("!!document.getElementById('ovda-styles')"), false, 'and does not put them on the page behind it');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovda-styles')"), true, 'the agent view keeps its styles in the window'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.getElementById('ovda-styles')"), false, 'and does not put them on the page behind it'));
// Back on the page, the same view is still there.
// Back on the page, the same view is still there.
await p.evaluate("window.__pip.close()");
await p.evaluate("window.__pip.close()");
await until(p, "!!document.querySelector('.ovdk .ovda iframe')", 'the stream back on the page');
await until(p, "!!document.querySelector('.ovdk .ovda iframe')", 'the stream back on the page');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovda iframe').getAttribute('src')"), agentView.embed_url);
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda iframe').getAttribute('src')"), agentView.embed_url));
await p.close();
await p.close();
}
}
@@ -639,7 +640,7 @@ async function drag(p, from, to) {
@@ -639,7 +640,7 @@ async function drag(p, from, to) {
await until(p, PILL, 'the pinned pill');
await until(p, PILL, 'the pinned pill');
await p.evaluate("document.getElementById('ovdk-pill').click()");
await p.evaluate("document.getElementById('ovdk-pill').click()");
await until(p, CARD, 'the card');
await until(p, CARD, 'the card');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab'));
await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
@@ -660,10 +661,10 @@ async function drag(p, from, to) {
@@ -660,10 +661,10 @@ async function drag(p, from, to) {
await p.evaluate("document.querySelector('.ovdk .ovdk-res-c button').click()");
await p.evaluate("document.querySelector('.ovdk .ovdk-res-c button').click()");
await until(p, "!!document.querySelector('.ovdk .ovdk-res-c.is-stopped')", 'the chip says it is stopped');
await until(p, "!!document.querySelector('.ovdk .ovdk-res-c.is-stopped')", 'the chip says it is stopped');
assert.deepStrictEqual(undone[0], { message_id: vibeMsgs.find((m) => m.from === 'vibe').id, index: 0 }, 'undo names the answer and the result');
assert.deepStrictEqual(undone[0], { message_id: vibeMsgs.find((m) => m.from === 'vibe').id, index: 0 }, 'undo names the answer and the result');
- assert.match(await p.evaluate("document.querySelector('.ovdk .ovdk-res-c').innerText"), /Reminder stopped: Stretch/);
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again');
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat');
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovdk .ovdk-res-c').innerText"), /Reminder stopped: Stretch/));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat'));
await wait(900);
await wait(900);
assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`);
assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`);
await p.close();
await p.close();
@@ -691,15 +692,15 @@ async function drag(p, from, to) {
@@ -691,15 +692,15 @@ async function drag(p, from, to) {
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic'));
await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
await until(p, "window.__recording === true && document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed') === 'true'", 'recording, and the mic says so');
await until(p, "window.__recording === true && document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed') === 'true'", 'recording, and the mic says so');
await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
await until(p, "[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')].some((m) => /drink water at 3/.test(m.innerText))", 'what was said, asked');
await until(p, "[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')].some((m) => /drink water at 3/.test(m.innerText))", 'what was said, asked');
assert.deepStrictEqual(listened[0], { type: 'audio/ogg', bytes: 4096, lang: 'en', auth: 'Bearer tok_user' }, 'the recording goes to Chat as itself, with the person\'s token');
assert.deepStrictEqual(listened[0], { type: 'audio/ogg', bytes: 4096, lang: 'en', auth: 'Bearer tok_user' }, 'the recording goes to Chat as itself, with the person\'s token');
assert.strictEqual(asked[0].message, 'remind me to drink water at 3');
assert.strictEqual(asked[0].message, 'remind me to drink water at 3');
- assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false');
+ await eventually(async () => assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false'));
await p.close();
await p.close();
}
}
@@ -711,11 +712,11 @@ async function drag(p, from, to) {
@@ -711,11 +712,11 @@ async function drag(p, from, to) {
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, "!!document.querySelector('.openvibe-navbar')", 'the navbar');
await until(p, "!!document.querySelector('.openvibe-navbar')", 'the navbar');
await wait(600);
await wait(600);
- assert.strictEqual(await p.evaluate("!!document.getElementById('ov-glyphs-loader')"), false, 'no icon on the page: no glyphs loaded');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.getElementById('ov-glyphs-loader')"), false, 'no icon on the page: no glyphs loaded'));
await p.evaluate("(() => { const i = document.createElement('i'); i.className = 'fa-solid fa-gear'; i.id = 'late-icon'; document.querySelector('main').prepend(i); })()");
await p.evaluate("(() => { const i = document.createElement('i'); i.className = 'fa-solid fa-gear'; i.id = 'late-icon'; document.querySelector('main').prepend(i); })()");
await until(p, "!!document.getElementById('ov-glyphs-loader') && !!window.OVGlyphs", 'the glyphs load once an icon appears');
await until(p, "!!document.getElementById('ov-glyphs-loader') && !!window.OVGlyphs", 'the glyphs load once an icon appears');
await until(p, "(() => { const e = document.getElementById('late-icon'); return !e || !!e.querySelector('svg') || e.tagName.toLowerCase() === 'svg'; })()", 'and the late icon is drawn');
await until(p, "(() => { const e = document.getElementById('late-icon'); return !e || !!e.querySelector('svg') || e.tagName.toLowerCase() === 'svg'; })()", 'and the late icon is drawn');
- assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"ov-glyphs.js\"]').length"), 1, 'once');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"ov-glyphs.js\"]').length"), 1, 'once'));
await p.close();
await p.close();
}
}
@@ -769,14 +770,14 @@ async function drag(p, from, to) {
@@ -769,14 +770,14 @@ async function drag(p, from, to) {
const p = await fresh(1280);
const p = await fresh(1280);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
await wait(700);
await wait(700);
- assert.strictEqual(await p.evaluate(PILL), null, 'no pill without the route');
+ await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill without the route'));
const off = await p.evaluate("JSON.parse(localStorage.getItem('ov-dock') || 'null')");
const off = await p.evaluate("JSON.parse(localStorage.getItem('ov-dock') || 'null')");
assert.ok(off && off.off > Date.now() + 23 * 3600e3, 'and it waits a day before asking again');
assert.ok(off && off.off > Date.now() + 23 * 3600e3, 'and it waits a day before asking again');
await p.evaluate("localStorage.removeItem('ov-dock')");
await p.evaluate("localStorage.removeItem('ov-dock')");
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
await p.goto(`${base}/page?in=0`, { maxMs: 5000 });
await p.goto(`${base}/page?in=0`, { maxMs: 5000 });
await wait(600);
await wait(600);
- assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all'));
await p.close();
await p.close();
}
}
// ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──
// ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──
@@ -790,7 +791,7 @@ async function drag(p, from, to) {
@@ -790,7 +791,7 @@ async function drag(p, from, to) {
await until(p, CARD, 'the Dock opens');
await until(p, CARD, 'the Dock opens');
const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');
assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab'));
await wait(400);
await wait(400);
assert.strictEqual(asked.length, before, 'nothing is sent for the person');
assert.strictEqual(asked.length, before, 'nothing is sent for the person');
await p.close();
await p.close();
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// task with the person's token and the list reads again; Stop stops it; signed out, it says to sign in; when Codes cannot
// task with the person's token and the list reads again; Stop stops it; signed out, it says to sign in; when Codes cannot
// be reached, the box opens openvibe.codes with what was written in it.
// be reached, the box opens openvibe.codes with what was written in it.
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('node:fs');
const fs = require('node:fs');
const os = require('node:os');
const os = require('node:os');
const path = require('node:path');
const path = require('node:path');
@@ -87,7 +88,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
@@ -87,7 +88,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
b = await page.evaluate(`({ text: document.querySelector('.ovdc-text').value, note: document.querySelector('.ovdc-note').textContent, focused: document.activeElement === document.querySelector('.ovdc-text'), go: document.querySelector('.ovdc-go').disabled })`);
b = await page.evaluate(`({ text: document.querySelector('.ovdc-text').value, note: document.querySelector('.ovdc-note').textContent, focused: document.activeElement === document.querySelector('.ovdc-text'), go: document.querySelector('.ovdc-go').disabled })`);
assert.deepEqual(b, { text: 'Build a goal bar for my sub goal', note: 'From your chat with Vibe. Start it, or change it first.', focused: true, go: false }, 'handed-over words are in the box, ready to start (My code keeps its repository)');
assert.deepEqual(b, { text: 'Build a goal bar for my sub goal', note: 'From your chat with Vibe. Start it, or change it first.', focused: true, go: false }, 'handed-over words are in the box, ready to start (My code keeps its repository)');
await page.evaluate(`document.querySelector('[data-mode="create"]').click()`);
await page.evaluate(`document.querySelector('[data-mode="create"]').click()`);
- assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false);
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false));
await page.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = ''; t.dispatchEvent(new Event('input')); })()`);
await page.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = ''; t.dispatchEvent(new Event('input')); })()`);
// Stop.
// Stop.
@@ -101,7 +102,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
@@ -101,7 +102,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
const out = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });
const out = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });
await out.goto(base + '/?out=1', { maxMs: 5000 });
await out.goto(base + '/?out=1', { maxMs: 5000 });
await wait(300);
await wait(300);
- assert.match(await out.evaluate(`document.querySelector('.ovdc-list').textContent`), /Sign in to start coding tasks/);
+ await eventually(async () => assert.match(await out.evaluate(`document.querySelector('.ovdc-list').textContent`), /Sign in to start coding tasks/));
assert.equal(seen.length, before, 'nothing is asked without a token');
assert.equal(seen.length, before, 'nothing is asked without a token');
await out.close();
await out.close();
@@ -110,7 +111,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
@@ -110,7 +111,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
const down = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });
const down = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });
await down.goto(base + '/', { maxMs: 5000 });
await down.goto(base + '/', { maxMs: 5000 });
await wait(300);
await wait(300);
- assert.match(await down.evaluate(`document.querySelector('.ovdc-list').textContent`), /Your tasks are on openvibe\.codes/);
+ await eventually(async () => assert.match(await down.evaluate(`document.querySelector('.ovdc-list').textContent`), /Your tasks are on openvibe\.codes/));
await down.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = 'A goal bar'; t.dispatchEvent(new Event('input')); document.querySelector('.ovdc-go').click(); })()`);
await down.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = 'A goal bar'; t.dispatchEvent(new Event('input')); document.querySelector('.ovdc-go').click(); })()`);
const went = await (async () => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await down.evaluate('location.pathname + location.search').catch(() => ''); if (v.startsWith('/codes')) return v; await wait(50); } return ''; })();
const went = await (async () => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await down.evaluate('location.pathname + location.search').catch(() => ''); if (v.startsWith('/codes')) return v; await wait(50); } return ''; })();
assert.equal(went, '/codes/?prompt=A+goal+bar&mode=create', 'it opens Codes with what you wrote');
assert.equal(went, '/codes/?prompt=A+goal+bar&mode=create', 'it opens Codes with what you wrote');
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+// A browser test checks the page after it is ready, not after a guessed sleep (2026-11-11, the owner: "Can we fix the
+// Mac OS bug once and for all?"). The macOS runners are slow: menu-places-chrome slept 180 ms for a 140 ms debounce and
+// failed now and then, holding back releases 4.202.0 and 4.203.0. A check after `await wait(n)` now goes through
+// browser-harness's eventually() (or the test's own until()), which retries until the page is there.
+//
+// This is a ratchet: test/fixtures/fixed-waits.json counts the sleep-then-assert places each file still has. A file may
+// lose them, never gain them, and a new file starts at none. Lower the number in the fixture when you fix one.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const DIR = __dirname;
+const baseline = JSON.parse(fs.readFileSync(path.join(DIR, 'fixtures', 'fixed-waits.json'), 'utf8'));
+
+/** The sleep-then-assert places in one file: an `await wait(<number>)`, then within two lines a bare assert. */
+function count(text) {
+ const lines = text.split('\n');
+ let n = 0;
+ for (let i = 0; i < lines.length; i++) {
+ if (!/await\s+wait\(\s*\d+\s*\)/.test(lines[i])) continue;
+ const same = lines[i].slice(lines[i].search(/await\s+wait\(/));
+ const next = [same.replace(/^await\s+wait\(\s*\d+\s*\)\s*;?/, ''), lines[i + 1] || '', lines[i + 2] || ''];
+ // Only a check that reads the page now (an evaluate) races the sleep; one of a value measured before does not.
+ if (next.some((l) => /^\s*assert[.(]/.test(l) && /evaluate\(|inside\(\)/.test(l) && !/eventually\(|until\(/.test(l))) n++;
+ }
+ return n;
+}
+
+const now = {};
+for (const f of fs.readdirSync(DIR).filter((x) => x.endsWith('.test.js')).sort()) {
+ const n = count(fs.readFileSync(path.join(DIR, f), 'utf8'));
+ if (n) now[f] = n;
+}
+const grew = Object.entries(now).filter(([f, n]) => n > (baseline[f] || 0)).map(([f, n]) => `${f}: ${n} (was ${baseline[f] || 0})`);
+assert.deepStrictEqual(grew, [], 'a check right after a fixed sleep: use eventually() from browser-harness (or until()) instead');
+const better = Object.entries(baseline).filter(([f, n]) => (now[f] || 0) < n).map(([f, n]) => `${f}: ${now[f] || 0} (fixture says ${n})`);
+assert.deepStrictEqual(better, [], 'fewer than the fixture says: lower test/fixtures/fixed-waits.json so it cannot grow back');
+assert.strictEqual(count(fs.readFileSync(path.join(DIR, 'menu-places-chrome.test.js'), 'utf8')), 0, 'the test that held back 4.202 and 4.203 has none');
+console.log(`fixed-waits: ${Object.values(now).reduce((a, b) => a + b, 0)} sleep-then-assert places left in ${Object.keys(now).length} files, none new`);
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{}
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
// and more optimized"). Live's shell carries hidden modal forms, so every Live page fetched ov-forms.js and .css
// and more optimized"). Live's shell carries hidden modal forms, so every Live page fetched ov-forms.js and .css
// (17 KB) for selects nobody saw. A real headless Chrome; skipped without Chrome.
// (17 KB) for selects nobody saw. A real headless Chrome; skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -45,12 +46,12 @@ const server = http.createServer((req, res) => {
@@ -45,12 +46,12 @@ const server = http.createServer((req, res) => {
const p = await h.openPage(browser, context, { width: 390 });
const p = await h.openPage(browser, context, { width: 390 });
await p.goto(`${base}/hidden`, { maxMs: 5000 });
await p.goto(`${base}/hidden`, { maxMs: 5000 });
await wait(3000);
await wait(3000);
- assert.strictEqual(await p.evaluate(asked), false, 'a hidden dialog\'s select does not fetch the forms kit');
- assert.strictEqual(await p.evaluate('!!window.OVForms'), false);
+ await eventually(async () => assert.strictEqual(await p.evaluate(asked), false, 'a hidden dialog\'s select does not fetch the forms kit'));
+ await eventually(async () => assert.strictEqual(await p.evaluate('!!window.OVForms'), false));
await p.evaluate("(document.getElementById('modal').hidden = false, 1)");
await p.evaluate("(document.getElementById('modal').hidden = false, 1)");
assert.ok(await until(p, '!!window.OVForms'), 'the kit loads when the dialog opens');
assert.ok(await until(p, '!!window.OVForms'), 'the kit loads when the dialog opens');
- assert.strictEqual(await p.evaluate(asked), true);
+ await eventually(async () => assert.strictEqual(await p.evaluate(asked), true));
const q = await h.openPage(browser, context, { width: 390 });
const q = await h.openPage(browser, context, { width: 390 });
await q.goto(`${base}/shown`, { maxMs: 5000 });
await q.goto(`${base}/shown`, { maxMs: 5000 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+// 4.204.0, buttons that show what they do and what is happening (docs/kit.md#buttons-42040): the builder's options,
+// the sheet's rules (every motion scaled by --ov-move), and busy() on a fake button.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const kit = require('../kit.js');
+
+const hero = kit.button({ label: 'Go live', icon: 'live', kind: 'hero', nudge: 'forward', iconEnd: 'chevron', attention: true, badge: 120 });
+assert.match(hero, /^<button class="ovk-btn ovk-btn--hero" type="submit" data-nudge="forward" data-attention="" data-badge="99\+">/);
+assert.ok((hero.match(/<svg/g) || []).length === 2, 'an icon before and one after the words');
+assert.match(kit.button({ label: 'Delete', kind: 'danger', hold: 99999, type: 'button' }), /data-hold="4000"/, 'a hold is 4 s at most');
+assert.match(kit.button({ label: 'Delete', kind: 'danger', hold: 10, type: 'button' }), /data-hold="400"/, 'and 0.4 s at least');
+assert.match(kit.button({ label: 'Following', kind: 'soft', pressed: true, dot: 'live', type: 'button' }), /data-dot="live" aria-pressed="true"/);
+assert.ok(!/data-badge/.test(kit.button({ label: 'x', badge: 0 })), 'no badge for nothing');
+assert.match(kit.button({ label: '<b>', attrs: { 'data-x': '1' } }), /data-x="1"><span><b><\/span>/, 'the words are text and other attributes stay');
+
+const css = fs.readFileSync(path.join(__dirname, '..', 'kit.css'), 'utf8');
+for (const k of ['hero', 'soft', 'live']) assert.ok(css.includes(`.ovk-btn--${k} {`), k);
+for (const sel of ['[data-attention]', '[data-badge]', '[data-dot="ok"]', '[data-state="busy"]', '[data-state="done"]', '[data-state="error"]', '[data-hold]', '--ovk-progress']) assert.ok(css.includes(sel), sel);
+const start = css.indexOf('Buttons that show what they do');
+const block = css.slice(start, css.indexOf('@media (hover: none) { .ovk-btn:hover', start));
+const moves = block.match(/(translate[XY]?|rotate|scale)\([^;]*\)/g) || [];
+assert.ok(moves.length >= 10 && moves.every((m) => /--ov-move/.test(m) || /\((0|0px)\)$/.test(m)), 'every move is scaled by --ov-move: ' + moves.filter((m) => !/--ov-move/.test(m) && !/\((0|0px)\)$/.test(m)).join(' | '));
+
+// busy(): a fake document and button.
+global.document = { readyState: 'complete', addEventListener() {}, createElement: () => ({ className: '', innerHTML: '', setAttribute() {}, remove() { this.gone = true; } }) };
+const web = require('../kit-web.js');
+const btn = { dataset: {}, disabled: false, attrs: {}, kids: [], setAttribute(k, v) { this.attrs[k] = v; }, removeAttribute(k) { delete this.attrs[k]; }, appendChild(c) { this.kids.push(c); } };
+(async () => {
+ const p = web.busy(btn, Promise.resolve(7));
+ assert.strictEqual(btn.dataset.state, 'busy'); assert.strictEqual(btn.attrs['aria-busy'], 'true'); assert.strictEqual(btn.disabled, true);
+ assert.strictEqual(await p, 7);
+ assert.strictEqual(btn.dataset.state, 'done'); assert.strictEqual(btn.disabled, false); assert.ok(btn.kids[0].gone);
+ await assert.rejects(web.busy(btn, () => Promise.reject(new Error('no'))), /no/);
+ assert.strictEqual(btn.dataset.state, 'error');
+ console.log('kit-buttons: kinds, status, attention, hold and busy');
+ process.exit(0);
+})();
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// sent without asking. This checks each of those, and the tool frame's clear button and "Try" examples, in the page.
// sent without asking. This checks each of those, and the tool frame's clear button and "Try" examples, in the page.
// Skipped without Chrome.
// Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -50,27 +51,27 @@ const server = http.createServer((req, res) => {
@@ -50,27 +51,27 @@ const server = http.createServer((req, res) => {
const p = await h.openPage(browser, context, { width: 390 });
const p = await h.openPage(browser, context, { width: 390 });
await p.goto(`${base}/`, { maxMs: 5000 });
await p.goto(`${base}/`, { maxMs: 5000 });
await wait(200);
await wait(200);
- assert.strictEqual(await p.evaluate('Math.round(scrollY)'), 0, 'tabs below the fold never scroll the page to themselves');
- assert.ok(await p.evaluate("document.querySelector('.ovk-tabs').scrollLeft") > 0, 'but their strip shows the open tab');
+ await eventually(async () => assert.strictEqual(await p.evaluate('Math.round(scrollY)'), 0, 'tabs below the fold never scroll the page to themselves'));
+ await eventually(async () => assert.ok(await p.evaluate("document.querySelector('.ovk-tabs').scrollLeft") > 0, 'but their strip shows the open tab'));
const hasClipboard = await p.evaluate('!!navigator.clipboard');
const hasClipboard = await p.evaluate('!!navigator.clipboard');
if (hasClipboard) assert.strictEqual(await p.evaluate("document.querySelector('[data-ovk-copy]').hidden"), false, 'the copy button shows where the clipboard works');
if (hasClipboard) assert.strictEqual(await p.evaluate("document.querySelector('[data-ovk-copy]').hidden"), false, 'the copy button shows where the clipboard works');
await p.evaluate("document.querySelector('[data-ovk-sheet]').click()");
await p.evaluate("document.querySelector('[data-ovk-sheet]').click()");
await wait(100);
await wait(100);
- assert.strictEqual(await p.evaluate("document.getElementById('sh').open"), true, 'the sheet button opens its sheet');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('sh').open"), true, 'the sheet button opens its sheet'));
await p.evaluate("document.querySelector('#sh [data-ovk-close]').click()");
await p.evaluate("document.querySelector('#sh [data-ovk-close]').click()");
await p.evaluate("document.querySelector('form[data-ovk-confirm] button').click()");
await p.evaluate("document.querySelector('form[data-ovk-confirm] button').click()");
await wait(100);
await wait(100);
- assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Delete it?'], 'a form that asks first asks');
- assert.ok(!(await p.evaluate("window.__sent.some(([a]) => a === '/gone')")), 'and is not sent when the answer is no');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Delete it?'], 'a form that asks first asks'));
+ await eventually(async () => assert.ok(!(await p.evaluate("window.__sent.some(([a]) => a === '/gone')")), 'and is not sent when the answer is no'));
// The tool frame: the clear button follows the box; a Try example fills the box and runs the tool.
// The tool frame: the clear button follows the box; a Try example fills the box and runs the tool.
- assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), true, 'an empty box hides its clear button');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), true, 'an empty box hides its clear button'));
await p.evaluate("document.querySelector('a[data-ovk-try]').click()");
await p.evaluate("document.querySelector('a[data-ovk-try]').click()");
await wait(100);
await wait(100);
- assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), 'openvibe.network', 'the example fills the box');
- assert.ok(await p.evaluate("window.__sent.some(([, q]) => q === 'openvibe.network')"), 'and runs the tool (its form is sent)');
- assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), false, 'a box with text shows its clear button');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), 'openvibe.network', 'the example fills the box'));
+ await eventually(async () => assert.ok(await p.evaluate("window.__sent.some(([, q]) => q === 'openvibe.network')"), 'and runs the tool (its form is sent)'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), false, 'a box with text shows its clear button'));
await p.evaluate("document.querySelector('.ovk-tool-clear').click()");
await p.evaluate("document.querySelector('.ovk-tool-clear').click()");
- assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), '', 'which empties it');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), '', 'which empties it'));
await p.close();
await p.close();
console.log(`kit-web-chrome: ${hasClipboard ? 'copy shows, ' : ''}sheets open, confirms ask, the tool frame's clear and Try work ok`);
console.log(`kit-web-chrome: ${hasClipboard ? 'copy shows, ' : ''}sheets open, confirms ask, the tool frame's clear and Try work ok`);
} finally {
} finally {
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// area, named for a screen reader, and a tap closes the menu. On a wide screen the button keeps its word, Close.
// area, named for a screen reader, and a tap closes the menu. On a wide screen the button keeps its word, Close.
// Skipped without Chrome.
// Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -64,26 +65,26 @@ async function openMenu(p) {
@@ -64,26 +65,26 @@ async function openMenu(p) {
await openMenu(p);
await openMenu(p);
const x = await p.evaluate(BUTTON);
const x = await p.evaluate(BUTTON);
assert.ok(x && x.shown, 'the close button shows on a phone');
assert.ok(x && x.shown, 'the close button shows on a phone');
- assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the menu locks the page without moving it');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the menu locks the page without moving it'));
assert.strictEqual(x.vw, 390, 'a phone layout');
assert.strictEqual(x.vw, 390, 'a phone layout');
assert.ok(x.w >= 40 && x.h >= 40 && x.right <= x.vw && x.top >= 0, `a 40 px target on screen: ${JSON.stringify(x)}`);
assert.ok(x.w >= 40 && x.h >= 40 && x.right <= x.vw && x.top >= 0, `a 40 px target on screen: ${JSON.stringify(x)}`);
assert.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');
assert.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');
- assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovmb .ovmb-app').length === 3 && getComputedStyle(o.querySelector('.ovmb-bar')).transform !== 'none'; })()`), true, 'its bar and three apps have crossed into an x');
- assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovmb .ovmb-app').length === 3 && getComputedStyle(o.querySelector('.ovmb-bar')).transform !== 'none'; })()`), true, 'its bar and three apps have crossed into an x'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb'));
const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);
const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);
assert.ok(hit, 'the hit area reaches past the circle');
assert.ok(hit, 'the hit area reaches past the circle');
- assert.ok(await p.evaluate('document.activeElement.classList.contains("ovs-orb")'), 'the phone menu keeps focus on its orb');
+ await eventually(async () => assert.ok(await p.evaluate('document.activeElement.classList.contains("ovs-orb")'), 'the phone menu keeps focus on its orb'));
await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await p.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await p.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
// Up to three seconds: the fold takes 280 ms, and a slow runner (macOS in CI) has needed more than 450.
// Up to three seconds: the fold takes 280 ms, and a slow runner (macOS in CI) has needed more than 450.
let stillOpen = true;
let stillOpen = true;
for (let i = 0; i < 30 && stillOpen; i += 1) { await wait(100); stillOpen = await p.evaluate('!!document.querySelector(".ovs.is-open")'); }
for (let i = 0; i < 30 && stillOpen; i += 1) { await wait(100); stillOpen = await p.evaluate('!!document.querySelector(".ovs.is-open")'); }
assert.strictEqual(stillOpen, false, 'Escape closes the menu from its orb');
assert.strictEqual(stillOpen, false, 'Escape closes the menu from its orb');
- assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing unlocks the page');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing unlocks the page'));
await openMenu(p);
await openMenu(p);
await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');
await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');
await wait(450); // the menu folds back into the orb
await wait(450); // the menu folds back into the orb
- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'a tap closes the menu');
+ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'a tap closes the menu'));
const wide = await h.openPage(browser, context, { width: 1280, height: 900 });
const wide = await h.openPage(browser, context, { width: 1280, height: 900 });
await wide.goto(`${base}/`, { maxMs: 5000 });
await wide.goto(`${base}/`, { maxMs: 5000 });
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// and arrows; ticking one shows it, an arrow reorders, at most four, and the choice is kept on this device. A real
// and arrows; ticking one shows it, an arrow reorders, at most four, and the choice is kept on this device. A real
// headless Chrome; skipped without Chrome.
// headless Chrome; skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -64,11 +65,11 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -64,11 +65,11 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');
// 4.149.0 (the owner: tabs at the top, a Feed tab, remember the last one): this site's tab first, then Feed and You.
// 4.149.0 (the owner: tabs at the top, a Feed tab, remember the last one): this site's tab first, then Feed and You.
- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-starttabs .ovs-tab')].map((t) => t.getAttribute('data-tab') + ':' + t.getAttribute('aria-selected'))`), ['here:true', 'feed:false', 'you:false'], 'three tabs, this site first');
- assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-starttabs .ovs-tab')].map((t) => t.getAttribute('data-tab') + ':' + t.getAttribute('aria-selected'))`), ['here:true', 'feed:false', 'you:false'], 'three tabs, this site first'));
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s'));
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await wait(150);
await wait(150);
- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Top stories', 'Today'], 'by default, every feed (owner, 11-09)');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Top stories', 'Today'], 'by default, every feed (owner, 11-09)'));
// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a
// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a
// picture-sized tile, so the titles line up. A group with no pictures keeps plain icons.
// picture-sized tile, so the titles line up. A group with no pictures keeps plain icons.
const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);
const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);
@@ -78,33 +79,33 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -78,33 +79,33 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');
assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');
- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']);
- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, true, true]);
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']));
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, true, true]));
// Turn two off: the rest show.
// Turn two off: the rest show.
await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);
await wait(80);
await wait(80);
await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
await wait(100);
await wait(100);
- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'a feed turned off goes');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'a feed turned off goes'));
// Tick Today: it shows, after the two.
// Tick Today: it shows, after the two.
await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
await wait(100);
await wait(100);
- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Today']);
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Today']));
// Move Today up twice: first.
// Move Today up twice: first.
await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);
await wait(60);
await wait(60);
await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);
await wait(100);
await wait(100);
- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'Trending', 'New worlds'], 'the arrows order them');
- assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-ovs-feed-move')`), 'up|today:today', 'focus stays on the arrow');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'Trending', 'New worlds'], 'the arrows order them'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-ovs-feed-move')`), 'up|today:today', 'focus stays on the arrow'));
// Untick Trending; tick Top stories: four at most.
// Untick Trending; tick Top stories: four at most.
await p.evaluate(`document.querySelector('[data-ovs-feed="media-hub:trending"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-feed="media-hub:trending"]').click(), true`);
await wait(60);
await wait(60);
await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);
await wait(100);
await wait(100);
- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories']);
- assert.deepStrictEqual(await p.evaluate(`JSON.parse(localStorage.getItem('ovs-feeds'))`), { on: ['today:today', 'games:new-worlds', 'news:top'] }, 'kept on this device');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories']));
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`JSON.parse(localStorage.getItem('ovs-feeds'))`), { on: ['today:today', 'games:new-worlds', 'news:top'] }, 'kept on this device'));
const box = await p.evaluate(`document.documentElement.scrollWidth`);
const box = await p.evaluate(`document.documentElement.scrollWidth`);
assert.ok(box <= 390, `nothing runs off a phone (${box})`);
assert.ok(box <= 390, `nothing runs off a phone (${box})`);
@@ -114,10 +115,10 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -114,10 +115,10 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
await wait(150);
await wait(150);
- assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'here', 'it reopens on this site\'s tab, whatever was open last');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'here', 'it reopens on this site\'s tab, whatever was open last'));
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await wait(250);
await wait(250);
- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order'));
// Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.
// Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.
const ctx2 = await browser.newContext();
const ctx2 = await browser.newContext();
const q = await h.openPage(browser, ctx2, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
const q = await h.openPage(browser, ctx2, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
@@ -131,7 +132,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -131,7 +132,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await until(q, `!!document.querySelector('.ovs-feeds')`);
await until(q, `!!document.querySelector('.ovs-feeds')`);
await q.evaluate(`document.querySelector('[data-ovs-feed="games:new-worlds"]').click(), true`);
await q.evaluate(`document.querySelector('[data-ovs-feed="games:new-worlds"]').click(), true`);
assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');
assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');
- assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });
+ await eventually(async () => assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] }));
await q.close();
await q.close();
console.log('menu-feeds-chrome: every feed by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');
console.log('menu-feeds-chrome: every feed by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');
} finally {
} finally {
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
// one row of names, Show more when there is more, and the composer only when Community says the person can post.
// one row of names, Show more when there is more, and the composer only when Community says the person can post.
// Nothing runs off a 390 px screen. Skipped without Chrome.
// Nothing runs off a 390 px screen. Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -100,11 +101,11 @@ const server = http.createServer((req, res) => {
@@ -100,11 +101,11 @@ const server = http.createServer((req, res) => {
assert.strictEqual(first.length, 13, `thirteen on Home by default (${first})`);
assert.strictEqual(first.length, 13, `thirteen on Home by default (${first})`);
assert.deepStrictEqual(first.slice(-3), ['folder:g-play', 'folder:build', 'folder:tools'], 'the final group, Build and Quick tools folders stay at the end');
assert.deepStrictEqual(first.slice(-3), ['folder:g-play', 'folder:build', 'folder:tools'], 'the final group, Build and Quick tools folders stay at the end');
assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');
assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');
- assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');
- assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');
- assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches');
- assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many');
- assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is'));
+ await eventually(async () => assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab'));
+ await eventually(async () => assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches'));
+ await eventually(async () => assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many'));
+ await eventually(async () => assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip'));
// A long-press starts Edit, and the same press drags the first app onto the third's place.
// A long-press starts Edit, and the same press drags the first app onto the third's place.
await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring
await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring
@@ -113,23 +114,23 @@ const server = http.createServer((req, res) => {
@@ -113,23 +114,23 @@ const server = http.createServer((req, res) => {
await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });
await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });
await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: a0.x, y: a0.y, button: 'left', clickCount: 1 });
await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: a0.x, y: a0.y, button: 'left', clickCount: 1 });
await wait(650);
await wait(650);
- assert.ok(await p.evaluate(`!!document.querySelector('.ovh.is-edit')`), 'a long-press starts Edit');
+ await eventually(async () => assert.ok(await p.evaluate(`!!document.querySelector('.ovh.is-edit')`), 'a long-press starts Edit'));
for (let k = 1; k <= 6; k++) { await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x + ((a2.x - a0.x) * k) / 6, y: a0.y + ((a2.y - a0.y) * k) / 6, button: 'left' }); await wait(40); }
for (let k = 1; k <= 6; k++) { await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x + ((a2.x - a0.x) * k) / 6, y: a0.y + ((a2.y - a0.y) * k) / 6, button: 'left' }); await wait(40); }
await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: a2.x, y: a2.y, button: 'left', clickCount: 1 });
await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: a2.x, y: a2.y, button: 'left', clickCount: 1 });
await wait(450); // a person's pause after a drop (a tap within 300 ms of it is taken as part of the drag)
await wait(450); // a person's pause after a drop (a tap within 300 ms of it is taken as part of the drag)
const dragged = await order(p);
const dragged = await order(p);
assert.strictEqual(dragged.indexOf(first[0]), 2, `the app moved to where it was dropped (${dragged})`);
assert.strictEqual(dragged.indexOf(first[0]), 2, `the app moved to where it was dropped (${dragged})`);
- assert.ok(await p.evaluate(`location.pathname === '/'`), 'the long-press did not open the app');
+ await eventually(async () => assert.ok(await p.evaluate(`location.pathname === '/'`), 'the long-press did not open the app'));
const stored = await p.evaluate(`JSON.parse(localStorage.getItem('ovs-home')).home`);
const stored = await p.evaluate(`JSON.parse(localStorage.getItem('ovs-home')).home`);
assert.deepStrictEqual(stored, dragged, 'the arrangement is kept on the device');
assert.deepStrictEqual(stored, dragged, 'the arrangement is kept on the device');
- assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Your home', 'once arranged it is yours');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Your home', 'once arranged it is yours'));
// Keys: the focused app moves; Delete takes it off Home.
// Keys: the focused app moves; Delete takes it off Home.
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${dragged[0]}"]').focus(), true`);
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${dragged[0]}"]').focus(), true`);
await p.evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })), true`);
await p.evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })), true`);
const keyed = await order(p);
const keyed = await order(p);
assert.strictEqual(keyed.indexOf(dragged[0]), 1, 'ArrowRight moves the focused app one later');
assert.strictEqual(keyed.indexOf(dragged[0]), 1, 'ArrowRight moves the focused app one later');
- assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus'));
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);
const removed = await order(p);
const removed = await order(p);
assert.ok(!removed.includes(keyed[0]) && removed.length === 12, '− takes it off Home');
assert.ok(!removed.includes(keyed[0]) && removed.length === 12, '− takes it off Home');
@@ -148,7 +149,7 @@ const server = http.createServer((req, res) => {
@@ -148,7 +149,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(folder.n, 24, 'the folder shows two dozen tools');
assert.strictEqual(folder.n, 24, 'the folder shows two dozen tools');
assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');
assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');
assert.strictEqual(folder.more, 'All 30 tools →', 'and says how many there are in all');
assert.strictEqual(folder.more, 'All 30 tools →', 'and says how many there are in all');
- assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on You');
+ await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on You'));
// ── Feed, signed out: today, the wall, went live in one row, text only, Show more; no composer.
// ── Feed, signed out: today, the wall, went live in one row, text only, Show more; no composer.
await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
@@ -169,7 +170,7 @@ const server = http.createServer((req, res) => {
@@ -169,7 +170,7 @@ const server = http.createServer((req, res) => {
assert.match(news.head, /What's new \(2 today\)/, 'the recently updated stay on the Feed, counted (library bumps left out)');
assert.match(news.head, /What's new \(2 today\)/, 'the recently updated stay on the Feed, counted (library bumps left out)');
assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');
assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');
assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);
assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);
- assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed');
+ await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed'));
await p.close();
await p.close();
// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.
// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.
@@ -178,38 +179,38 @@ const server = http.createServer((req, res) => {
@@ -178,38 +179,38 @@ const server = http.createServer((req, res) => {
assert.ok(await until(s, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');
assert.ok(await until(s, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');
await openTab(s, 'you');
await openTab(s, 'you');
assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');
assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');
- assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step');
+ await eventually(async () => assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step'));
// You on top: the name, Display (the Look panel) and Settings beside it; then your home, Most used and Jump back in.
// You on top: the name, Display (the Look panel) and Settings beside it; then your home, Most used and Jump back in.
// 4.195.0 (the owner, the old block crossed out: "get rid of this thing … we didn't really need [it] here, other than
// 4.195.0 (the owner, the old block crossed out: "get rid of this thing … we didn't really need [it] here, other than
// like adding your account"): one slim row of its own, and the old account block is not set in the menu at all.
// like adding your account"): one slim row of its own, and the old account block is not set in the menu at all.
- assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top');
- assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu');
- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order');
- assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me > a.ovh-ib').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row');
- assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more');
+ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top'));
+ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu'));
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order'));
+ await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me > a.ovh-ib').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row'));
+ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more'));
assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');
assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');
- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']);
- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site');
- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report');
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']));
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site'));
+ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report'));
// The owner, 2026-11-10: "Your home should be above Jump back in… the thing at the top a little more compact… links
// The owner, 2026-11-10: "Your home should be above Jump back in… the thing at the top a little more compact… links
// to whatever the user uses the most". Your home comes first, then what you use most, then where you were last;
// to whatever the user uses the most". Your home comes first, then what you use most, then where you were last;
// the bar with your name is short; each most-used app is a way in (its icon, a full touch target), not a legend.
// the bar with your name is short; each most-used app is a way in (its icon, a full touch target), not a legend.
- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were');
- assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 64`), 'the row with your name is one short row');
- assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets');
- assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me > *')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'all of it on the screen');
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were'));
+ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 64`), 'the row with your name is one short row'));
+ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets'));
+ await eventually(async () => assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me > *')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'all of it on the screen'));
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => [k.getAttribute('href'), k.getAttribute('aria-label'), !!k.querySelector('.ovh-ic > *')].join('|'))`),
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => [k.getAttribute('href'), k.getAttribute('aria-label'), !!k.querySelector('.ovh-ic > *')].join('|'))`),
['https://openvibe.live/|Live, opened 5 times|true', 'https://openvibe.pics/|Pics, opened 2 times|true'], 'each most-used app is a link with its icon and says how often');
['https://openvibe.live/|Live, opened 5 times|true', 'https://openvibe.pics/|Pics, opened 2 times|true'], 'each most-used app is a link with its icon and says how often');
- assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target');
- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is');
- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
+ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target'));
+ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is'));
+ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start'));
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons'));
await s.evaluate(`document.querySelector('.ovh-sec .ovh-edit').click(), true`);
await s.evaluate(`document.querySelector('.ovh-sec .ovh-edit').click(), true`);
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');
assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');
- assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?");
+ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?"));
// Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each
// Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each
// uploads through Network and the post names them with that text.
// uploads through Network and the post names them with that text.
await s.evaluate(`(async () => {
await s.evaluate(`(async () => {
@@ -220,9 +221,9 @@ const server = http.createServer((req, res) => {
@@ -220,9 +221,9 @@ const server = http.createServer((req, res) => {
const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));
const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));
})(), true`);
})(), true`);
assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');
assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');
- assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows');
+ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows'));
await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);
await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);
- assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on');
+ await eventually(async () => assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on'));
await s.evaluate(`document.querySelector('.ovh-post').click(), true`);
await s.evaluate(`document.querySelector('.ovh-post').click(), true`);
assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');
assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');
const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
@@ -252,7 +253,7 @@ const server = http.createServer((req, res) => {
@@ -252,7 +253,7 @@ const server = http.createServer((req, res) => {
const closing = await s.evaluate(`(() => { window.OpenVibeDock.close(); const dock = document.querySelector('.ovdk'); return { hidden: dock.hidden, out: dock.classList.contains('is-out'), back: dock.querySelector('.ovdk-orbx').classList.contains('is-from'), navIsDock: !!document.querySelector('.ovnav-orb.is-dock') }; })()`);
const closing = await s.evaluate(`(() => { window.OpenVibeDock.close(); const dock = document.querySelector('.ovdk'); return { hidden: dock.hidden, out: dock.classList.contains('is-out'), back: dock.querySelector('.ovdk-orbx').classList.contains('is-from'), navIsDock: !!document.querySelector('.ovnav-orb.is-dock') }; })()`);
assert.ok(!closing.hidden && closing.out && closing.back && !closing.navIsDock, `closing folds it back into the orb first (${JSON.stringify(closing)})`);
assert.ok(!closing.hidden && closing.out && closing.back && !closing.navIsDock, `closing folds it back into the orb first (${JSON.stringify(closing)})`);
assert.ok(await until(s, `document.querySelector('.ovdk').hidden`), 'then the sheet is gone');
assert.ok(await until(s, `document.querySelector('.ovdk').hidden`), 'then the sheet is gone');
- assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was');
+ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was'));
const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);
const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);
assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');
assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');
await s.close();
await s.close();
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
// (defaultPrevented) draws nothing.
// (defaultPrevented) draws nothing.
// Skipped without Chrome.
// Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -73,8 +74,8 @@ const server = http.createServer((req, res) => {
@@ -73,8 +74,8 @@ const server = http.createServer((req, res) => {
await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Camera').click(), true`);
await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Camera').click(), true`);
assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 1500), 'the menu closed when a page was chosen');
assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 1500), 'the menu closed when a page was chosen');
await wait(300);
await wait(300);
- assert.strictEqual(await p.evaluate('location.pathname'), '/camera', 'the page moved, and the menu did not step Back over it');
- assert.ok(await p.evaluate('history.length') >= before, 'no history entry was taken back');
+ await eventually(async () => assert.strictEqual(await p.evaluate('location.pathname'), '/camera', 'the page moved, and the menu did not step Back over it'));
+ await eventually(async () => assert.ok(await p.evaluate('history.length') >= before, 'no history entry was taken back'));
// Open again on the new page: Camera is marked; swap back to / in place while it is open: Home is marked.
// Open again on the new page: Camera is marked; swap back to / in place while it is open: Home is marked.
await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
@@ -88,7 +89,7 @@ const server = http.createServer((req, res) => {
@@ -88,7 +89,7 @@ const server = http.createServer((req, res) => {
// A click the page handled draws no bar; a link that leaves draws it at once, while the next page loads.
// A click the page handled draws no bar; a link that leaves draws it at once, while the next page loads.
await p.evaluate(`document.getElementById('handled').click(), true`);
await p.evaluate(`document.getElementById('handled').click(), true`);
await wait(100);
await wait(100);
- assert.strictEqual(await p.evaluate(`!!document.querySelector('#ovnav-leave.is-run')`), false, 'a handled click draws nothing');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('#ovnav-leave.is-run')`), false, 'a handled click draws nothing'));
await p.evaluate(`document.getElementById('plain').click(), true`);
await p.evaluate(`document.getElementById('plain').click(), true`);
assert.ok(await until(p, `window.__left === true`, 1500), 'the plain link was clicked');
assert.ok(await until(p, `window.__left === true`, 1500), 'the plain link was clicked');
assert.ok(await until(p, `(() => { const b = document.getElementById('ovnav-leave'); return !!b && b.classList.contains('is-run') && getComputedStyle(b).position === 'fixed' && parseFloat(getComputedStyle(b).height) === 3; })()`, 1500), 'the top bar runs as the page is left');
assert.ok(await until(p, `(() => { const b = document.getElementById('ovnav-leave'); return !!b && b.classList.contains('is-run') && getComputedStyle(b).position === 'fixed' && parseFloat(getComputedStyle(b).height) === 3; })()`, 1500), 'the top bar runs as the page is left');
@@ -10,6 +10,7 @@ const os = require('node:os');
@@ -10,6 +10,7 @@ const os = require('node:os');
const path = require('node:path');
const path = require('node:path');
const http = require('node:http');
const http = require('node:http');
const h = require('../browser-harness');
const h = require('../browser-harness');
+const { eventually } = h; // a check retries until the page is there (5 s ceiling), never a fixed sleep
if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-places-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-places-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title><link rel="icon" href="data:,"></head>
const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title><link rel="icon" href="data:,"></head>
@@ -49,39 +50,39 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -49,39 +50,39 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate(OPEN(true).replace("service: 'chat'", "service: 'design'"));
await page.evaluate(OPEN(true).replace("service: 'chat'", "service: 'design'"));
assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places on another site`);
assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places on another site`);
- assert.deepEqual((await page.evaluate(ROW)).slice(2, 4).map((t) => t.label), ['Chat', 'Vibe'], `${name}: on another site, Chat and Vibe`);
+ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).slice(2, 4).map((t) => t.label), ['Chat', 'Vibe'], `${name}: on another site, Chat and Vibe`));
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate(OPEN(true)); await wait(350);
await page.evaluate(OPEN(true)); await wait(350);
}
}
assert.ok(row.every((t) => t.top === row[0].top), `${name}: one row (${row.map((t) => t.top).join(',')})`);
assert.ok(row.every((t) => t.top === row[0].top), `${name}: one row (${row.map((t) => t.top).join(',')})`);
assert.ok(row.every((t) => Math.abs(t.w - row[0].w) <= 1 && t.h >= 42), `${name}: the same width each, and tall enough to press (${row.map((t) => `${t.w}x${t.h}`).join(' ')})`);
assert.ok(row.every((t) => Math.abs(t.w - row[0].w) <= 1 && t.h >= 42), `${name}: the same width each, and tall enough to press (${row.map((t) => `${t.w}x${t.h}`).join(' ')})`);
assert.ok(row.every((t) => t.role === 'tab') && row[row.length - 1].right <= width, `${name}: all on the screen`);
assert.ok(row.every((t) => t.role === 'tab') && row[row.length - 1].right <= width, `${name}: all on the screen`);
- assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).map((t) => t.dataset.tab)`), ['here', 'feed', 'chats', 'vibe', 'you'], `${name}: stable place ids`);
- assert.equal(await page.evaluate('OpenVibeSearch._state().from === document.body'), true, `${name}: opening source retained`);
- assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`);
- assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`);
+ await eventually(async () => assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).map((t) => t.dataset.tab)`), ['here', 'feed', 'chats', 'vibe', 'you'], `${name}: stable place ids`));
+ await eventually(async () => assert.equal(await page.evaluate('OpenVibeSearch._state().from === document.body'), true, `${name}: opening source retained`));
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`));
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);
- assert.equal(await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"] .ovs-tabnew-red').textContent`), '7', `${name}: You has the red unread badge`);
- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-notifications-host [data-role="list"]')?.textContent`), 'five newest', `${name}: You starts with the notifications preview`);
- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more')?.textContent`), 'Show all 12', `${name}: notification expansion count, in the section's head`);
- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead b')?.textContent`), 'Notifications', `${name}: the section is named`);
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"] .ovs-tabnew-red').textContent`), '7', `${name}: You has the red unread badge`));
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-notifications-host [data-role="list"]')?.textContent`), 'five newest', `${name}: You starts with the notifications preview`));
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more')?.textContent`), 'Show all 12', `${name}: notification expansion count, in the section's head`));
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead b')?.textContent`), 'Notifications', `${name}: the section is named`));
await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more').click()`);
await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more').click()`);
- assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`);
+ await eventually(async () => assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);
if (width <= 640) {
if (width <= 640) {
await page.evaluate(`document.querySelector('.ovs-list > li > .ovs-dockyou-b').click()`);
await page.evaluate(`document.querySelector('.ovs-list > li > .ovs-dockyou-b').click()`);
- assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-dock-you button')).map((b) => b.textContent)`), ['Start a timer', 'Keep the Dock button on every page', 'Open the Dock in its own window'], `${name}: The Dock row opens its three actions in place`);
+ await eventually(async () => assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-dock-you button')).map((b) => b.textContent)`), ['Start a timer', 'Keep the Dock button on every page', 'Open the Dock in its own window'], `${name}: The Dock row opens its three actions in place`));
await page.evaluate(`document.querySelector('.ovs-dock-you button').click()`);
await page.evaluate(`document.querySelector('.ovs-dock-you button').click()`);
- assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: You Dock timer action`);
+ await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: You Dock timer action`));
}
}
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="here"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="here"]').click()`);
if (width > 640) {
if (width > 640) {
// A computer opens the floating Dock beside the menu.
// A computer opens the floating Dock beside the menu.
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);
- assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: Vibe opens the Dock's Vibe`);
+ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: Vibe opens the Dock's Vibe`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`);
- assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`);
- assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`);
+ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`));
+ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`));
}
}
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);
assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);
assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);
@@ -97,32 +98,32 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -97,32 +98,32 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
chosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).join(' ') })`);
chosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).join(' ') })`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);
if (width <= 640) {
if (width <= 640) {
- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`);
- assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`);
+ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`));
+ await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);
- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`);
+ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`));
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
- assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`);
+ await eventually(async () => assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`));
await page.evaluate(`document.querySelector('.ovs-ai-timer').click()`);
await page.evaluate(`document.querySelector('.ovs-ai-timer').click()`);
- assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: AI timer entry`);
+ await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: AI timer entry`));
await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);
await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);
- assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`);
+ await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`));
await page.evaluate(`document.querySelector('.ovs-input').value = ''; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`);
await page.evaluate(`document.querySelector('.ovs-input').value = ''; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);
- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`);
+ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`));
await page.evaluate(`document.querySelector('.ovs-input').value = 'sara'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(180);
await page.evaluate(`document.querySelector('.ovs-input').value = 'sara'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(180);
- assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`);
+ await eventually(async () => assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`));
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));`);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));`);
- assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`);
+ await eventually(async () => assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);
- assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`);
+ await eventually(async () => assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
- assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`);
+ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`));
} else {
} else {
- assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`);
- assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`);
+ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`));
+ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`));
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
}
}
await page.evaluate('delete window.OpenVibeDock');
await page.evaluate('delete window.OpenVibeDock');
@@ -141,7 +142,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -141,7 +142,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
// A visitor: three places, as before.
// A visitor: three places, as before.
await page.evaluate(OPEN(false));
await page.evaluate(OPEN(false));
assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 3`), `${name}: a visitor has this site, Feed and You`);
assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 3`), `${name}: a visitor has this site, Feed and You`);
- assert.deepEqual((await page.evaluate(ROW)).map((t) => t.label), ['Chat', 'Feed', 'You'].map((x, i) => x), `${name}: in that order`);
+ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).map((t) => t.label), ['Chat', 'Feed', 'You'].map((x, i) => x), `${name}: in that order`));
await page.close();
await page.close();
}
}
for (const width of [390, 1280]) {
for (const width of [390, 1280]) {
@@ -154,13 +155,13 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -154,13 +155,13 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
: new Response('{}', { status: 404 }); localStorage.setItem('ovs-feed-seen', '1');`);
: new Response('{}', { status: 404 }); localStorage.setItem('ovs-feed-seen', '1');`);
const endBadge = Date.now() + 7000;
const endBadge = Date.now() + 7000;
while (!await page.evaluate("!!document.querySelector('.ovnav-avatar-count:not([hidden])')") && Date.now() < endBadge) await wait(80);
while (!await page.evaluate("!!document.querySelector('.ovnav-avatar-count:not([hidden])')") && Date.now() < endBadge) await wait(80);
- assert.equal(await page.evaluate("!!document.querySelector('.openvibe-bell, #openvibe-bell-mount')"), false, `${width}px: no bar bell or bell mount`);
- assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`);
+ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-bell, #openvibe-bell-mount')"), false, `${width}px: no bar bell or bell mount`));
+ await eventually(async () => assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`));
await page.evaluate("document.querySelector('#openvibe-avatar-btn').click()"); await wait(500);
await page.evaluate("document.querySelector('#openvibe-avatar-btn').click()"); await wait(500);
- assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`);
- assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications]')"), true, `${width}px avatar opens at Notifications`);
- assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`);
- assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`);
+ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`));
+ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications]')"), true, `${width}px avatar opens at Notifications`));
+ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`));
+ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`));
await page.close();
await page.close();
}
}
// Two pages in one browser context share the Feed seen time; the receiving open menu redraws its badge at once.
// Two pages in one browser context share the Feed seen time; the receiving open menu redraws its badge at once.
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// draws nothing there; Close stops and forgets; on a phone it is a bar across the bottom; a page's own video starting
// draws nothing there; Close stops and forgets; on a phone it is a bar across the bottom; a page's own video starting
// closes it.
// closes it.
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('node:fs');
const fs = require('node:fs');
const os = require('node:os');
const os = require('node:os');
const path = require('node:path');
const path = require('node:path');
@@ -80,7 +81,7 @@ const server = http.createServer((req, res) => {
@@ -80,7 +81,7 @@ const server = http.createServer((req, res) => {
await page.evaluate(`document.querySelector('.ovmini-text').click()`);
await page.evaluate(`document.querySelector('.ovmini-text').click()`);
assert.ok(await until(`location.pathname === '/watch' && !!document.getElementById('v') && document.getElementById('v').readyState >= 1`), 'back on its page');
assert.ok(await until(`location.pathname === '/watch' && !!document.getElementById('v') && document.getElementById('v').readyState >= 1`), 'back on its page');
assert.ok(await until(`document.getElementById('v').currentTime >= ${at - 0.05}`), 'the page takes the second back');
assert.ok(await until(`document.getElementById('v').currentTime >= ${at - 0.05}`), 'the page takes the second back');
- assert.deepEqual(await page.evaluate(`[!!document.querySelector('.ovmini'), OpenVibeMini.record()]`), [false, null], 'no small player on the video\'s own page, and nothing left written down');
+ await eventually(async () => assert.deepEqual(await page.evaluate(`[!!document.querySelector('.ovmini'), OpenVibeMini.record()]`), [false, null], 'no small player on the video\'s own page, and nothing left written down'));
// Close stops and forgets.
// Close stops and forgets.
await take();
await take();
@@ -89,13 +90,13 @@ const server = http.createServer((req, res) => {
@@ -89,13 +90,13 @@ const server = http.createServer((req, res) => {
await page.evaluate(`document.querySelector('.ovmini [data-m="close"]').click()`);
await page.evaluate(`document.querySelector('.ovmini [data-m="close"]').click()`);
assert.ok(await until(`!document.querySelector('.ovmini') && OpenVibeMini.record() === null`), 'closed and forgotten');
assert.ok(await until(`!document.querySelector('.ovmini') && OpenVibeMini.record() === null`), 'closed and forgotten');
await page.goto(`${base}/second`, { maxMs: 6000 }); await wait(300);
await page.goto(`${base}/second`, { maxMs: 6000 }); await wait(300);
- assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page');
+ await eventually(async () => assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page'));
// A file that cannot be played here: the card says so and keeps the way back; nothing follows to the next page.
// A file that cannot be played here: the card says so and keeps the way back; nothing follows to the next page.
await page.goto(`${base}/second`, { maxMs: 6000 });
await page.goto(`${base}/second`, { maxMs: 6000 });
await page.evaluate(`OpenVibeMini.open({ page: '/watch', src: '/no-such-file.webm', title: 'The harbour at night', by: 'Kim', t: 3, playing: true })`);
await page.evaluate(`OpenVibeMini.open({ page: '/watch', src: '/no-such-file.webm', title: 'The harbour at night', by: 'Kim', t: 3, playing: true })`);
assert.ok(await until(`!!document.querySelector('.ovmini.is-broken')`), 'a file that cannot play is said, not hidden');
assert.ok(await until(`!!document.querySelector('.ovmini.is-broken')`), 'a file that cannot play is said, not hidden');
- assert.deepEqual(await page.evaluate(`(() => { const e = document.querySelector('.ovmini'); return [e.querySelector('.ovmini-by').textContent, e.querySelector('[data-m="play"]').hidden, e.querySelector('.ovmini-text').getAttribute('href'), OpenVibeMini.record()]; })()`), ['Open its page to keep watching', true, '/watch', null]);
+ await eventually(async () => assert.deepEqual(await page.evaluate(`(() => { const e = document.querySelector('.ovmini'); return [e.querySelector('.ovmini-by').textContent, e.querySelector('[data-m="play"]').hidden, e.querySelector('.ovmini-text').getAttribute('href'), OpenVibeMini.record()]; })()`), ['Open its page to keep watching', true, '/watch', null]));
await page.evaluate(`OpenVibeMini.close()`);
await page.evaluate(`OpenVibeMini.close()`);
// A phone: one bar across the bottom, the picture at its left, 44 px buttons.
// A phone: one bar across the bottom, the picture at its left, 44 px buttons.
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// the reader touches the scene; and under reduced motion there is no cursor at all. Skipped (exit 0) without Chrome or
// the reader touches the scene; and under reduced motion there is no cursor at all. Skipped (exit 0) without Chrome or
// with OV_SKIP_BROWSER=1.
// with OV_SKIP_BROWSER=1.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -46,17 +47,17 @@ const server = http.createServer((req, res) => {
@@ -46,17 +47,17 @@ const server = http.createServer((req, res) => {
if (now && now !== seen[seen.length - 1]) seen.push(now);
if (now && now !== seen[seen.length - 1]) seen.push(now);
}
}
assert.deepStrictEqual(seen, ['First', 'Second', 'Third'], 'the steps in their numbered order, one chosen at a time');
assert.deepStrictEqual(seen, ['First', 'Second', 'Third'], 'the steps in their numbered order, one chosen at a time');
- assert.strictEqual(await p.evaluate('!!document.querySelector(".ov-ghost[aria-hidden=true]")'), true, 'the cursor is there, hidden from assistive tech');
- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'the page stays its width');
+ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ov-ghost[aria-hidden=true]")'), true, 'the cursor is there, hidden from assistive tech'));
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'the page stays its width'));
await p.evaluate('document.querySelector(".row").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); 1');
await p.evaluate('document.querySelector(".row").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); 1');
await wait(200);
await wait(200);
- assert.strictEqual(await p.evaluate(`!document.querySelector(".ov-ghost") && ${chosen} === ""`), true, 'a touch ends it for good');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!document.querySelector(".ov-ghost") && ${chosen} === ""`), true, 'a touch ends it for good'));
const still = await h.openPage(browser, context, { width: 390 });
const still = await h.openPage(browser, context, { width: 390 });
await still.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
await still.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
await still.goto(`${base}/still`, { maxMs: 5000 });
await still.goto(`${base}/still`, { maxMs: 5000 });
await wait(1500);
await wait(1500);
- assert.strictEqual(await still.evaluate('!document.querySelector(".ov-ghost") && document.querySelectorAll("[data-ov-ghost-on]").length === 0'), true, 'motion off: no cursor at all');
+ await eventually(async () => assert.strictEqual(await still.evaluate('!document.querySelector(".ov-ghost") && document.querySelectorAll("[data-ov-ghost-on]").length === 0'), true, 'motion off: no cursor at all'));
const errors = p.state.errors.concat(still.state.errors).filter((e) => !/Failed to load resource/.test(e.text));
const errors = p.state.errors.concat(still.state.errors).filter((e) => !/Failed to load resource/.test(e.text));
assert.deepStrictEqual(errors, [], JSON.stringify(errors));
assert.deepStrictEqual(errors, [], JSON.stringify(errors));
console.log('motion-ghost-chrome: all checks passed');
console.log('motion-ghost-chrome: all checks passed');
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// the magnifier or the drawer's "All sites". Runs in a real headless Chrome (skipped without Chrome or with
// the magnifier or the drawer's "All sites". Runs in a real headless Chrome (skipped without Chrome or with
// OV_SKIP_BROWSER=1), with the Frame answer served through a stubbed fetch.
// OV_SKIP_BROWSER=1), with the Frame answer served through a stubbed fetch.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -61,7 +62,7 @@ const server = http.createServer((req, res) => {
@@ -61,7 +62,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(first.display, false, 'this site\'s tab is its pages and every site');
assert.strictEqual(first.display, false, 'this site\'s tab is its pages and every site');
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click(), true`);
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click(), true`);
await wait(150);
await wait(150);
- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs-displayrow .ovnav-display')`), true, 'Display is on the You tab');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs-displayrow .ovnav-display')`), true, 'Display is on the You tab'));
assert.strictEqual(first.expanded, 'true', 'the burger says the menu is open');
assert.strictEqual(first.expanded, 'true', 'the burger says the menu is open');
// The drawer's "All sites" (kept for sites that ask for the legacy drawer) opens the list of every site.
// The drawer's "All sites" (kept for sites that ask for the legacy drawer) opens the list of every site.
await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);
await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// panel back, and a wide table never makes a phone's page scroll sideways.
// panel back, and a wide table never makes a phone's page scroll sideways.
// node test/ov-canvas-chrome.test.js
// node test/ov-canvas-chrome.test.js
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -61,42 +62,42 @@ const server = http.createServer((req, res) => {
@@ -61,42 +62,42 @@ const server = http.createServer((req, res) => {
await p.goto(`${base}/`, { maxMs: 5000 });
await p.goto(`${base}/`, { maxMs: 5000 });
await wait(200);
await wait(200);
const C = "document.getElementById('c')";
const C = "document.getElementById('c')";
- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November');
- assert.strictEqual(await p.evaluate(`${C}.querySelector('h3').textContent`), 'Lisbon, 14–17 November', 'a heading');
- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-metric-v').textContent`), '€612', 'a metric');
- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-when').textContent`), '5 min ago', 'when it changed, in words');
- assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-check input')].map((i) => i.checked)`), [true, false], 'a checklist');
- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('.ovc-table tbody tr').length`), 2, 'a table');
- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-table td.is-num').textContent`), '118', 'numbers align as numbers');
- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg').length`), 2, 'two charts');
- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg polyline').length`), 1, 'a line');
- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg rect').length`), 2, 'grouped bars');
- assert.ok(await p.evaluate(`${C}.querySelector('table.ovc-sr').textContent.includes('240€')`), "the chart's numbers are a table for a screen reader");
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('h3').textContent`), 'Lisbon, 14–17 November', 'a heading'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-metric-v').textContent`), '€612', 'a metric'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-when').textContent`), '5 min ago', 'when it changed, in words'));
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-check input')].map((i) => i.checked)`), [true, false], 'a checklist'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('.ovc-table tbody tr').length`), 2, 'a table'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-table td.is-num').textContent`), '118', 'numbers align as numbers'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg').length`), 2, 'two charts'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg polyline').length`), 1, 'a line'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg rect').length`), 2, 'grouped bars'));
+ await eventually(async () => assert.ok(await p.evaluate(`${C}.querySelector('table.ovc-sr').textContent.includes('240€')`), "the chart's numbers are a table for a screen reader"));
// Only openvibe.media pictures, only https links, never markup, never a block it does not know.
// Only openvibe.media pictures, only https links, never markup, never a block it does not know.
- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('img')].every((i) => i.getAttribute('src').startsWith('https://openvibe.media/'))"), true, 'only openvibe.media pictures (one that fails to load is removed)');
+ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('img')].every((i) => i.getAttribute('src').startsWith('https://openvibe.media/'))"), true, 'only openvibe.media pictures (one that fails to load is removed)'));
assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('a')].map((a) => [a.getAttribute('href'), a.target, a.rel, a.querySelector('span').textContent])`),
assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('a')].map((a) => [a.getAttribute('href'), a.target, a.rel, a.querySelector('span').textContent])`),
[['https://openvibe.video/watch?v=AbCdEfGhIjK', '_blank', 'noopener noreferrer nofollow', 'openvibe.video']], 'a link shows its host; a javascript: link is not drawn');
[['https://openvibe.video/watch?v=AbCdEfGhIjK', '_blank', 'noopener noreferrer nofollow', 'openvibe.video']], 'a link shows its host; a javascript: link is not drawn');
- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('script, iframe, style:not(#ov-canvas-css)').length`), 0, 'no script, frame or style inside');
- assert.strictEqual(await p.evaluate('window.__xss === undefined'), true, 'text is text');
- assert.match(await p.evaluate(`${C}.querySelector('.ovc-text').textContent`), /^<script>window.__xss = 1<\/script>/, 'shown as written');
- assert.strictEqual(await p.evaluate(`${C}.textContent.includes('never')`), false, 'an html block and a running button are left out');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('script, iframe, style:not(#ov-canvas-css)').length`), 0, 'no script, frame or style inside'));
+ await eventually(async () => assert.strictEqual(await p.evaluate('window.__xss === undefined'), true, 'text is text'));
+ await eventually(async () => assert.match(await p.evaluate(`${C}.querySelector('.ovc-text').textContent`), /^<script>window.__xss = 1<\/script>/, 'shown as written'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.textContent.includes('never')`), false, 'an html block and a running button are left out'));
// A button only asks; a tick tells; a canvas without the callbacks only shows.
// A button only asks; a tick tells; a canvas without the callbacks only shows.
- assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-btn')].map((b) => b.textContent)`), ['Book Casa A']);
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-btn')].map((b) => b.textContent)`), ['Book Casa A']));
await p.evaluate(`${C}.querySelector('.ovc-btn').click()`);
await p.evaluate(`${C}.querySelector('.ovc-btn').click()`);
- assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Book Casa A'], "the label goes to the agent as the person's message");
- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-btn').textContent`), 'Sent');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Book Casa A'], "the label goes to the agent as the person's message"));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-btn').textContent`), 'Sent'));
await p.evaluate(`${C}.querySelectorAll('.ovc-check input')[1].click()`);
await p.evaluate(`${C}.querySelectorAll('.ovc-check input')[1].click()`);
- assert.deepStrictEqual(await p.evaluate('window.__checked'), [['todo', 'hotel', true]], 'the tick names the block and the item');
- assert.strictEqual(await p.evaluate("document.querySelector('#ro .ovc-btn').disabled && document.querySelector('#ro .ovc-check input').disabled"), true, 'no callbacks, nothing to press');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__checked'), [['todo', 'hotel', true]], 'the tick names the block and the item'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#ro .ovc-btn').disabled && document.querySelector('#ro .ovc-check input').disabled"), true, 'no callbacks, nothing to press'));
// An older version never rolls the panel back; a newer one replaces it.
// An older version never rolls the panel back; a newer one replaces it.
- assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 2, title: 'Old' })`), 3);
- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November');
- assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 4, title: 'Newer' })`), 4);
- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Newer');
- assert.strictEqual(await p.evaluate("OVCanvas.valid({ canvas: 'something-else', version: 1, blocks: [] })"), false);
+ await eventually(async () => assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 2, title: 'Old' })`), 3));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 4, title: 'Newer' })`), 4));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Newer'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("OVCanvas.valid({ canvas: 'something-else', version: 1, blocks: [] })"), false));
// A phone: the wide table scrolls in its own box; the page never scrolls sideways.
// A phone: the wide table scrolls in its own box; the page never scrolls sideways.
- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'no sideways scroll at 390 px');
- assert.ok(await p.evaluate(`${C}.querySelector('.ovc-table').scrollWidth > ${C}.querySelector('.ovc-table').clientWidth`), 'the table scrolls inside');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'no sideways scroll at 390 px'));
+ await eventually(async () => assert.ok(await p.evaluate(`${C}.querySelector('.ovc-table').scrollWidth > ${C}.querySelector('.ovc-table').clientWidth`), 'the table scrolls inside'));
if (process.env.OV_SHOT) {
if (process.env.OV_SHOT) {
const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
fs.writeFileSync(process.env.OV_SHOT, Buffer.from(data, 'base64'));
fs.writeFileSync(process.env.OV_SHOT, Buffer.from(data, 'base64'));
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// is followed, the keyboard works, options say their state, and data-ov-native keeps the browser's own. Skipped
// is followed, the keyboard works, options say their state, and data-ov-native keeps the browser's own. Skipped
// without Chrome.
// without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -63,68 +64,68 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
@@ -63,68 +64,68 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
await p.goto(`${base}/`, { maxMs: 5000 });
await p.goto(`${base}/`, { maxMs: 5000 });
await wait(250);
await wait(250);
const btn = "document.querySelector('#agent + .ov-sel')";
const btn = "document.querySelector('#agent + .ov-sel')";
- assert.strictEqual(await p.evaluate("document.getElementById('agent').classList.contains('ov-sel-native')"), true, 'the select is enhanced');
- assert.strictEqual(await p.evaluate(`${btn}.textContent.trim()`), 'Choose…');
- assert.strictEqual(await p.evaluate("!!document.querySelector('#raw + .ov-sel')"), false, 'data-ov-native keeps the browser\'s own');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('agent').classList.contains('ov-sel-native')"), true, 'the select is enhanced'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.textContent.trim()`), 'Choose…'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('#raw + .ov-sel')"), false, 'data-ov-native keeps the browser\'s own'));
assert.deepStrictEqual(await p.evaluate("(() => { const b = document.querySelector('#bare + .ov-sel'); const cs = getComputedStyle(b); return [b.classList.contains('is-bare'), cs.borderTopWidth, cs.backgroundColor, getComputedStyle(b.querySelector('.ov-sel-chev')).display]; })()"),
assert.deepStrictEqual(await p.evaluate("(() => { const b = document.querySelector('#bare + .ov-sel'); const cs = getComputedStyle(b); return [b.classList.contains('is-bare'), cs.borderTopWidth, cs.backgroundColor, getComputedStyle(b.querySelector('.ov-sel-chev')).display]; })()"),
[true, '0px', 'rgba(0, 0, 0, 0)', 'none'], 'a bare select keeps its chip\'s look: no box, no chevron');
[true, '0px', 'rgba(0, 0, 0, 0)', 'none'], 'a bare select keeps its chip\'s look: no box, no chevron');
- assert.strictEqual(await p.evaluate(`${btn}.getAttribute('aria-labelledby').split(' ').some((id) => document.getElementById(id) && document.getElementById(id).tagName === 'LABEL')`), true, 'the button is named by the select\'s label');
- assert.strictEqual(await p.evaluate(`${btn}.getBoundingClientRect().width === document.getElementById('f').getBoundingClientRect().width`), true, 'a full-width select gives a full-width button');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.getAttribute('aria-labelledby').split(' ').some((id) => document.getElementById(id) && document.getElementById(id).tagName === 'LABEL')`), true, 'the button is named by the select\'s label'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.getBoundingClientRect().width === document.getElementById('f').getBoundingClientRect().width`), true, 'a full-width select gives a full-width button'));
await p.evaluate(`${btn}.click()`);
await p.evaluate(`${btn}.click()`);
await wait(80);
await wait(80);
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop.is-open')"), true, 'a click opens the menu');
- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop [role=option]').length"), 4);
- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop [role=option][aria-disabled=true] a.ov-sel-link').getAttribute('href')"), '/setup/opencode', 'a choice that is not ready links to its setup');
- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-desc').textContent"), 'Your own Claude login, on your machine');
- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-badge.is-warn').textContent"), '91% used');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop.is-open')"), true, 'a click opens the menu'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop [role=option]').length"), 4));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop [role=option][aria-disabled=true] a.ov-sel-link').getAttribute('href')"), '/setup/opencode', 'a choice that is not ready links to its setup'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-desc').textContent"), 'Your own Claude login, on your machine'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-badge.is-warn').textContent"), '91% used'));
await p.evaluate(key('ArrowDown'));
await p.evaluate(key('ArrowDown'));
await p.evaluate(key('ArrowDown'));
await p.evaluate(key('ArrowDown'));
await p.evaluate(key('ArrowDown')); // past the end: OpenCode is disabled, Codex stays the last
await p.evaluate(key('ArrowDown')); // past the end: OpenCode is disabled, Codex stays the last
await p.evaluate(key('Enter'));
await p.evaluate(key('Enter'));
await wait(60);
await wait(60);
- assert.strictEqual(await p.evaluate("document.getElementById('agent').value"), 'codex', 'the keyboard chooses, skipping a disabled choice');
- assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and the select says change, as a person\'s choice would');
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'and the menu closes');
- assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'with the focus back on the button');
- assert.match(await p.evaluate(`${btn}.textContent`), /Codex.*91% used/, 'the button shows the choice and its badge');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('agent').value"), 'codex', 'the keyboard chooses, skipping a disabled choice'));
+ await eventually(async () => assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and the select says change, as a person\'s choice would'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'and the menu closes'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'with the focus back on the button'));
+ await eventually(async () => assert.match(await p.evaluate(`${btn}.textContent`), /Codex.*91% used/, 'the button shows the choice and its badge'));
await p.evaluate("document.getElementById('agent').value = 'claude-code'");
await p.evaluate("document.getElementById('agent').value = 'claude-code'");
- assert.match(await p.evaluate(`${btn}.textContent`), /Claude Code/, 'a script that sets the value is followed');
+ await eventually(async () => assert.match(await p.evaluate(`${btn}.textContent`), /Claude Code/, 'a script that sets the value is followed'));
// A long list has a search box, and groups.
// A long list has a search box, and groups.
await p.evaluate("document.querySelector('#long + .ov-sel').click()");
await p.evaluate("document.querySelector('#long + .ov-sel').click()");
await wait(80);
await wait(80);
- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-group').length"), 2);
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-group').length"), 2));
await p.evaluate("(() => { const i = document.querySelector('.ov-sel-pop .ov-sel-search input'); i.value = 'tw'; i.dispatchEvent(new Event('input', { bubbles: true })); })()");
await p.evaluate("(() => { const i = document.querySelector('.ov-sel-pop .ov-sel-search input'); i.value = 'tw'; i.dispatchEvent(new Event('input', { bubbles: true })); })()");
- assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ov-sel-pop .ov-sel-label')].map((x) => x.textContent)"), ['two', 'twelve'], 'typing narrows the list');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ov-sel-pop .ov-sel-label')].map((x) => x.textContent)"), ['two', 'twelve'], 'typing narrows the list'));
await p.evaluate(key('Enter', "document.querySelector('.ov-sel-pop .ov-sel-search input')"));
await p.evaluate(key('Enter', "document.querySelector('.ov-sel-pop .ov-sel-search input')"));
await wait(60);
await wait(60);
- assert.strictEqual(await p.evaluate("document.getElementById('long').value"), 'two');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('long').value"), 'two'));
// A multiple select keeps its menu open and counts.
// A multiple select keeps its menu open and counts.
await p.evaluate("document.querySelector('#many + .ov-sel').click()");
await p.evaluate("document.querySelector('#many + .ov-sel').click()");
await wait(80);
await wait(80);
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[0].click()");
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[0].click()");
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[2].click()");
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[2].click()");
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), true, 'the menu stays open while choosing several');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), true, 'the menu stays open while choosing several'));
await p.evaluate(key('Escape', "document.querySelector('.ov-sel-pop')"));
await p.evaluate(key('Escape', "document.querySelector('.ov-sel-pop')"));
await wait(30);
await wait(30);
- assert.strictEqual(await p.evaluate("document.querySelector('#many + .ov-sel').textContent.trim()"), 'a, c');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#many + .ov-sel').textContent.trim()"), 'a, c'));
// A click elsewhere closes the menu; the form posts what was chosen.
// A click elsewhere closes the menu; the form posts what was chosen.
await p.evaluate("document.querySelector('#agent + .ov-sel').click()");
await p.evaluate("document.querySelector('#agent + .ov-sel').click()");
await wait(60);
await wait(60);
await p.evaluate("document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");
await p.evaluate("document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");
- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'a click elsewhere closes it');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'a click elsewhere closes it'));
await p.evaluate("document.getElementById('send').click()");
await p.evaluate("document.getElementById('send').click()");
- assert.deepStrictEqual(await p.evaluate('window.__sent'), { agent: 'claude-code', many: ['a', 'c'], long: 'two' }, 'the form posts the hidden selects\' values');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__sent'), { agent: 'claude-code', many: ['a', 'c'], long: 'two' }, 'the form posts the hidden selects\' values'));
// A required select left empty: the button says so, not a bubble on a hidden control.
// A required select left empty: the button says so, not a bubble on a hidden control.
await p.evaluate("document.getElementById('agent').value = ''");
await p.evaluate("document.getElementById('agent').value = ''");
await p.evaluate("document.getElementById('f').requestSubmit()");
await p.evaluate("document.getElementById('f').requestSubmit()");
- assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button');
- assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus'));
// A select inside a modal dialog opens over it (the top layer), and can be chosen from.
// A select inside a modal dialog opens over it (the top layer), and can be chosen from.
await p.evaluate("document.getElementById('dlg').showModal()");
await p.evaluate("document.getElementById('dlg').showModal()");
@@ -135,15 +136,15 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
@@ -135,15 +136,15 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
assert.strictEqual(seen, true, `the list is seen over a modal dialog: ${seen}`);
assert.strictEqual(seen, true, `the list is seen over a modal dialog: ${seen}`);
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[1].click()");
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[1].click()");
await wait(60);
await wait(60);
- assert.strictEqual(await p.evaluate("document.getElementById('indlg').value"), 'two', 'and a choice in it lands');
- assert.strictEqual(await p.evaluate("document.getElementById('dlg').open"), true, 'without closing the dialog');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('indlg').value"), 'two', 'and a choice in it lands'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('dlg').open"), true, 'without closing the dialog'));
await p.evaluate("document.getElementById('dlg').close()");
await p.evaluate("document.getElementById('dlg').close()");
// Markup added later is enhanced too.
// Markup added later is enhanced too.
await p.evaluate("document.body.insertAdjacentHTML('beforeend', '<select id=\"late\"><option>x</option><option>y</option></select>')");
await p.evaluate("document.body.insertAdjacentHTML('beforeend', '<select id=\"late\"><option>x</option><option>y</option></select>')");
await wait(200);
await wait(200);
- assert.strictEqual(await p.evaluate("!!document.querySelector('#late + .ov-sel')"), true, 'a select added later is enhanced');
- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('sw')).appearance"), 'none', 'a checkbox with role=switch is a switch');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('#late + .ov-sel')"), true, 'a select added later is enhanced'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('sw')).appearance"), 'none', 'a checkbox with role=switch is a switch'));
await p.close();
await p.close();
// ── A phone: the sheet ──
// ── A phone: the sheet ──
@@ -152,17 +153,17 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
@@ -152,17 +153,17 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
await wait(250);
await wait(250);
await phone.evaluate("document.querySelector('#agent + .ov-sel').click()");
await phone.evaluate("document.querySelector('#agent + .ov-sel').click()");
await wait(320);
await wait(320);
- assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet.is-open') && !!document.querySelector('.ov-sel-scrim.is-open')"), true, 'a phone gets a sheet over a scrim');
- assert.strictEqual(await phone.evaluate("document.querySelector('.ov-sel-sheet-title').textContent"), 'Agent', 'titled with the select\'s label');
- assert.strictEqual(await phone.evaluate("document.documentElement.classList.contains('ov-sel-locked')"), true, 'the page under it does not scroll');
- assert.strictEqual(await phone.evaluate('document.body.style.position'), 'fixed', 'the shared helper locks the page in place');
- assert.ok(await phone.evaluate("document.querySelector('.ov-sel-sheet .ov-sel-opt').getBoundingClientRect().height") >= 44, 'rows are a finger tall');
+ await eventually(async () => assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet.is-open') && !!document.querySelector('.ov-sel-scrim.is-open')"), true, 'a phone gets a sheet over a scrim'));
+ await eventually(async () => assert.strictEqual(await phone.evaluate("document.querySelector('.ov-sel-sheet-title').textContent"), 'Agent', 'titled with the select\'s label'));
+ await eventually(async () => assert.strictEqual(await phone.evaluate("document.documentElement.classList.contains('ov-sel-locked')"), true, 'the page under it does not scroll'));
+ await eventually(async () => assert.strictEqual(await phone.evaluate('document.body.style.position'), 'fixed', 'the shared helper locks the page in place'));
+ await eventually(async () => assert.ok(await phone.evaluate("document.querySelector('.ov-sel-sheet .ov-sel-opt').getBoundingClientRect().height") >= 44, 'rows are a finger tall'));
await phone.evaluate("document.querySelectorAll('.ov-sel-sheet .ov-sel-opt')[1].click()");
await phone.evaluate("document.querySelectorAll('.ov-sel-sheet .ov-sel-opt')[1].click()");
await wait(320);
await wait(320);
- assert.strictEqual(await phone.evaluate("document.getElementById('agent').value"), 'claude-code');
- assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet') || document.documentElement.classList.contains('ov-sel-locked')"), false, 'choosing closes the sheet and frees the page');
- assert.strictEqual(await phone.evaluate('document.body.style.position'), '', 'closing restores the page');
- assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390, 'nothing is wider than the phone');
+ await eventually(async () => assert.strictEqual(await phone.evaluate("document.getElementById('agent').value"), 'claude-code'));
+ await eventually(async () => assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet') || document.documentElement.classList.contains('ov-sel-locked')"), false, 'choosing closes the sheet and frees the page'));
+ await eventually(async () => assert.strictEqual(await phone.evaluate('document.body.style.position'), '', 'closing restores the page'));
+ await eventually(async () => assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390, 'nothing is wider than the phone'));
await phone.close();
await phone.close();
console.log('ov-forms-chrome: the menu, the sheet, the keyboard, search, several choices, the form\'s values and native opt-outs ok');
console.log('ov-forms-chrome: the menu, the sheet, the keyboard, search, several choices, the form\'s values and native opt-outs ok');
} finally {
} finally {
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// locker; making a locker refuses a weak or mismatched passphrase, shows 24 words once and checks two of them; and,
// locker; making a locker refuses a weak or mismatched passphrase, shows 24 words once and checks two of them; and,
// the point of all of it, the passphrase and the recovery words never leave the page, and nothing is stored.
// the point of all of it, the passphrase and the recovery words never leave the page, and nothing is stored.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -84,51 +85,51 @@ const server = http.createServer((req, res) => {
@@ -84,51 +85,51 @@ const server = http.createServer((req, res) => {
// A password lock: only its password opens it; the card says so plainly.
// A password lock: only its password opens it; the card says so plainly.
await p.evaluate(openItem('password:hunter22:the notes'));
await p.evaluate(openItem('password:hunter22:the notes'));
await wait(150);
await wait(150);
- assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /is locked/);
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /is locked/));
await p.evaluate(type('item-pass', 'wrong')); await p.evaluate(submit('item-open')); await wait(120);
await p.evaluate(type('item-pass', 'wrong')); await p.evaluate(submit('item-open')); await wait(120);
- assert.match(await p.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/);
- assert.deepStrictEqual(await p.evaluate('window.__opened'), []);
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/));
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), []));
await p.evaluate(type('item-pass', 'hunter22')); await p.evaluate(submit('item-open')); await wait(120);
await p.evaluate(type('item-pass', 'hunter22')); await p.evaluate(submit('item-open')); await wait(120);
- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes'], 'the right password opens it');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes'], 'the right password opens it'));
// A link lock without its key, then with it.
// A link lock without its key, then with it.
await p.evaluate(openItem('link::linked'));
await p.evaluate(openItem('link::linked'));
await wait(120);
await wait(120);
- assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /missing its key/);
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /missing its key/));
const key = 'A'.repeat(43);
const key = 'A'.repeat(43);
await p.evaluate(openItem('link::linked', `, link: 'https://x.test/p/1#k=${key}'`));
await p.evaluate(openItem('link::linked', `, link: 'https://x.test/p/1#k=${key}'`));
await wait(120);
await wait(120);
- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked'], 'the key after #k= opens it by itself');
- assert.strictEqual(await p.evaluate("OVLocker.keyFromLink('https://x.test/#k=short')"), null, 'a key that is not 32 bytes is no key');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked'], 'the key after #k= opens it by itself'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("OVLocker.keyFromLink('https://x.test/#k=short')"), null, 'a key that is not 32 bytes is no key'));
// A people lock: unlock the locker (wrong passphrase first), then it opens.
// A people lock: unlock the locker (wrong passphrase first), then it opens.
await p.evaluate(openItem('people::for you'));
await p.evaluate(openItem('people::for you'));
await wait(120);
await wait(120);
await p.evaluate(submit('item-unlock')); await wait(300);
await p.evaluate(submit('item-unlock')); await wait(300);
await p.evaluate(type('pass', 'nope')); await p.evaluate(submit('unlock')); await wait(150);
await p.evaluate(type('pass', 'nope')); await p.evaluate(submit('unlock')); await wait(150);
- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not open your locker/);
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not open your locker/));
await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(400);
await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(400);
- assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), true);
- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked', 'for you'], 'unlocked, it opens');
+ await eventually(async () => assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), true));
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked', 'for you'], 'unlocked, it opens'));
await p.evaluate('OVLocker.forget()');
await p.evaluate('OVLocker.forget()');
- assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), false, 'forget() drops the keys');
+ await eventually(async () => assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), false, 'forget() drops the keys'));
// Making a locker: weak, then mismatched, then good; 24 words; two checked; saved locked.
// Making a locker: weak, then mismatched, then good; 24 words; two checked; saved locked.
await p.evaluate('(() => { OVLocker.setup(); })()');
await p.evaluate('(() => { OVLocker.setup(); })()');
await wait(300);
await wait(300);
await p.evaluate(type('pass', 'short')); await p.evaluate(type('pass2', 'short')); await p.evaluate(submit('next')); await wait(80);
await p.evaluate(type('pass', 'short')); await p.evaluate(type('pass2', 'short')); await p.evaluate(submit('next')); await wait(80);
- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /longer/, 'a weak passphrase is refused');
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /longer/, 'a weak passphrase is refused'));
await p.evaluate(type('pass', 'violet lantern river cobalt')); await p.evaluate(type('pass2', 'violet lantern river cobalT')); await p.evaluate(submit('next')); await wait(80);
await p.evaluate(type('pass', 'violet lantern river cobalt')); await p.evaluate(type('pass2', 'violet lantern river cobalT')); await p.evaluate(submit('next')); await wait(80);
- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /do not match/);
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /do not match/));
await p.evaluate(type('pass2', 'violet lantern river cobalt')); await p.evaluate(submit('next')); await wait(300);
await p.evaluate(type('pass2', 'violet lantern river cobalt')); await p.evaluate(submit('next')); await wait(300);
const words = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
const words = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
assert.strictEqual(words.length, 24, 'the kit is 24 words');
assert.strictEqual(words.length, 24, 'the kit is 24 words');
const labels = await p.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");
const labels = await p.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");
await p.evaluate(type('w1', 'wrong')); await p.evaluate(type('w2', 'words')); await p.evaluate(submit('make')); await wait(100);
await p.evaluate(type('w1', 'wrong')); await p.evaluate(type('w2', 'words')); await p.evaluate(submit('make')); await wait(100);
- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /not the words/);
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /not the words/));
assert.ok(!sent.some((s) => s.startsWith('PUT')), 'nothing is saved before the kit is checked');
assert.ok(!sent.some((s) => s.startsWith('PUT')), 'nothing is saved before the kit is checked');
await p.evaluate(type('w1', words[labels[0] - 1])); await p.evaluate(type('w2', words[labels[1] - 1])); await p.evaluate(submit('make')); await wait(400);
await p.evaluate(type('w1', words[labels[0] - 1])); await p.evaluate(type('w2', words[labels[1] - 1])); await p.evaluate(submit('make')); await wait(400);
- assert.strictEqual(await p.evaluate(`${q('fingerprint')}.textContent`), 'cedar ember lumen onyx fable maple gecko iris', 'the fingerprint, eight words');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('fingerprint')}.textContent`), 'cedar ember lumen onyx fable maple gecko iris', 'the fingerprint, eight words'));
const put = sent.find((s) => s.startsWith('PUT /api/v1/me/keys'));
const put = sent.find((s) => s.startsWith('PUT /api/v1/me/keys'));
assert.ok(put, 'the locked copies are saved');
assert.ok(put, 'the locked copies are saved');
assert.ok(!put.includes('violet') && !put.includes('lantern'), 'the passphrase never leaves the page');
assert.ok(!put.includes('violet') && !put.includes('lantern'), 'the passphrase never leaves the page');
@@ -138,7 +139,7 @@ const server = http.createServer((req, res) => {
@@ -138,7 +139,7 @@ const server = http.createServer((req, res) => {
assert.ok(!put.includes('"d":"private"') && !put.includes('private'), 'nor the private keys in the clear');
assert.ok(!put.includes('"d":"private"') && !put.includes('private'), 'nor the private keys in the clear');
assert.match(put, /"backups":\{"passphrase":\{"format":"locker.envelope.v1"/, 'what is saved is two Locker envelopes');
assert.match(put, /"backups":\{"passphrase":\{"format":"locker.envelope.v1"/, 'what is saved is two Locker envelopes');
assert.match(put, /"self_signature":"sig"/, 'with the keys\u2019 self-signature (the SDK\u2019s fingerprint_signature)');
assert.match(put, /"self_signature":"sig"/, 'with the keys\u2019 self-signature (the SDK\u2019s fingerprint_signature)');
- assert.strictEqual(await p.evaluate('localStorage.length + sessionStorage.length'), 0, 'nothing is stored in the browser');
+ await eventually(async () => assert.strictEqual(await p.evaluate('localStorage.length + sessionStorage.length'), 0, 'nothing is stored in the browser'));
// Lost both: a plain warning, a checkbox that gates the button, then a new locker saved with reset: true.
// Lost both: a plain warning, a checkbox that gates the button, then a new locker saved with reset: true.
await p.evaluate('OVLocker.forget()');
await p.evaluate('OVLocker.forget()');
@@ -146,8 +147,8 @@ const server = http.createServer((req, res) => {
@@ -146,8 +147,8 @@ const server = http.createServer((req, res) => {
await p.evaluate('(() => { OVLocker.unlock(); })()'); await wait(300);
await p.evaluate('(() => { OVLocker.unlock(); })()'); await wait(300);
await p.evaluate(submit('other')); await wait(80);
await p.evaluate(submit('other')); await wait(80);
await p.evaluate(submit('lost')); await wait(80);
await p.evaluate(submit('lost')); await wait(80);
- assert.match(await p.evaluate("document.querySelector('dialog .ovl-body').textContent"), /cannot be opened again/, 'it says what is lost');
- assert.strictEqual(await p.evaluate(`${q('lost-go')}.disabled`), true, 'nothing starts until the person says they understand');
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-body').textContent"), /cannot be opened again/, 'it says what is lost'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('lost-go')}.disabled`), true, 'nothing starts until the person says they understand'));
await p.evaluate(`${q('lost-ok')}.click()`); await p.evaluate(submit('lost-go')); await wait(400);
await p.evaluate(`${q('lost-ok')}.click()`); await p.evaluate(submit('lost-go')); await wait(400);
await p.evaluate(type('pass', 'amber window tide falcon')); await p.evaluate(type('pass2', 'amber window tide falcon')); await p.evaluate(submit('next')); await wait(300);
await p.evaluate(type('pass', 'amber window tide falcon')); await p.evaluate(type('pass2', 'amber window tide falcon')); await p.evaluate(submit('next')); await wait(300);
const w2 = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
const w2 = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
@@ -173,7 +174,7 @@ const server = http.createServer((req, res) => {
@@ -173,7 +174,7 @@ const server = http.createServer((req, res) => {
await wait(300);
await wait(300);
authError = { error: 'locker.proof_required' };
authError = { error: 'locker.proof_required' };
await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(150);
await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(150);
- assert.strictEqual(new URL(await p.evaluate("document.querySelector('dialog .ovl-msg a').href")).searchParams.get('reauth'), '1');
+ await eventually(async () => assert.strictEqual(new URL(await p.evaluate("document.querySelector('dialog .ovl-msg a').href")).searchParams.get('reauth'), '1'));
authError = null;
authError = null;
// A phone: the kit fits, no sideways scroll.
// A phone: the kit fits, no sideways scroll.
@@ -182,8 +183,8 @@ const server = http.createServer((req, res) => {
@@ -182,8 +183,8 @@ const server = http.createServer((req, res) => {
await phone.evaluate('(() => { OVLocker.setup(); })()');
await phone.evaluate('(() => { OVLocker.setup(); })()');
await wait(300);
await wait(300);
await phone.evaluate(type('pass', 'violet lantern river cobalt')); await phone.evaluate(type('pass2', 'violet lantern river cobalt')); await phone.evaluate(submit('next')); await wait(300);
await phone.evaluate(type('pass', 'violet lantern river cobalt')); await phone.evaluate(type('pass2', 'violet lantern river cobalt')); await phone.evaluate(submit('next')); await wait(300);
- assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390);
- assert.ok(await phone.evaluate("(() => { const d = document.querySelector('dialog').getBoundingClientRect(); return [...document.querySelectorAll('dialog input')].every((i) => i.getBoundingClientRect().right <= d.right + 0.5); })()"), 'every field fits inside the sheet');
+ await eventually(async () => assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390));
+ await eventually(async () => assert.ok(await phone.evaluate("(() => { const d = document.querySelector('dialog').getBoundingClientRect(); return [...document.querySelectorAll('dialog input')].every((i) => i.getBoundingClientRect().right <= d.right + 0.5); })()"), 'every field fits inside the sheet'));
await phone.close();
await phone.close();
console.log('ov-locker-chrome: password, link and people locks; making and unlocking a locker; nothing secret leaves the page or is stored; the phone ok');
console.log('ov-locker-chrome: password, link and people locks; making and unlocking a locker; nothing secret leaves the page or is stored; the phone ok');
} finally {
} finally {
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
// an item locked in Node for that person's public key opens in the browser; a stranger's locker cannot open it;
// an item locked in Node for that person's public key opens in the browser; a stranger's locker cannot open it;
// a password-locked item opens with its password only; the recovery words open the locker too.
// a password-locked item opens with its password only; the recovery words open the locker too.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -93,16 +94,16 @@ const server = http.createServer((req, res) => {
@@ -93,16 +94,16 @@ const server = http.createServer((req, res) => {
await b.evaluate(type('pass', PASS)); await b.evaluate(press('unlock'));
await b.evaluate(type('pass', PASS)); await b.evaluate(press('unlock'));
await wait(5000);
await wait(5000);
if (process.env.LOCKER_E2E_DEBUG) console.log('DEBUG', await b.evaluate("JSON.stringify({ card: (document.querySelector('.ovl-card') || {}).textContent, msg: (document.querySelector('dialog .ovl-msg') || {}).textContent, state: OVLocker.state() })"));
if (process.env.LOCKER_E2E_DEBUG) console.log('DEBUG', await b.evaluate("JSON.stringify({ card: (document.querySelector('.ovl-card') || {}).textContent, msg: (document.querySelector('dialog .ovl-msg') || {}).textContent, state: OVLocker.state() })"));
- assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only'], 'a new device unlocks with the passphrase and opens what was locked for this person');
+ await eventually(async () => assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only'], 'a new device unlocks with the passphrase and opens what was locked for this person'));
await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/theirs' }); })()`);
await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/theirs' }); })()`);
await wait(800);
await wait(800);
- assert.match(await b.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /not locked for you/, 'someone else’s item says so');
+ await eventually(async () => assert.match(await b.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /not locked for you/, 'someone else’s item says so'));
await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/pw', onOpen: (x) => window.__opened.push(new TextDecoder().decode(x)) }); })()`);
await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/pw', onOpen: (x) => window.__opened.push(new TextDecoder().decode(x)) }); })()`);
await wait(300);
await wait(300);
await b.evaluate(type('item-pass', 'wrong one')); await b.evaluate(press('item-open')); await wait(3500);
await b.evaluate(type('item-pass', 'wrong one')); await b.evaluate(press('item-open')); await wait(3500);
- assert.match(await b.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/);
+ await eventually(async () => assert.match(await b.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/));
await b.evaluate(type('item-pass', 'hunter22 hunter22')); await b.evaluate(press('item-open')); await wait(3500);
await b.evaluate(type('item-pass', 'hunter22 hunter22')); await b.evaluate(press('item-open')); await wait(3500);
- assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only', 'with a password'], 'the password item opens with its password');
+ await eventually(async () => assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only', 'with a password'], 'the password item opens with its password'));
// Device three: the recovery words open the locker too.
// Device three: the recovery words open the locker too.
const c = await h.openPage(browser, await browser.newContext(), { width: 1280 });
const c = await h.openPage(browser, await browser.newContext(), { width: 1280 });
@@ -111,7 +112,7 @@ const server = http.createServer((req, res) => {
@@ -111,7 +112,7 @@ const server = http.createServer((req, res) => {
await c.evaluate(press('other')); await wait(80);
await c.evaluate(press('other')); await wait(80);
await c.evaluate(type('words', words.join(' ').toUpperCase())); await c.evaluate(press('unlock'));
await c.evaluate(type('words', words.join(' ').toUpperCase())); await c.evaluate(press('unlock'));
await wait(5000);
await wait(5000);
- assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker');
+ await eventually(async () => assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker'));
// A PRF-capable authenticator is stubbed at the browser boundary; sealing and opening remain real WebCrypto.
// A PRF-capable authenticator is stubbed at the browser boundary; sealing and opening remain real WebCrypto.
// Hardware availability varies, so the unsupported case must leave setup usable too.
// Hardware availability varies, so the unsupported case must leave setup usable too.
const d = await h.openPage(browser, await browser.newContext(), { width: 390 });
const d = await h.openPage(browser, await browser.newContext(), { width: 390 });
@@ -130,7 +131,7 @@ const server = http.createServer((req, res) => {
@@ -130,7 +131,7 @@ const server = http.createServer((req, res) => {
await wait(300); await d.evaluate(press('setup-passkey')); await wait(1500);
await wait(300); await d.evaluate(press('setup-passkey')); await wait(1500);
const recovery = await d.evaluate("[...document.querySelectorAll('.ovl-words li')].map(x => x.textContent)");
const recovery = await d.evaluate("[...document.querySelectorAll('.ovl-words li')].map(x => x.textContent)");
assert.strictEqual(recovery.length, 24);
assert.strictEqual(recovery.length, 24);
- assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=print]')"), true);
+ await eventually(async () => assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=print]')"), true));
const ask = await d.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map(x => Number(x.textContent.replace(/\\D/g, '')))");
const ask = await d.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map(x => Number(x.textContent.replace(/\\D/g, '')))");
await d.evaluate(type('w1', recovery[ask[0] - 1])); await d.evaluate(type('w2', recovery[ask[1] - 1])); await d.evaluate(press('make')); await wait(9000);
await d.evaluate(type('w1', recovery[ask[0] - 1])); await d.evaluate(type('w2', recovery[ask[1] - 1])); await d.evaluate(press('make')); await wait(9000);
assert.ok(stored.backups.passkey && stored.backups.recovery && !stored.backups.passphrase);
assert.ok(stored.backups.passkey && stored.backups.recovery && !stored.backups.passphrase);
@@ -140,18 +141,18 @@ const server = http.createServer((req, res) => {
@@ -140,18 +141,18 @@ const server = http.createServer((req, res) => {
await d.evaluate(press('done'));
await d.evaluate(press('done'));
await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);
await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);
await d.evaluate(press('unlock-passkey')); await wait(5000);
await d.evaluate(press('unlock-passkey')); await wait(5000);
- assert.strictEqual(await d.evaluate('OVLocker.state().fingerprint'), pkFp, 'PRF opens the same SDK-protected keys');
+ await eventually(async () => assert.strictEqual(await d.evaluate('OVLocker.state().fingerprint'), pkFp, 'PRF opens the same SDK-protected keys'));
items.stream = (await locker.seal('streamed on device', { lock: 'people', keys: [{ subject: { type: 'user', id: 'usr_x' }, publicKey: stored.ecdh_public }] })).bytes;
items.stream = (await locker.seal('streamed on device', { lock: 'people', keys: [{ subject: { type: 'user', id: 'usr_x' }, publicKey: stored.ecdh_public }] })).bytes;
- assert.strictEqual(await d.evaluate("(async () => new Response(await OVLocker.openStream((await fetch('/item/stream')).body)).text())()"), 'streamed on device');
+ await eventually(async () => assert.strictEqual(await d.evaluate("(async () => new Response(await OVLocker.openStream((await fetch('/item/stream')).body)).text())()"), 'streamed on device'));
await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);
await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);
await d.evaluate(type('pass', PASS)); await d.evaluate(press('unlock')); await wait(300);
await d.evaluate(type('pass', PASS)); await d.evaluate(press('unlock')); await wait(300);
- assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /recovery kit/);
- assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=make-instead]')"), false, 'an existing passkey locker is never described as missing');
+ await eventually(async () => assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /recovery kit/));
+ await eventually(async () => assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=make-instead]')"), false, 'an existing passkey locker is never described as missing'));
await d.evaluate("document.querySelector('dialog button[aria-label=Close]').click()");
await d.evaluate("document.querySelector('dialog button[aria-label=Close]').click()");
await d.evaluate(`(() => { OVLocker.forget(); navigator.credentials.create = async () => ({ getClientExtensionResults: () => ({}) }); OVLocker.setup(); })()`);
await d.evaluate(`(() => { OVLocker.forget(); navigator.credentials.create = async () => ({ getClientExtensionResults: () => ({}) }); OVLocker.setup(); })()`);
await wait(300); await d.evaluate(press('setup-passkey')); await wait(300);
await wait(300); await d.evaluate(press('setup-passkey')); await wait(300);
- assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not support Locker/);
- assert.strictEqual(await d.evaluate("document.querySelector('[data-k=setup-passkey]').disabled"), false);
+ await eventually(async () => assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not support Locker/));
+ await eventually(async () => assert.strictEqual(await d.evaluate("document.querySelector('[data-k=setup-passkey]').disabled"), false));
console.log('ov-locker-e2e-chrome: real crypto: make a locker, unlock it on a new device by passphrase and by recovery words, open what was locked for you, refuse what was not');
console.log('ov-locker-e2e-chrome: real crypto: make a locker, unlock it on a new device by passphrase and by recovery words, open what was locked for you, refuse what was not');
} finally {
} finally {
await browser.close();
await browser.close();
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
// and the recipient's banner and the not-on-the-list gate call the right routes. Names and titles are text, never
// and the recipient's banner and the not-on-the-list gate call the right routes. Names and titles are text, never
// markup. A phone gets a sheet no wider than the phone. Skipped without Chrome.
// markup. A phone gets a sheet no wider than the phone. Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -99,22 +100,22 @@ const server = http.createServer((req, res) => {
@@ -99,22 +100,22 @@ const server = http.createServer((req, res) => {
await p.evaluate("document.getElementById('sh').click()");
await p.evaluate("document.getElementById('sh').click()");
await wait(300);
await wait(300);
- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'a [data-ov-share-sheet] button opens the sheet as a modal dialog');
- assert.strictEqual(await p.evaluate("document.getElementById('ov-share-title').textContent"), 'Share “<b>notes</b>”', 'the title is text, never markup');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'a [data-ov-share-sheet] button opens the sheet as a modal dialog'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ov-share-title').textContent"), 'Share “<b>notes</b>”', 'the title is text, never markup'));
// A site's own h2 rule (Wiki: h2 { border-bottom; padding-bottom; margin }) never reaches the sheet's title.
// A site's own h2 rule (Wiki: h2 { border-bottom; padding-bottom; margin }) never reaches the sheet's title.
await p.evaluate("(() => { const st = document.createElement('style'); st.textContent = 'h2 { border-bottom: 3px solid red; padding-bottom: 9px; text-transform: uppercase; }'; document.head.appendChild(st); })()");
await p.evaluate("(() => { const st = document.createElement('style'); st.textContent = 'h2 { border-bottom: 3px solid red; padding-bottom: 9px; text-transform: uppercase; }'; document.head.appendChild(st); })()");
- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).borderBottomWidth"), '0px', 'a page h2 border does not reach the sheet title');
- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).paddingBottom"), '0px', 'nor its padding');
- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).textTransform"), 'none', 'nor its text-transform');
- assert.strictEqual(await p.evaluate(`${q('aud-private')}.checked`), true, 'the service\'s audience is chosen');
- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share [data-k=\"done\"]').textContent"), 'Done', 'nothing changed: Done, not Save');
+ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).borderBottomWidth"), '0px', 'a page h2 border does not reach the sheet title'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).paddingBottom"), '0px', 'nor its padding'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).textTransform"), 'none', 'nor its text-transform'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('aud-private')}.checked`), true, 'the service\'s audience is chosen'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share [data-k=\"done\"]').textContent"), 'Done', 'nothing changed: Done, not Save'));
// A name nobody has: said plainly, nothing added, nothing saved.
// A name nobody has: said plainly, nothing added, nothing saved.
await p.evaluate(typeIn('add', '@nobody'));
await p.evaluate(typeIn('add', '@nobody'));
await p.evaluate(`${q('add-btn')}.click()`);
await p.evaluate(`${q('add-btn')}.click()`);
await wait(150);
await wait(150);
- assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /No OpenVibe account is called @nobody/);
- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-list').children.length"), 1, 'only the owner');
+ await eventually(async () => assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /No OpenVibe account is called @nobody/));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-list').children.length"), 1, 'only the owner'));
// A name that is not a name is refused before anyone is asked.
// A name that is not a name is refused before anyone is asked.
await p.evaluate(typeIn('add', 'a b!'));
await p.evaluate(typeIn('add', 'a b!'));
await p.evaluate(`${q('add-btn')}.click()`);
await p.evaluate(`${q('add-btn')}.click()`);
@@ -124,23 +125,23 @@ const server = http.createServer((req, res) => {
@@ -124,23 +125,23 @@ const server = http.createServer((req, res) => {
await p.evaluate(typeIn('add', 'slow'));
await p.evaluate(typeIn('add', 'slow'));
await p.evaluate(`${q('add-btn')}.click()`);
await p.evaluate(`${q('add-btn')}.click()`);
await wait(150);
await wait(150);
- assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /Too many name checks/);
+ await eventually(async () => assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /Too many name checks/));
// A real name: added, Only me becomes Specific people, the display name stays text.
// A real name: added, Only me becomes Specific people, the display name stays text.
await p.evaluate(typeIn('add', '@Quinn'));
await p.evaluate(typeIn('add', '@Quinn'));
await p.evaluate(`${q('add-btn')}.click()`);
await p.evaluate(`${q('add-btn')}.click()`);
await wait(150);
await wait(150);
- assert.strictEqual(await p.evaluate(`${q('aud-people')}.checked`), true, 'adding someone to Only me switches to Specific people');
- assert.strictEqual(await p.evaluate('window.__xss'), undefined, 'a display name is never markup');
- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('dialog.ov-share .ov-share-display')].map((n) => n.textContent)[1]"), PEOPLE.quinn.display_name);
- assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Save', 'a change asks to be saved');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('aud-people')}.checked`), true, 'adding someone to Only me switches to Specific people'));
+ await eventually(async () => assert.strictEqual(await p.evaluate('window.__xss'), undefined, 'a display name is never markup'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('dialog.ov-share .ov-share-display')].map((n) => n.textContent)[1]"), PEOPLE.quinn.display_name));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Save', 'a change asks to be saved'));
assert.ok(!calls.some((c) => c.startsWith('PUT')), 'nothing is saved before Save');
assert.ok(!calls.some((c) => c.startsWith('PUT')), 'nothing is saved before Save');
// Leaving with unsaved changes asks first; Keep editing keeps them.
// Leaving with unsaved changes asks first; Keep editing keeps them.
await p.evaluate(`${q('close')}.click()`);
await p.evaluate(`${q('close')}.click()`);
await wait(80);
await wait(80);
- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'unsaved changes keep the sheet open');
- assert.ok(await p.evaluate(`!!${q('discard')}`), 'and ask: Discard or Keep editing');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'unsaved changes keep the sheet open'));
+ await eventually(async () => assert.ok(await p.evaluate(`!!${q('discard')}`), 'and ask: Discard or Keep editing'));
await p.evaluate(`${q('keep-editing')}.click()`);
await p.evaluate(`${q('keep-editing')}.click()`);
await wait(50);
await wait(50);
@@ -152,62 +153,62 @@ const server = http.createServer((req, res) => {
@@ -152,62 +153,62 @@ const server = http.createServer((req, res) => {
assert.deepStrictEqual(S.lastPut, { audience: 'people', members: [{ role: 'viewer', subject: PEOPLE.quinn.subject }, { role: 'viewer', subject: PEOPLE.maticus.subject }], notify: true },
assert.deepStrictEqual(S.lastPut, { audience: 'people', members: [{ role: 'viewer', subject: PEOPLE.quinn.subject }, { role: 'viewer', subject: PEOPLE.maticus.subject }], notify: true },
'Save sends the audience and the members by subject (never by display name), with no end date when there is none');
'Save sends the audience and the members by subject (never by display name), with no end date when there is none');
assert.strictEqual(S.lastIfMatch, '"7"', 'and only over the version it was shown (If-Match)');
assert.strictEqual(S.lastIfMatch, '"7"', 'and only over the version it was shown (If-Match)');
- assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and tells the page (ov-share:change)');
- assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Done');
+ await eventually(async () => assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and tells the page (ov-share:change)'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Done'));
// Transfer: to a person on the list, removing my access by default, bound to their subject.
// Transfer: to a person on the list, removing my access by default, bound to their subject.
await p.evaluate(`${q('give')}.click()`);
await p.evaluate(`${q('give')}.click()`);
await wait(80);
await wait(80);
- assert.strictEqual(await p.evaluate(`${q('keep-false')}.checked`), true, '"Remove my access" is the default');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('keep-false')}.checked`), true, '"Remove my access" is the default'));
await p.evaluate(`${q('give-maticus')}.click()`);
await p.evaluate(`${q('give-maticus')}.click()`);
await wait(50);
await wait(50);
await p.evaluate(`${q('give-send')}.click()`);
await p.evaluate(`${q('give-send')}.click()`);
await wait(200);
await wait(200);
assert.deepStrictEqual(S.lastTransfer, { to: { subject: PEOPLE.maticus.subject }, keep_access: false });
assert.deepStrictEqual(S.lastTransfer, { to: { subject: PEOPLE.maticus.subject }, keep_access: false });
- assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-banner').textContent"), /Waiting for @maticus to accept/);
- assert.strictEqual(await p.evaluate(`!!${q('give')}`), false, 'one offer at a time');
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-banner').textContent"), /Waiting for @maticus to accept/));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('give')}`), false, 'one offer at a time'));
await p.evaluate(`${q('cancel-give')}.click()`);
await p.evaluate(`${q('cancel-give')}.click()`);
await wait(200);
await wait(200);
assert.strictEqual(S.transfer, null, 'the owner can cancel');
assert.strictEqual(S.transfer, null, 'the owner can cancel');
assert.deepStrictEqual(S.lastCancel, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'naming the offer it cancels');
assert.deepStrictEqual(S.lastCancel, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'naming the offer it cancels');
await p.evaluate(`${q('close')}.click()`);
await p.evaluate(`${q('close')}.click()`);
await wait(320);
await wait(320);
- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog').length + (document.documentElement.classList.contains('ov-share-locked') ? 1 : 0)"), 0, 'closing removes the sheet and frees the page');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog').length + (document.documentElement.classList.contains('ov-share-locked') ? 1 : 0)"), 0, 'closing removes the sheet and frees the page'));
// A gate in markup starts by itself, once, with its values as plain attributes (a quote or </script> in one is
// A gate in markup starts by itself, once, with its values as plain attributes (a quote or </script> in one is
// just text); signed out, it offers the sign-in link it was given.
// just text); signed out, it offers the sign-in link it was given.
- assert.ok(await p.evaluate("!!document.querySelector('#gate2 .ov-share-gate')"), 'a gate in markup starts when ov-share.js loads');
- assert.strictEqual(await p.evaluate("document.querySelector('#gate2 a').getAttribute('href')"), '/login?next=%2Fp%2F"</script>', 'its sign-in link is the attribute, as text');
- assert.strictEqual(await p.evaluate('OVShare.startGates()'), 0, 'and it starts only once');
+ await eventually(async () => assert.ok(await p.evaluate("!!document.querySelector('#gate2 .ov-share-gate')"), 'a gate in markup starts when ov-share.js loads'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#gate2 a').getAttribute('href')"), '/login?next=%2Fp%2F"</script>', 'its sign-in link is the attribute, as text'));
+ await eventually(async () => assert.strictEqual(await p.evaluate('OVShare.startGates()'), 0, 'and it starts only once'));
// The recipient's banner and the gate.
// The recipient's banner and the gate.
await p.evaluate("OVShare.offer(document.getElementById('offer'), { kind: 'paste', id: 'abc', transfer_id: '00000000-0000-4000-8000-0000000000aa', from: { username: 'goosely' }, title: 'notes' })");
await p.evaluate("OVShare.offer(document.getElementById('offer'), { kind: 'paste', id: 'abc', transfer_id: '00000000-0000-4000-8000-0000000000aa', from: { username: 'goosely' }, title: 'notes' })");
await p.evaluate("[...document.querySelectorAll('#offer button')].find((b) => b.textContent === 'Accept').click()");
await p.evaluate("[...document.querySelectorAll('#offer button')].find((b) => b.textContent === 'Accept').click()");
await wait(200);
await wait(200);
assert.ok(calls.includes('POST /api/v1/paste/abc/share/transfer/accept'));
assert.ok(calls.includes('POST /api/v1/paste/abc/share/transfer/accept'));
assert.deepStrictEqual(S.lastAccept, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'the answer names the offer');
assert.deepStrictEqual(S.lastAccept, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'the answer names the offer');
- assert.match(await p.evaluate("document.getElementById('offer').textContent"), /It is yours now/);
+ await eventually(async () => assert.match(await p.evaluate("document.getElementById('offer').textContent"), /It is yours now/));
await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signedInAs: 'quinn' })");
await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signedInAs: 'quinn' })");
- assert.ok(!/notes/.test(await p.evaluate("document.getElementById('gate').textContent")), 'the gate never shows the title');
+ await eventually(async () => assert.ok(!/notes/.test(await p.evaluate("document.getElementById('gate').textContent")), 'the gate never shows the title'));
await p.evaluate("document.querySelector('#gate .ov-share-btn').click()");
await p.evaluate("document.querySelector('#gate .ov-share-btn').click()");
await wait(200);
await wait(200);
assert.ok(calls.includes('POST /api/v1/paste/abc/share/requests'));
assert.ok(calls.includes('POST /api/v1/paste/abc/share/requests'));
- assert.match(await p.evaluate("document.getElementById('gate').textContent"), /Asked/);
+ await eventually(async () => assert.match(await p.evaluate("document.getElementById('gate').textContent"), /Asked/));
await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signInUrl: '/login' })");
await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signInUrl: '/login' })");
- assert.strictEqual(await p.evaluate("document.querySelector('#gate a').getAttribute('href')"), '/login', 'signed out: sign in');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#gate a').getAttribute('href')"), '/login', 'signed out: sign in'));
// Send it: for anyone, with no kind or id; the link, Copy, the places, "Start at", embed code.
// Send it: for anyone, with no kind or id; the link, Copy, the places, "Start at", embed code.
await p.evaluate("Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__clip = t; return Promise.resolve(); } }, configurable: true })"); // headless Chrome refuses the real one without a grant
await p.evaluate("Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__clip = t; return Promise.resolve(); } }, configurable: true })"); // headless Chrome refuses the real one without a grant
await p.evaluate("document.getElementById('send').click()");
await p.evaluate("document.getElementById('send').click()");
await wait(300);
await wait(300);
- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud').length"), 0, 'no "who can open it" without kind and id');
- assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1', 'the link, shown without https://');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud').length"), 0, 'no "who can open it" without kind and id'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1', 'the link, shown without https://'));
await p.evaluate(`${q('copy')}.click()`);
await p.evaluate(`${q('copy')}.click()`);
await wait(120);
await wait(120);
- assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link');
- assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)');
- assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied');
+ await eventually(async () => assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link'));
+ await eventually(async () => assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied'));
// No Dock on this page (signed out): no OpenVibe Chat tile.
// No Dock on this page (signed out): no OpenVibe Chat tile.
- assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat without the Dock');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat without the Dock'));
// OpenVibe Community is offered to everyone (4.179.0): a link to its new-topic form, the title and the link filled in.
// OpenVibe Community is offered to everyone (4.179.0): a link to its new-topic form, the title and the link filled in.
const comm = await p.evaluate(`(() => { const a = ${q('to-ovcommunity')}; return a ? { href: a.getAttribute('href'), text: a.textContent, first: document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile') === a } : null; })()`);
const comm = await p.evaluate(`(() => { const a = ${q('to-ovcommunity')}; return a ? { href: a.getAttribute('href'), text: a.textContent, first: document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile') === a } : null; })()`);
assert.ok(comm, 'the Community tile');
assert.ok(comm, 'the Community tile');
@@ -217,20 +218,20 @@ const server = http.createServer((req, res) => {
@@ -217,20 +218,20 @@ const server = http.createServer((req, res) => {
assert.ok(decodeURIComponent(comm.href.split('&body=')[1]).includes('http'), 'the body carries the link');
assert.ok(decodeURIComponent(comm.href.split('&body=')[1]).includes('http'), 'the body carries the link');
const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);
const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);
assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');
assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');
- assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer'));
assert.ok(!/utm_|ref=/.test(x), 'no tracking is added to a link');
assert.ok(!/utm_|ref=/.test(x), 'no tracking is added to a link');
await p.evaluate(`${q('at-time')}.click()`);
await p.evaluate(`${q('at-time')}.click()`);
await wait(60);
await wait(60);
- assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1?t=83', '"Start at 1:23" adds the time');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1?t=83', '"Start at 1:23" adds the time'));
await p.evaluate(`${q('more')} && ${q('more')}.click()`);
await p.evaluate(`${q('more')} && ${q('more')}.click()`);
await wait(60);
await wait(60);
await p.evaluate(`${q('to-embed')}.click()`);
await p.evaluate(`${q('to-embed')}.click()`);
await wait(60);
await wait(60);
- assert.match(await p.evaluate(`${q('embed-code')}.value`), /^<iframe src="https:\/\/video\.test\/embed\/1\?t=83" [^>]*title="A robot & a wave"/, 'embed code, with the time and an escaped title');
+ await eventually(async () => assert.match(await p.evaluate(`${q('embed-code')}.value`), /^<iframe src="https:\/\/video\.test\/embed\/1\?t=83" [^>]*title="A robot & a wave"/, 'embed code, with the time and an escaped title'));
await p.evaluate(`${q('to-mastodon')}.click()`);
await p.evaluate(`${q('to-mastodon')}.click()`);
await wait(60);
await wait(60);
await p.evaluate(`(() => { const i = ${q('masto')}; i.value = 'not a server'; i.form.requestSubmit(); })()`);
await p.evaluate(`(() => { const i = ${q('masto')}; i.value = 'not a server'; i.form.requestSubmit(); })()`);
- assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address');
+ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address'));
await p.evaluate(`${q('close')}.click()`);
await p.evaluate(`${q('close')}.click()`);
await wait(320);
await wait(320);
// Signed in (the Dock is on the page): OpenVibe Chat comes first; it closes the sheet and hands the Dock the title
// Signed in (the Dock is on the page): OpenVibe Chat comes first; it closes the sheet and hands the Dock the title
@@ -238,25 +239,25 @@ const server = http.createServer((req, res) => {
@@ -238,25 +239,25 @@ const server = http.createServer((req, res) => {
await p.evaluate("window.__docked = []; window.OpenVibeDock = { share: (o) => { window.__docked.push(o); }, isOpen: () => false }");
await p.evaluate("window.__docked = []; window.OpenVibeDock = { share: (o) => { window.__docked.push(o); }, isOpen: () => false }");
await p.evaluate("document.getElementById('send').click()");
await p.evaluate("document.getElementById('send').click()");
await wait(300);
await wait(300);
- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile').getAttribute('data-k')"), 'to-ovchat', 'OpenVibe Chat is the first place');
- assert.strictEqual(await p.evaluate(`${q('to-ovchat')}.textContent`), 'OpenVibe Chat');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile').getAttribute('data-k')"), 'to-ovchat', 'OpenVibe Chat is the first place'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('to-ovchat')}.textContent`), 'OpenVibe Chat'));
await p.evaluate(`${q('to-ovchat')}.click()`);
await p.evaluate(`${q('to-ovchat')}.click()`);
await wait(320);
await wait(320);
- assert.deepStrictEqual(await p.evaluate('window.__docked'), [{ text: 'A robot & a wave https://video.test/v/1', title: 'A robot & a wave' }], 'the Dock gets the title and the link');
- assert.strictEqual(await p.evaluate("!!document.querySelector('dialog.ov-share')"), false, 'the sheet closes');
+ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__docked'), [{ text: 'A robot & a wave https://video.test/v/1', title: 'A robot & a wave' }], 'the Dock gets the title and the link'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('dialog.ov-share')"), false, 'the sheet closes'));
// A Dock that may not send (a read-only Dock token): no OpenVibe Chat.
// A Dock that may not send (a read-only Dock token): no OpenVibe Chat.
await p.evaluate("window.OpenVibeDock = { share: () => {}, isOpen: () => false, canSend: () => false }");
await p.evaluate("window.OpenVibeDock = { share: () => {}, isOpen: () => false, canSend: () => false }");
await p.evaluate("document.getElementById('send').click()");
await p.evaluate("document.getElementById('send').click()");
await wait(300);
await wait(300);
- assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat when the Dock cannot send');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat when the Dock cannot send'));
await p.evaluate(`${q('close')}.click()`);
await p.evaluate(`${q('close')}.click()`);
await wait(320);
await wait(320);
await p.evaluate("delete window.OpenVibeDock");
await p.evaluate("delete window.OpenVibeDock");
// Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.
// Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.
await p.evaluate("document.getElementById('theirs').click()");
await p.evaluate("document.getElementById('theirs').click()");
await wait(300);
await wait(300);
- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud, dialog.ov-share .ov-share-error').length"), 0, 'a 404 from the share API leaves the send half alone');
- assert.ok(await p.evaluate(`!!${q('copy')}`));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud, dialog.ov-share .ov-share-error').length"), 0, 'a 404 from the share API leaves the send half alone'));
+ await eventually(async () => assert.ok(await p.evaluate(`!!${q('copy')}`)));
await p.evaluate(`${q('close')}.click()`);
await p.evaluate(`${q('close')}.click()`);
await wait(320);
await wait(320);
@@ -272,15 +273,15 @@ const server = http.createServer((req, res) => {
@@ -272,15 +273,15 @@ const server = http.createServer((req, res) => {
await wait(400);
await wait(400);
await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"device\"]').click()");
await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"device\"]').click()");
await wait(100);
await wait(100);
- assert.deepStrictEqual(await phone.evaluate('window.__shared'), { title: 'A robot & a wave', url: 'https://video.test/v/1' }, 'the phone\'s sheet gets the title and the link');
- assert.ok(await phone.evaluate("!!document.querySelector('dialog.ov-share [data-k=\"to-sms\"]') || !!document.querySelector('dialog.ov-share [data-k=\"more\"]')"), 'Messages is offered on a phone');
+ await eventually(async () => assert.deepStrictEqual(await phone.evaluate('window.__shared'), { title: 'A robot & a wave', url: 'https://video.test/v/1' }, 'the phone\'s sheet gets the title and the link'));
+ await eventually(async () => assert.ok(await phone.evaluate("!!document.querySelector('dialog.ov-share [data-k=\"to-sms\"]') || !!document.querySelector('dialog.ov-share [data-k=\"more\"]')"), 'Messages is offered on a phone'));
await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"close\"]').click()");
await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"close\"]').click()");
await wait(320);
await wait(320);
await phone.evaluate("document.getElementById('sh').click()");
await phone.evaluate("document.getElementById('sh').click()");
await wait(500);
await wait(500);
const r = await phone.evaluate("(() => { const d = document.querySelector('dialog.ov-share').getBoundingClientRect(); return [Math.round(d.width), Math.round(innerHeight - d.bottom), document.documentElement.scrollWidth]; })()");
const r = await phone.evaluate("(() => { const d = document.querySelector('dialog.ov-share').getBoundingClientRect(); return [Math.round(d.width), Math.round(innerHeight - d.bottom), document.documentElement.scrollWidth]; })()");
assert.deepStrictEqual(r, [390, 0, 390], 'the sheet sits on the bottom edge, the phone\'s width, and nothing is wider');
assert.deepStrictEqual(r, [390, 0, 390], 'the sheet sits on the bottom edge, the phone\'s width, and nothing is wider');
- assert.ok(await phone.evaluate("document.querySelector('dialog.ov-share .ov-share-addfield').getBoundingClientRect().height") >= 44, 'the name field is a finger tall');
+ await eventually(async () => assert.ok(await phone.evaluate("document.querySelector('dialog.ov-share .ov-share-addfield').getBoundingClientRect().height") >= 44, 'the name field is a finger tall'));
await phone.close();
await phone.close();
console.log('ov-share-chrome: send (copy, X, start at, embed, Mastodon, the phone sheet), not-the-owner, loading, @names (unknown, invalid, rate-limited, found), Only me → people, save by subject, leave-unsaved, transfer and cancel, the offer, the gate, the phone ok');
console.log('ov-share-chrome: send (copy, X, start at, embed, Mastodon, the phone sheet), not-the-owner, loading, @names (unknown, invalid, rate-limited, found), Only me → people, save by subject, leave-unsaved, transfer and cancel, the offer, the gate, the phone ok');
} finally {
} finally {
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
// removing @maticus seals the content again under a fresh key: the new bytes open for quinn, not for maticus;
// removing @maticus seals the content again under a fresh key: the new bytes open for quinn, not for maticus;
// a link-locked paste's link keeps the key after #k=, and says so when the key is missing.
// a link-locked paste's link keeps the key after #k=, and says so when the key is missing.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -147,30 +148,30 @@ const server = http.createServer((req, res) => {
@@ -147,30 +148,30 @@ const server = http.createServer((req, res) => {
// The sheet itself loads no Locker: a fresh tab proves it, and that the first press of the part does.
// The sheet itself loads no Locker: a fresh tab proves it, and that the first press of the part does.
const fresh = await h.openPage(browser, await browser.newContext(), { width: 390 });
const fresh = await h.openPage(browser, await browser.newContext(), { width: 390 });
await fresh.goto(`${base}/`, { maxMs: 5000 });
await fresh.goto(`${base}/`, { maxMs: 5000 });
- assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker');
+ await eventually(async () => assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker'));
await fresh.evaluate("document.getElementById('sh').click()");
await fresh.evaluate("document.getElementById('sh').click()");
await wait(400);
await wait(400);
- assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked');
+ await eventually(async () => assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked'));
await fresh.evaluate(press('lock-open'));
await fresh.evaluate(press('lock-open'));
await wait(600);
await wait(600);
- assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js');
+ await eventually(async () => assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js'));
await fresh.evaluate(press('close'));
await fresh.evaluate(press('close'));
// 1. The Locked part says what the lock means, in plain words, and whose keys it is wrapped for.
// 1. The Locked part says what the lock means, in plain words, and whose keys it is wrapped for.
await page.evaluate("document.getElementById('sh').click()");
await page.evaluate("document.getElementById('sh').click()");
await wait(400);
await wait(400);
- assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked');
- assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words');
+ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked'));
+ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words'));
await page.evaluate(press('lock-open'));
await page.evaluate(press('lock-open'));
await wait(500);
await wait(500);
- assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means');
- assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown');
+ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means'));
+ await eventually(async () => assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown'));
// 2. Add @quinn: their public key is wrapped for them here.
// 2. Add @quinn: their public key is wrapped for them here.
await page.evaluate(type('add', 'quinn'));
await page.evaluate(type('add', 'quinn'));
await page.evaluate(press('add-btn'));
await page.evaluate(press('add-btn'));
await wait(600);
await wait(600);
- assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list');
+ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list'));
await page.evaluate(press('done'));
await page.evaluate(press('done'));
await wait(2500); // the re-wrap
await wait(2500); // the re-wrap
assert.strictEqual(puts.length, 1, 'one save');
assert.strictEqual(puts.length, 1, 'one save');
@@ -189,7 +190,7 @@ const server = http.createServer((req, res) => {
@@ -189,7 +190,7 @@ const server = http.createServer((req, res) => {
await wait(400);
await wait(400);
await page.evaluate(press('rm-user:usr_mat'));
await page.evaluate(press('rm-user:usr_mat'));
await wait(200);
await wait(200);
- assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened');
+ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened'));
await page.evaluate(press('done'));
await page.evaluate(press('done'));
await wait(3000);
await wait(3000);
assert.strictEqual(puts.length, 2, 'the removal saved');
assert.strictEqual(puts.length, 2, 'the removal saved');
@@ -222,14 +223,14 @@ const server = http.createServer((req, res) => {
@@ -222,14 +223,14 @@ const server = http.createServer((req, res) => {
items['/p/locked/raw'] = linkSealed.bytes;
items['/p/locked/raw'] = linkSealed.bytes;
await linkPage.evaluate(`(() => { OVShare.open({ kind: 'paste', id: 'locked', title: 'Locked note' }); })()`);
await linkPage.evaluate(`(() => { OVShare.open({ kind: 'paste', id: 'locked', title: 'Locked note' }); })()`);
await wait(500);
await wait(500);
- assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #');
+ await eventually(async () => assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #'));
await linkPage.evaluate(press('lock-open'));
await linkPage.evaluate(press('lock-open'));
await wait(400);
await wait(400);
await linkPage.evaluate('history.replaceState(null, "", location.pathname)');
await linkPage.evaluate('history.replaceState(null, "", location.pathname)');
await linkPage.evaluate(press('lock-open'));
await linkPage.evaluate(press('lock-open'));
await linkPage.evaluate(press('lock-open'));
await linkPage.evaluate(press('lock-open'));
await wait(500);
await wait(500);
- assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so');
+ await eventually(async () => assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so'));
console.log('ov-share-lock-chrome: the Locked part with real crypto: lazy load, fingerprints, add wraps for them, removal re-seals, the link lock keeps its key');
console.log('ov-share-lock-chrome: the Locked part with real crypto: lazy load, fingerprints, add wraps for them, removal re-seals, the link lock keeps its key');
} finally {
} finally {
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// shows it at once; a press, Escape or leaving hides it; an opened button shows none; it is hidden from assistive
// shows it at once; a press, Escape or leaving hides it; an opened button shows none; it is hidden from assistive
// technology (the element keeps its own name).
// technology (the element keeps its own name).
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('node:fs');
const fs = require('node:fs');
const os = require('node:os');
const os = require('node:os');
const path = require('node:path');
const path = require('node:path');
@@ -37,7 +38,7 @@ const server = http.createServer((req, res) => {
@@ -37,7 +38,7 @@ const server = http.createServer((req, res) => {
// A mouse resting on the button at the right edge: the tooltip, after a moment, under it and on the screen.
// A mouse resting on the button at the right edge: the tooltip, after a moment, under it and on the screen.
await move(782, 26);
await move(782, 26);
- assert.equal(await page.evaluate(ON), false, 'not at once: a pointer passing over shows nothing');
+ await eventually(async () => assert.equal(await page.evaluate(ON), false, 'not at once: a pointer passing over shows nothing'));
assert.ok(await until(ON), 'after a short rest it shows');
assert.ok(await until(ON), 'after a short rest it shows');
await wait(260);
await wait(260);
const a = await page.evaluate(READ('a'));
const a = await page.evaluate(READ('a'));
@@ -67,7 +68,7 @@ const server = http.createServer((req, res) => {
@@ -67,7 +68,7 @@ const server = http.createServer((req, res) => {
await move(400, 300); await wait(600);
await move(400, 300); await wait(600);
// An opened button shows none.
// An opened button shows none.
await move(38, 26); await wait(700);
await move(38, 26); await wait(700);
- assert.equal(await page.evaluate(ON), false, 'a button whose panel is open shows no tooltip');
+ await eventually(async () => assert.equal(await page.evaluate(ON), false, 'a button whose panel is open shows no tooltip'));
await move(400, 300);
await move(400, 300);
// The keyboard: focus shows it at once; Escape hides it.
// The keyboard: focus shows it at once; Escape hides it.
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// opens again where it was left; on a phone every kind is a sheet that a swipe down closes; the window style is one
// opens again where it was left; on a phone every kind is a sheet that a swipe down closes; the window style is one
// attribute for every window; content given as text is set as text.
// attribute for every window; content given as text is set as text.
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('node:fs');
const fs = require('node:fs');
const os = require('node:os');
const os = require('node:os');
const path = require('node:path');
const path = require('node:path');
@@ -42,22 +43,22 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
@@ -42,22 +43,22 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
assert.ok(v.popX !== '', 'it grows out of its button');
assert.ok(v.popX !== '', 'it grows out of its button');
// The focus stays inside: Tab from the last control goes to the first (×), Shift+Tab from the first to the last.
// The focus stays inside: Tab from the last control goes to the first (×), Shift+Tab from the first to the last.
await page.evaluate(`document.getElementById('last').focus()`); await key(page, 'Tab');
await page.evaluate(`document.getElementById('last').focus()`); await key(page, 'Tab');
- assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)');
+ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)'));
await key(page, 'Tab', true);
await key(page, 'Tab', true);
- assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last');
+ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last'));
// A second window on top: Escape closes the top one only.
// A second window on top: Escape closes the top one only.
await page.evaluate(`window.w2 = mk({ title: 'Send', from: document.getElementById('last') })`);
await page.evaluate(`window.w2 = mk({ title: 'Send', from: document.getElementById('last') })`);
- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2);
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2));
await key(page, 'Escape'); await wait(350);
await key(page, 'Escape'); await wait(350);
- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window');
- assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it');
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window'));
+ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it'));
await key(page, 'Escape'); await wait(350);
await key(page, 'Escape'); await wait(350);
v = await page.evaluate(`({ n: document.querySelectorAll('.ovw').length, scrims: document.querySelectorAll('.ovw-scrim').length, focus: document.activeElement.id, locked: document.documentElement.classList.contains('ovw-locked'), shut: window.shut })`);
v = await page.evaluate(`({ n: document.querySelectorAll('.ovw').length, scrims: document.querySelectorAll('.ovw-scrim').length, focus: document.activeElement.id, locked: document.documentElement.classList.contains('ovw-locked'), shut: window.shut })`);
assert.deepEqual(v, { n: 0, scrims: 0, focus: 'open', locked: false, shut: ['escape', 'escape'] }, 'both gone, the page free, the focus back on Open');
assert.deepEqual(v, { n: 0, scrims: 0, focus: 'open', locked: false, shut: ['escape', 'escape'] }, 'both gone, the page free, the focus back on Open');
// A press outside a dialog closes it.
// A press outside a dialog closes it.
await page.evaluate(`mk({})`); await wait(80);
await page.evaluate(`mk({})`); await wait(80);
await page.evaluate(`document.querySelector('.ovw-scrim').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`); await wait(350);
await page.evaluate(`document.querySelector('.ovw-scrim').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`); await wait(350);
- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog');
+ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog'));
// Text is text.
// Text is text.
await page.evaluate(`OVWindow.open({ title: '<b>x</b>', body: '<img src=x onerror=alert(1)>' })`);
await page.evaluate(`OVWindow.open({ title: '<b>x</b>', body: '<img src=x onerror=alert(1)>' })`);
v = await page.evaluate(`({ t: document.querySelector('.ovw-t').textContent, imgs: document.querySelectorAll('.ovw img').length })`);
v = await page.evaluate(`({ t: document.querySelector('.ovw-t').textContent, imgs: document.querySelectorAll('.ovw img').length })`);
@@ -75,18 +76,18 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
@@ -75,18 +76,18 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 200, y: hb.y + 150, button: 'left' });
await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 200, y: hb.y + 150, button: 'left' });
await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: hb.x + 200, y: hb.y + 150, button: 'left', clickCount: 1 });
await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: hb.x + 200, y: hb.y + 150, button: 'left', clickCount: 1 });
const moved = await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`);
const moved = await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`);
- assert.deepEqual(await page.evaluate(`JSON.parse(localStorage.getItem('ov:window:dock-test'))`), moved, 'where it was left is kept');
+ await eventually(async () => assert.deepEqual(await page.evaluate(`JSON.parse(localStorage.getItem('ov:window:dock-test'))`), moved, 'where it was left is kept'));
await page.evaluate('p1.close()'); await wait(300);
await page.evaluate('p1.close()'); await wait(300);
await page.evaluate(`mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);
await page.evaluate(`mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);
await wait(400);
await wait(400);
- assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again');
+ await eventually(async () => assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again'));
await page.evaluate('OVWindow.closeAll()'); await wait(300);
await page.evaluate('OVWindow.closeAll()'); await wait(300);
// The window style: one attribute, every window.
// The window style: one attribute, every window.
- assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline');
- assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline');
- assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default');
- assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null);
+ await eventually(async () => assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline'));
+ await eventually(async () => assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline'));
+ await eventually(async () => assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default'));
+ await eventually(async () => assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null));
await page.close();
await page.close();
// A phone: every kind is a sheet; a swipe down on its head closes it.
// A phone: every kind is a sheet; a swipe down on its head closes it.
@@ -101,8 +102,8 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
@@ -101,8 +102,8 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 260, button: 'left' });
await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 260, button: 'left' });
await ph.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sh.x, y: sh.y + 260, button: 'left', clickCount: 1 });
await ph.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sh.x, y: sh.y + 260, button: 'left', clickCount: 1 });
await wait(400);
await wait(400);
- assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet');
- assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']);
+ await eventually(async () => assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet'));
+ await eventually(async () => assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']));
await ph.close();
await ph.close();
console.log('ov-window-chrome: a named modal dialog from its button, focus kept and given back, stacked Escape, outside press, text as text, a panel beside its button that keeps its place, one window style, a phone sheet closed by a swipe');
console.log('ov-window-chrome: a named modal dialog from its button, focus kept and given back, stacked Escape, outside press, text as text, a panel beside its button that keeps its place, one window style, a phone sheet closed by a swipe');
} finally {
} finally {
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// another OpenVibe site carries it in #ovo=; the next page opens it again and takes the hash out of the address; a
// another OpenVibe site carries it in #ovo=; the next page opens it again and takes the hash out of the address; a
// source that is not first-party is never opened; a reload brings it back. Skipped without Chrome.
// source that is not first-party is never opened; a reload brings it back. Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -65,30 +66,30 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
@@ -65,30 +66,30 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
assert.ok(await until(p, `getComputedStyle(document.querySelector('.ovo-side')).transform === 'none'`), 'the sheet has finished opening');
assert.ok(await until(p, `getComputedStyle(document.querySelector('.ovo-side')).transform === 'none'`), 'the sheet has finished opening');
const side = await p.evaluate(`(() => { const s = document.querySelector('.ovo-side'); const r = s.getBoundingClientRect(); return { open: s.classList.contains('is-open'), bottom: Math.round(r.bottom), src: s.querySelector('iframe').getAttribute('src') }; })()`);
const side = await p.evaluate(`(() => { const s = document.querySelector('.ovo-side'); const r = s.getBoundingClientRect(); return { open: s.classList.contains('is-open'), bottom: Math.round(r.bottom), src: s.querySelector('iframe').getAttribute('src') }; })()`);
assert.deepStrictEqual(side, { open: true, bottom: 844 - 58 - 10, src: `${base}/frame-chat` }, 'the chat sheet, above the app bar');
assert.deepStrictEqual(side, { open: true, bottom: 844 - 58 - 10, src: `${base}/frame-chat` }, 'the chat sheet, above the app bar');
- assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the chat sheet locks the page');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the chat sheet locks the page'));
await p.evaluate(`document.querySelector('[data-ovo="side"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovo="side"]').click(), true`);
- assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing the chat sheet unlocks the page');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing the chat sheet unlocks the page'));
// A link to another OpenVibe site carries it; one on this site does not.
// A link to another OpenVibe site carries it; one on this site does not.
const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);
const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);
assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);
assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);
const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);
assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);
- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash');
- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://example.com/x')`), 'https://example.com/x', 'not an OpenVibe site: no hash');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://example.com/x')`), 'https://example.com/x', 'not an OpenVibe site: no hash'));
// Arriving with the hash: the card opens and the hash leaves the address.
// Arriving with the hash: the card opens and the hash leaves the address.
const data = away.split('#ovo=')[1];
const data = away.split('#ovo=')[1];
const q = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 900 });
const q = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 900 });
await q.goto(`${base}/b#ovo=${data}`, { maxMs: 5000 });
await q.goto(`${base}/b#ovo=${data}`, { maxMs: 5000 });
assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'the carried card opens');
assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'the carried card opens');
- assert.strictEqual(await q.evaluate(`location.hash`), '', 'the hash is gone from the address');
- assert.strictEqual(await q.evaluate(`document.querySelector('.ovo-card .ovo-name').textContent`), 'maticus');
+ await eventually(async () => assert.strictEqual(await q.evaluate(`location.hash`), '', 'the hash is gone from the address'));
+ await eventually(async () => assert.strictEqual(await q.evaluate(`document.querySelector('.ovo-card .ovo-name').textContent`), 'maticus'));
// Reload: it comes back (this site's session).
// Reload: it comes back (this site's session).
await q.goto(`${base}/b`, { maxMs: 5000 });
await q.goto(`${base}/b`, { maxMs: 5000 });
assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'a reload brings it back');
assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'a reload brings it back');
await q.evaluate(`OVOverlay.close('live'), true`);
await q.evaluate(`OVOverlay.close('live'), true`);
- assert.strictEqual(await q.evaluate(`sessionStorage.getItem('ov-overlay')`), null, 'closed: nothing kept');
+ await eventually(async () => assert.strictEqual(await q.evaluate(`sessionStorage.getItem('ov-overlay')`), null, 'closed: nothing kept'));
// A source that is not first-party is never opened, from the hash or from a call.
// A source that is not first-party is never opened, from the hash or from a call.
const evil = Buffer.from(JSON.stringify([{ id: 'x', src: 'https://evil.example/steal', title: 'x' }])).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const evil = Buffer.from(JSON.stringify([{ id: 'x', src: 'https://evil.example/steal', title: 'x' }])).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -96,15 +97,15 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
@@ -96,15 +97,15 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
await e.goto(`${base}/c#ovo=${evil}`, { maxMs: 5000 });
await e.goto(`${base}/c#ovo=${evil}`, { maxMs: 5000 });
await until(e, `!!window.OVOverlay`);
await until(e, `!!window.OVOverlay`);
await wait(200);
await wait(200);
- assert.strictEqual(await e.evaluate(`document.querySelectorAll('.ovo-card').length`), 0, 'a foreign source is refused');
- assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'javascript:alert(1)' })`), null);
+ await eventually(async () => assert.strictEqual(await e.evaluate(`document.querySelectorAll('.ovo-card').length`), 0, 'a foreign source is refused'));
+ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'javascript:alert(1)' })`), null));
for (const host of ['portal--alex.openvibe.link', 'portal--alex.openvibe.website', 'nested.portal--alex.openvibe.link', 'unknown.openvibe.link']) {
for (const host of ['portal--alex.openvibe.link', 'portal--alex.openvibe.website', 'nested.portal--alex.openvibe.link', 'unknown.openvibe.link']) {
- assert.strictEqual(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), null, host);
- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://${host}/')`), `https://${host}/`, 'no card carried to a share');
+ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), null, host));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://${host}/')`), `https://${host}/`, 'no card carried to a share'));
}
}
for (const host of ['openvibe.link', 'connect.openvibe.link']) assert.ok(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), host);
for (const host of ['openvibe.link', 'connect.openvibe.link']) assert.ok(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), host);
- assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'http://openvibe.live/embed/x' })`), null, 'plain http to another site is refused');
- assert.ok(await e.evaluate(`!!OVOverlay.safeSrc('https://openvibe.live/embed/maticus')`), "Live's embed is first-party");
+ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'http://openvibe.live/embed/x' })`), null, 'plain http to another site is refused'));
+ await eventually(async () => assert.ok(await e.evaluate(`!!OVOverlay.safeSrc('https://openvibe.live/embed/maticus')`), "Live's embed is first-party"));
console.log('overlay-chrome: ok');
console.log('overlay-chrome: ok');
} finally {
} finally {
await browser.close();
await browser.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const vm = require('vm');
+const { parseHTML } = require('linkedom');
+const { window, document } = parseHTML('<html><body><a data-ov-player-hls="https://media.test/master.m3u8">Watch</a></body></html>');
+const calls = [], connection = { effectiveType: '4g', addEventListener() {}, removeEventListener() {} };
+const bodies = {
+ 'https://media.test/master.m3u8': '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=2000000\nhigh.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=300000\nlow.m3u8',
+ 'https://media.test/low.m3u8': '#EXTM3U\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:4,\nfirst.m4s\n#EXTINF:4,\nsecond.m4s\n#EXT-X-ENDLIST',
+ 'https://media.test/init.mp4': 'init', 'https://media.test/first.m4s': 'segment',
+};
+const context = { window, document, navigator: { connection }, URL, TextDecoder, Uint8Array, AbortController,
+ console, setTimeout, clearTimeout, fetch: async (url) => { calls.push(url); return new Response(bodies[url] || '', {status:bodies[url] ? 200 : 404}); } };
+vm.runInNewContext(fs.readFileSync(require.resolve('../player.js'), 'utf8'), context);
+const P = window.OpenVibePlayer;
+(async () => {
+ for (const [type, expected] of [['slow-2g','none'],['2g','none'],['3g','metadata'],['4g','auto'],[undefined,'auto']])
+ assert.strictEqual(P.playbackPolicy({effectiveType:type}).preload, expected);
+ assert.strictEqual(P.playbackPolicy({saveData:true,effectiveType:'4g'}).preload,'none');
+ const low = P.playbackPolicy({}, {level:0.1,charging:false});
+ assert.strictEqual(low.hls.maxBufferLength,15); assert.strictEqual(low.hls.backBufferLength,0); assert.strictEqual(low.warm,false);
+ assert.strictEqual(P.playbackPolicy({}, {level:0.1,charging:true}).hls.maxBufferLength,60);
+ const video = document.createElement('video'); video.paused=true;
+ class Hls {
+ static isSupported() { return true; }
+ static Events = { DESTROYING: 'destroy' };
+ constructor(config) { this.config=config; this.events={}; this.starts=0; }
+ on(e, fn) { this.events[e]=fn; }
+ startLoad() { this.starts++; }
+ loadSource(url) { this.url=url; }
+ attachMedia(v) { this.video=v; }
+ }
+ connection.effectiveType='3g';
+ const hls=P.attachHls(video,Hls,'https://media.test/master.m3u8',{enableWorker:true});
+ assert.strictEqual(hls.config.autoStartLoad,false); assert.strictEqual(hls.config.startLevel,0);
+ assert.strictEqual(hls.config.enableWorker,true); assert.strictEqual(hls.starts,0);
+ video.dispatchEvent(new window.Event('play')); assert.strictEqual(hls.starts,1);
+ hls.events.destroy(); video.dispatchEvent(new window.Event('play')); assert.strictEqual(hls.starts,1,'destroy removes play listener');
+ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),false); assert.strictEqual(calls.length,0);
+ connection.effectiveType='4g';
+ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),true);
+ assert.deepStrictEqual(calls,Object.keys(bodies).filter(u=>!u.endsWith('high.m3u8')));
+ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),false,'deduplicated');
+ assert.strictEqual(await P.warmHls('https://media.test/private.m3u8?sig=private'),false);
+ assert.strictEqual(await P.warmHls('file:///x.m3u8'),false);
+ bodies['https://media.test/live.m3u8']='#EXTM3U\n#EXTINF:4,\nfirst.m4s';
+ assert.strictEqual(await P.warmHls('https://media.test/live.m3u8'),false,'live segments not prefetched');
+ bodies['https://media.test/big.m3u8']='#EXTM3U\n#EXTINF:4,\nbig.m4s\n#EXT-X-ENDLIST';
+ bodies['https://media.test/big.m4s']='x'.repeat(2*1024*1024+1);
+ assert.strictEqual(await P.warmHls('https://media.test/big.m3u8'),false,'byte cap');
+ bodies['https://media.test/range.m3u8']='#EXTM3U\n#EXT-X-MAP:URI="whole.mp4",BYTERANGE="100@0"\n#EXTINF:4,\nfirst.m4s\n#EXT-X-ENDLIST';
+ assert.strictEqual(await P.warmHls('https://media.test/range.m3u8'),false,'ranged init never fetches a whole file');
+ assert(!calls.includes('https://media.test/whole.mp4'));
+ bodies['https://media.test/encrypted.m3u8']='#EXTM3U\n#EXT-X-SESSION-KEY:METHOD=AES-128,URI="key"\n#EXT-X-STREAM-INF:BANDWIDTH=300000\nlow.m3u8';
+ const beforeEncrypted=calls.length;
+ assert.strictEqual(await P.warmHls('https://media.test/encrypted.m3u8'),false);
+ assert.strictEqual(calls.length,beforeEncrypted+1,'session encryption stops at the manifest');
+ const card = document.querySelector('[data-ov-player-hls]');
+ card.setAttribute('data-ov-player-hls','https://media.test/focus.m3u8');
+ bodies['https://media.test/focus.m3u8']=bodies['https://media.test/low.m3u8'];
+ const beforeFocus=calls.length;
+ card.dispatchEvent(new window.Event('focusin'));
+ card.dispatchEvent(new window.Event('focusout'));
+ await new Promise(resolve=>setTimeout(resolve,150));
+ assert.strictEqual(calls.length,beforeFocus,'leaving before intent delay cancels warming');
+ card.dispatchEvent(new window.Event('focusin'));
+ await new Promise(resolve=>setTimeout(resolve,150));
+ assert(calls.includes('https://media.test/focus.m3u8'),'keyboard focus warms a card');
+ card.dispatchEvent(new window.Event('focusout'));
+ console.log('player startup: preload, battery, deferred HLS, low rung, public bounded warming and teardown passed');
+})().catch(e=>{console.error(e);process.exitCode=1});
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// { state: 'dnd', show_status: true }) and the dot follows; a status line saves; Escape closes the picker; a second
// { state: 'dnd', show_status: true }) and the dot follows; a status line saves; Escape closes the picker; a second
// tab within the minute does not beat again; presence: false loads nothing. Skipped without Chrome.
// tab within the minute does not beat again; presence: false loads nothing. Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -65,7 +66,7 @@ const server = http.createServer((req, res) => {
@@ -65,7 +66,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(state, 'online', `${width}px: the dot says online`);
assert.strictEqual(state, 'online', `${width}px: the dot says online`);
const beats = await p.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`);
const beats = await p.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`);
assert.strictEqual(beats, 1, 'one heartbeat on load');
assert.strictEqual(beats, 1, 'one heartbeat on load');
- assert.strictEqual(await p.evaluate(`document.getElementById('openvibe-avatar-btn').title`), 'Online');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.getElementById('openvibe-avatar-btn').title`), 'Online'));
await p.evaluate(`OpenVibePresence.open()`);
await p.evaluate(`OpenVibePresence.open()`);
assert.ok(await until(p, `!!document.querySelector('.ovp-pop')`), `${width}px: the picker opens`);
assert.ok(await until(p, `!!document.querySelector('.ovp-pop')`), `${width}px: the picker opens`);
@@ -77,7 +78,7 @@ const server = http.createServer((req, res) => {
@@ -77,7 +78,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await until(p, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state === 'dnd' && d.dataset.state; })()`), 'dnd', `${width}px: Do not disturb shows`);
assert.strictEqual(await until(p, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state === 'dnd' && d.dataset.state; })()`), 'dnd', `${width}px: Do not disturb shows`);
const put = await p.evaluate(`window.__calls.find((c) => c.method === 'PUT')`);
const put = await p.evaluate(`window.__calls.find((c) => c.method === 'PUT')`);
assert.deepStrictEqual(put.body, { state: 'dnd', show_status: true });
assert.deepStrictEqual(put.body, { state: 'dnd', show_status: true });
- assert.strictEqual(await p.evaluate(`document.querySelector('.ovp-opt[aria-checked="true"]').dataset.value`), 'dnd', 'the picker marks it');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ovp-opt[aria-checked="true"]').dataset.value`), 'dnd', 'the picker marks it'));
await p.evaluate(`(() => { const i = document.querySelector('.ovp-text input'); i.value = 'Building a world'; i.form.requestSubmit(); })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovp-text input'); i.value = 'Building a world'; i.form.requestSubmit(); })()`);
assert.ok(await until(p, `/Building a world/.test(document.getElementById('openvibe-avatar-btn').title)`), 'the status line saves and shows on hover');
assert.ok(await until(p, `/Building a world/.test(document.getElementById('openvibe-avatar-btn').title)`), 'the status line saves and shows on hover');
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
@@ -88,12 +89,12 @@ const server = http.createServer((req, res) => {
@@ -88,12 +89,12 @@ const server = http.createServer((req, res) => {
const again = await h.openPage(browser, context, { width: 1280 });
const again = await h.openPage(browser, context, { width: 1280 });
await again.goto(`${base}/on`, { maxMs: 5000 });
await again.goto(`${base}/on`, { maxMs: 5000 });
assert.strictEqual(await until(again, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state; })()`), 'dnd');
assert.strictEqual(await until(again, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state; })()`), 'dnd');
- assert.strictEqual(await again.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`), 0, 'a second tab within the minute does not beat again');
+ await eventually(async () => assert.strictEqual(await again.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`), 0, 'a second tab within the minute does not beat again'));
await again.close();
await again.close();
const off = await h.openPage(browser, context, { width: 1280 });
const off = await h.openPage(browser, context, { width: 1280 });
await off.goto(`${base}/off`, { maxMs: 5000 });
await off.goto(`${base}/off`, { maxMs: 5000 });
await wait(400);
await wait(400);
- assert.strictEqual(await off.evaluate(`!!window.OpenVibePresence || !!document.getElementById('ov-presence-loader')`), false, 'presence: false loads nothing');
+ await eventually(async () => assert.strictEqual(await off.evaluate(`!!window.OpenVibePresence || !!document.getElementById('ov-presence-loader')`), false, 'presence: false loads nothing'));
await off.close();
await off.close();
console.log('presence-chrome: the dot, one heartbeat a minute across tabs, the picker (sheet on a phone), Do not disturb, a status line, Escape; presence: false ok');
console.log('presence-chrome: the dot, one heartbeat a minute across tabs, the picker (sheet on a phone), Do not disturb, a status line, Escape; presence: false ok');
} finally {
} finally {
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// where more is hidden and a position bar under them; data-ovk-scroll="drift" moves on its own while in view and stops for
// where more is hidden and a position bar under them; data-ovk-scroll="drift" moves on its own while in view and stops for
// good at the first touch; a strip with nothing hidden shows neither. Skipped without Chrome.
// good at the first touch; a strip with nothing hidden shows neither. Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -62,7 +63,7 @@ const server = http.createServer((req, res) => {
@@ -62,7 +63,7 @@ const server = http.createServer((req, res) => {
await p.evaluate("document.getElementById('drift').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");
await p.evaluate("document.getElementById('drift').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");
const c = await p.evaluate("document.getElementById('drift').scrollLeft");
const c = await p.evaluate("document.getElementById('drift').scrollLeft");
await wait(700);
await wait(700);
- assert.strictEqual(await p.evaluate("document.getElementById('drift').scrollLeft"), c, 'and stops at the first touch');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('drift').scrollLeft"), c, 'and stops at the first touch'));
console.log('rails-scroll-chrome: edge fades, the position bar, drift that stops at a touch, nothing on a short strip');
console.log('rails-scroll-chrome: edge fades, the position bar, drift that stops at a touch, nothing on a short strip');
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
// "all of OpenVibe" and "Ask Vibe"; arrows and Escape work; titles are text, never markup. Loading: a poster-backed
// "all of OpenVibe" and "Ask Vibe"; arrows and Escape work; titles are text, never markup. Loading: a poster-backed
// overlay that steps, slows with actions, fails with Retry and fades out; skeleton rows. Skipped without Chrome.
// overlay that steps, slows with actions, fails with Retry and fades out; skeleton rows. Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -80,8 +81,8 @@ const server = http.createServer((req, res) => {
@@ -80,8 +81,8 @@ const server = http.createServer((req, res) => {
await p.goto(`${base}/`, { maxMs: 5000 });
await p.goto(`${base}/`, { maxMs: 5000 });
await wait(200);
await wait(200);
// ── Search ──
// ── Search ──
- assert.strictEqual(await p.evaluate('!!document.getElementById("openvibe-search-btn")'), true, 'the magnifier is in the bar');
- assert.strictEqual(await p.evaluate('typeof window.OpenVibeSearch'), 'undefined', 'search.js is not loaded until asked');
+ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.getElementById("openvibe-search-btn")'), true, 'the magnifier is in the bar'));
+ await eventually(async () => assert.strictEqual(await p.evaluate('typeof window.OpenVibeSearch'), 'undefined', 'search.js is not loaded until asked'));
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
// search.js loads on first use: wait for its box, not a fixed time (macOS runners took longer than 400 ms, 4.28.0's CI).
// search.js loads on first use: wait for its box, not a fixed time (macOS runners took longer than 400 ms, 4.28.0's CI).
for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);
for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);
@@ -100,12 +101,12 @@ const server = http.createServer((req, res) => {
@@ -100,12 +101,12 @@ const server = http.createServer((req, res) => {
assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);
- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text'));
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); 1');
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); 1');
- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it'));
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);
await wait(100);
await wait(100);
- assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-mini .ovs-tile") && !document.querySelector("[data-ovs-fold=all]")'), 'every site is in the tab itself, compact, with no fold');
+ await eventually(async () => assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-mini .ovs-tile") && !document.querySelector("[data-ovs-fold=all]")'), 'every site is in the tab itself, compact, with no fold'));
// Typing: one request for the latest text (the earlier one cancelled), this site's results first.
// Typing: one request for the latest text (the earlier one cancelled), this site's results first.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);
await wait(1200);
await wait(1200);
@@ -138,22 +139,22 @@ const server = http.createServer((req, res) => {
@@ -138,22 +139,22 @@ const server = http.createServer((req, res) => {
assert.ok(/^Searched OpenVibe in \d+ ms · the web through Wikipedia in /.test(v.route), `the route in words: ${v.route}`);
assert.ok(/^Searched OpenVibe in \d+ ms · the web through Wikipedia in /.test(v.route), `the route in words: ${v.route}`);
assert.ok(asked.includes('query:network') && asked.includes('query:web'), 'two questions: OpenVibe quickly, the web on its own');
assert.ok(asked.includes('query:network') && asked.includes('query:web'), 'two questions: OpenVibe quickly, the web on its own');
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'nigh'; i.dispatchEvent(new Event('input')); return 1; })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'nigh'; i.dispatchEvent(new Event('input')); return 1; })()`);
- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-rtabs").hidden'), true, 'typing goes back to suggestions');
+ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-rtabs").hidden'), true, 'typing goes back to suggestions'));
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'night'; i.dispatchEvent(new Event('input')); return 1; })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'night'; i.dispatchEvent(new Event('input')); return 1; })()`);
await wait(400);
await wait(400);
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');
- assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move');
+ await eventually(async () => assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move'));
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');
- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes');
+ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes'));
// Back closes the menu and stays on the page (4.128.0, the owner 11-07: Back on a phone left the page).
// Back closes the menu and stays on the page (4.128.0, the owner 11-07: Back on a phone left the page).
const before = await p.evaluate('location.href');
const before = await p.evaluate('location.href');
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
for (let i = 0; i < 30 && !(await p.evaluate('!!document.querySelector(".ovs.is-open")')); i++) await wait(100);
for (let i = 0; i < 30 && !(await p.evaluate('!!document.querySelector(".ovs.is-open")')); i++) await wait(100);
- assert.strictEqual(await p.evaluate('!!(history.state && history.state.ovsMenu)'), true, 'opening adds one history entry');
+ await eventually(async () => assert.strictEqual(await p.evaluate('!!(history.state && history.state.ovsMenu)'), true, 'opening adds one history entry'));
await p.evaluate('history.back(); 1');
await p.evaluate('history.back(); 1');
await wait(400);
await wait(400);
- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu');
- assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');
+ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu'));
+ await eventually(async () => assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page'));
// ── The network's cards: a picture before the title, exactly as the entry sent it (Contracts 0.320.0) ──
// ── The network's cards: a picture before the title, exactly as the entry sent it (Contracts 0.320.0) ──
// The cards are the Feed tab's (4.149.0).
// The cards are the Feed tab's (4.149.0).
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
const assert = require('assert');
const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -34,15 +35,15 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -34,15 +35,15 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
}
}
const wasWorn = await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`);
const wasWorn = await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`);
await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>/^(Equip|Unequip)$/.test(b.textContent)).click()`); await wait(150);
await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>/^(Equip|Unequip)$/.test(b.textContent)).click()`); await wait(150);
- assert.strictEqual(await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`), !wasWorn);
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`), !wasWorn));
await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>b.textContent==='Buy Vibes').click()`);
await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>b.textContent==='Buy Vibes').click()`);
assert(await p.evaluate(`document.querySelector('.ov-wallet-checkout').innerText.includes('Continue to checkout')`));
assert(await p.evaluate(`document.querySelector('.ov-wallet-checkout').innerText.includes('Continue to checkout')`));
await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await wait(50);
await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await wait(50);
- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false);
- assert.strictEqual(await p.evaluate(`document.documentElement.style.overflow`), '');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false));
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.documentElement.style.overflow`), ''));
await p.evaluate(`OpenVibeNavbar.openWallet()`); await wait(150);
await p.evaluate(`OpenVibeNavbar.openWallet()`); await wait(150);
await p.evaluate(`OpenVibeNavbar.setUser(null,null)`);
await p.evaluate(`OpenVibeNavbar.setUser(null,null)`);
- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false, 'signout closes private content');
+ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false, 'signout closes private content'));
const exceptions = p.state.errors || [];
const exceptions = p.state.errors || [];
assert.strictEqual(exceptions.length, 0, JSON.stringify(exceptions));
assert.strictEqual(exceptions.length, 0, JSON.stringify(exceptions));
await p.close(); await context.dispose();
await p.close(); await context.dispose();
@@ -309,65 +309,3 @@ html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }
@@ -309,65 +309,3 @@ html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }
.ovt-composer > .ovt-c-note { position: absolute; left: 14px; right: 14px; top: calc(100% + 6px); margin: 0; text-align: center; font-size: 12px; line-height: 1.4; }
.ovt-composer > .ovt-c-note { position: absolute; left: 14px; right: 14px; top: calc(100% + 6px); margin: 0; text-align: center; font-size: 12px; line-height: 1.4; }
.ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px)); }
.ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px)); }
.ovt.is-start .ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: max(40px, env(safe-area-inset-bottom, 0px)); }
.ovt.is-start .ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: max(40px, env(safe-area-inset-bottom, 0px)); }
-
-/* 4.201.0, the one composer (ov-composer.js; ask 349): files as chips above the box, a drop target, the slash and
- mention list, the microphone, the count, a compact box, and your settings sheet. */
-.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
-.ovc-files[hidden] { display: none; }
-.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
- border: 1px solid var(--ovt-line); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
-.ovc-file.is-bad { border-color: color-mix(in srgb, var(--live-red, #ef4444) 70%, transparent); }
-.ovc-file.is-up::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: calc(var(--p, 0) * 100%); background: var(--ovt-accent); transition: width .2s; }
-.ovc-file-th { flex: none; width: 40px; height: 40px; border-radius: 10px; object-fit: cover; background: #0b1220; }
-.ovc-file-ic { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-accent) 16%, transparent); color: var(--ovt-accent); font-size: 11px; font-weight: 750; letter-spacing: .02em; }
-.ovc-file-t { min-width: 0; display: grid; line-height: 1.25; }
-.ovc-file-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--text-primary, #e6edf7); }
-.ovc-file-s { font-size: 12px; color: var(--text-muted, #7386a3); white-space: nowrap; }
-.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }
-.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }
-.ovc-file-b { top: auto; bottom: 4px; }
-.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }
-.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }
-.ovc-dropping { border-color: var(--ovt-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }
-.ovc-dropping::before { content: "Drop to attach"; position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; border-radius: inherit; background: color-mix(in srgb, var(--bg-card, #131c2e) 86%, var(--ovt-accent)); color: var(--text-primary, #e6edf7); font-weight: 650; pointer-events: none; }
-.ovc-pop { position: absolute; z-index: 40; left: 6px; right: 6px; bottom: calc(100% + 8px); max-height: min(320px, 50vh); overflow: auto; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line);
- background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
-.ovt.is-start .ovc-pop { bottom: auto; top: calc(100% + 8px); }
-.ovc-pop[hidden] { display: none; }
-.ovc-pop-i { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 6px 10px; box-sizing: border-box; border: 0; border-radius: 11px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
-.ovc-pop-i[aria-selected="true"], .ovc-pop-i:hover { background: color-mix(in srgb, var(--ovt-accent) 14%, transparent); }
-.ovc-pop-ic, .ovc-pop-av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--ovt-accent); background: color-mix(in srgb, var(--ovt-accent) 12%, transparent); object-fit: cover; }
-.ovc-pop-av { border-radius: 50%; }
-.ovc-pop-ic svg { width: 17px; height: 17px; }
-.ovc-pop-t { min-width: 0; display: grid; line-height: 1.3; }
-.ovc-pop-t b { font-weight: 650; font-size: 14px; color: var(--text-primary, #e6edf7); }
-.ovc-pop-t span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #7386a3); }
-.ovc-pop-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: 999px; color: var(--ovt-accent); background: color-mix(in srgb, var(--ovt-accent) 12%, transparent); }
-.ovc-mic[aria-pressed="true"] { color: var(--live-red, #ef4444); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); animation: ovc-listen 1.4s ease-in-out infinite; }
-.ovc-count { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }
-.ovc-count.is-low { color: var(--live-red, #ef4444); }
-.ovc-count[hidden] { display: none; }
-.ovt-composer.ovc-compact { padding: 6px 8px 6px 12px; border-radius: 18px; }
-.ovt-composer.ovc-compact .ovt-c-row { margin-top: 0; }
-.ovt-composer.ovc-compact .ovt-btn, .ovt-composer.ovc-compact .ovt-send { width: 36px; min-width: 36px; height: 36px; }
-@keyframes ovc-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }
-@keyframes ovc-listen { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 18%, transparent); } }
-html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
-.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
-.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
-.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }
-.ovc-set-in .ovc-set-f { padding: 0; }
-.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
-.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
-.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }
-.ovc-set-g legend { padding: 0 0 6px; font-weight: 650; font-size: 14px; }
-.ovc-set label { display: flex; align-items: center; gap: 10px; min-height: 40px; font-size: 14.5px; cursor: pointer; }
-.ovc-set small, .ovc-set-p { color: var(--text-muted, #7386a3); font-size: 12.5px; margin: 0; }
-.ovc-set input[type=radio], .ovc-set input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }
-.ovc-set-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
-.ovc-set-l li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 4px 4px 4px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }
-.ovc-set-l span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #b4c0d4); font-size: 13px; }
-.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }
-.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }
-.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
-@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }