diff --git a/README.md b/README.md index 22920b1..8862ffa 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ docs/devteam/ **Previews.** Any session can show the owner a running dev server, before anything is committed: `devteam.mjs preview --run ''` (or `preview ` for one it already runs). The name is two words derived from the project, the session and a label, so a link outlives a restart. The Library lists it above the artifacts, a question can cite it as `[[name]]`, and everything the kit started is stopped when the session goes. On a laptop the link is `http://127.0.0.1:`. A preview is run state: seeing one approves nothing, and nothing of it is logged. **Threads.** On the dashboard, hold Shift and point: the cursor becomes a pin and what's under it lights up with its name (a goal, a question, a ticket, a spot on a page). Click, and a small card opens beside it with that thing at the top; what you type there goes to the supervisor as your own words, and its replies show in the card. It may ask you an Inbox question in the thread (answered in either place, once), and propose resolving it; only you resolve, and your resolution is an observation the supervisor's loop reads. Pins mark the threads on the page, and the dock at the bottom right lists every open one, with **New thread** for one about nothing in particular and **Resolved** for the last 50. On a phone, the dock's **Pin** button points without Shift. Threads and their turns are log lines, so they survive a restart and move with the project. -**On an artifact.** The same works on a page the fleet drew: hold Shift over it in the Library's viewer and the element under the pointer lights up (`button "Buy now"`); a click starts a thread on it, and its pin stays on that element as the page scrolls. **Open** on its Library card, a `[[name]]` in a question, and the viewer's **Open in new tab** open it in a tab of its own (`/view/artifact/`), with its threads and the dock and nothing else. To name the element, the kit adds a small script of its own to the page whenever it is shown in a frame, and never to the page itself: what is committed, checked and published is the page as drawn. What that script tells the dashboard is the page describing itself, so it is shown to the supervisor as data; it can't start, write or answer a thread. +**On an artifact or a preview.** The same works on a page the fleet drew: hold Shift over it in the Library's viewer and the element under the pointer lights up (`button "Buy now"`); a click starts a thread on it, and its pin stays on that element as the page scrolls. **Open** on its Library card, a `[[name]]` in a question, and the viewer's **Open in new tab** open it in a tab of its own (`/view/artifact/`), with its threads and the dock and nothing else. To name the element, the kit adds a small script of its own to the page whenever it is shown in a frame, and never to the page itself: what is committed, checked and published is the page as drawn. What that script tells the dashboard is the page describing itself, so it is shown to the supervisor as data; it can't start, write or answer a thread. A session's running preview works the same way: click its card's title to open it here, or **Open** to open it in a tab of its own (`/view/preview/`), and the pin stays on the element as the app reloads. On a laptop the dashboard reaches the app through a relay of its own (`127.0.0.1:4800`-`4899`, one per preview). The relay opens the first time you look at a preview and closes when the preview goes. It answers only to its own address, and holds at most 32 live sockets (live reload and the like) per preview. Just opening a preview's address never starts a relay, and a preview shown for the first time asks you to reload once in the dashboard, and a tab of its own reloads itself once. A new `preview` can't use a port in that range. Update the kit in every checkout of the project before you use threads: an older kit refuses thread lines when the log is mirrored to it, and leaves out of its views every answer you gave in a thread. After an update there is a short gap: until the supervisor runs its next command as the supervisor (or the dashboard notices it running), what you type in a thread waits for the supervisor's next check instead of appearing in its terminal. **One log.** Everything structured is a line in one append-only JSON Lines log, validated against `schema/log.schema.json` on write and folded by timestamp with authority rules (owner > supervisor > planner > worker). State is computed from the log plus git; nothing else is stored. One file per writer: planners write `project/log/plan-.jsonl` in their worktree, so a plan lands only through its reviewed PR; everyone else writes `run/log/.jsonl` live, and the supervisor mirrors those into `project/log/` with the `chore-log` PR. @@ -172,10 +172,10 @@ Setting up a fleet needs none of this, and a project never copies it: `cloud/` s - **The deploy gate.** A release asks Render to build the box's image from that release's commit (`.github/workflows/box-deploy.yml`). The job runs in the GitHub environment `box`, skips prereleases, refuses a commit that isn't on `main`, and skips a release that changed nothing the image is built from (`cloud/`, `docs/devteam/dashboard/hub.html`, `docs/devteam/process/operator.md`). A release that changes only the kit doesn't restart the box, so the image's own copy of the kit (`/opt/devteam/kit`, which the operator's commands run) is the one from the last release that changed the image. It relies on settings the repo must keep: the `box` environment accepting only `v*` tags, with no approval step so that every release that changes the image deploys by itself (the owner's choice), `RENDER_DEPLOY_HOOK` held as a secret of that environment and never of the repo (a job outside the environment can't read it, and so can't call the hook with a commit of its choosing), and rulesets on `main` and `v*` tags. A release runs the workflow file as of its own tagged commit, so the checks in that file are only as trustworthy as the commit; the environment is the gate that commit can't edit. **Agents share the owner's `gh` login**, so an agent can do what that login can, including publishing a release or changing these settings. These gates raise the bar and make an unwanted deploy visible; they are not a hard boundary. A deploy that failed or was rejected isn't redone by a later release that leaves the image alone: re-run its job. The gateway keeps its own copies of the kit's artifact type map, CDN list and page policy for public pages (a test fails while they differ), so changing one in the kit changes `cloud/gateway.mjs` too, and that release deploys the box. - **The gateway** (`cloud/gateway.mjs`, Node, no dependencies) is the only thing listening publicly. It checks the passkey, refuses any request whose Origin isn't the page's own (and reads another site's page asks for), and passes requests to the kit as a local browser's, so the kit runs unchanged. It runs as its own user, so no agent can read or write its passkeys. It also serves `public.box.example.com`, from its own copy of what the owner published, with no sign-in; it sets no cookie there, and reads the owner's only to choose the owner's own view of a page (below). - **Public artifacts.** The owner can make a landed artifact readable by anyone at `public.box.example.com///` (`project` must be set in settings.md: it is part of every link). The Library's **Publish** opens a page on the box's own name that shows exactly what goes up; confirming it, the gateway fetches every file from that instance, checks each against the manifest it showed and the tree id against the bytes, keeps its own copy, and has the kit record an owner `artifact-publish` act in the log. From then on nothing on the box changes what is served: not the port, not main, not the log. **Update** publishes main's newer version, **Unpublish** takes a page down within a minute, and `box.example.com/public/` lists what is public. The record moves with the log; on a new box the Library shows each published artifact as not live there, and **Restore all** opens one confirm page for them. Each item starts unticked, because the box holds no copy of what was published, and each is what main holds now: the Library warns when that differs from what the log records (**Review all**), and the page names every file. Nothing else is public: no index, no drafts, no cookies on that name. The total is capped by `DEVTEAM_PUBLIC_MAX` (bytes, default 500 MB). Agents can't publish: only the gateway's own page can, and only for the signed-in owner. An agent on the box can still forge the log's record of a publish, so the record is only ever a reason to ask. Signed in, the owner opening a public link gets a page of the gateway's instead: the project's viewer, showing exactly the published version (served by the kit from git by its tree id), so a thread can be started on it, under a one-line bar whose **Open it as the public sees it** shows the plain page. That needs the project's dashboard running on the box, with a kit that has viewers; otherwise, or whenever anything about it is in doubt, the owner sees the plain page too. A version the box's checkout doesn't have shows main's, and says so. -- **Dev links.** A session's `preview` gets a name of its own, `cool-tiger.box.example.com`, two words derived from the project, the session and a label, under the wildcard the box already has. Only the signed-in owner can open one: it is behind the passkey session like an instance, and nothing about it is ever public. The gateway proxies HTTP and WebSockets to the session's dev server with `localhost` as `Host` and `Origin`, strips the session cookie and refuses `Domain=` cookies, so the app gets an origin of its own and can read nothing of the owner's. `box.example.com/dev/` lists every preview on the box, one tap from the hub. A link goes when its session goes (worktree removal, expiry after 7 days of no `preview`, or a day without an answer), and the kit prints no box link until the box runs a release with this gateway (`DEVTEAM_PREVIEWS=1`). From the installed (Home Screen) hub a link opens in an in-app browser with its own cookies, so the first one asks for the passkey once. A preview's name also answers `/.devteam/lens`, a page of the gateway's own that frames the app for the dashboard's viewer, so a thread can point at an element in a running app; a GET of that path can't reach the app through the link (other methods there still go to the app). +- **Dev links.** A session's `preview` gets a name of its own, `cool-tiger.box.example.com`, two words derived from the project, the session and a label, under the wildcard the box already has. Only the signed-in owner can open one: it is behind the passkey session like an instance, and nothing about it is ever public. The gateway proxies HTTP and WebSockets to the session's dev server with `localhost` as `Host` and `Origin`, strips the session cookie and refuses `Domain=` cookies, so the app gets an origin of its own and can read nothing of the owner's. `box.example.com/dev/` lists every preview on the box, one tap from the hub. A link goes when its session goes (worktree removal, expiry after 7 days of no `preview`, or a day without an answer), and the kit prints no box link until the box runs a release with this gateway (`DEVTEAM_PREVIEWS=1`). From the installed (Home Screen) hub a link opens in an in-app browser with its own cookies, so the first one asks for the passkey once. A preview's name also answers `/.devteam/lens`, a page of the gateway's own that frames the app for the dashboard's viewer, so a thread can point at an element in a running app; a GET of that path can't reach the app through the link (other methods there still go to the app). On a laptop the kit's relay does the same: it serves that page on `GET /.devteam/lens`, and forwards everything else to the app with `localhost` as `Host` and `Origin`, sockets included (only from the relay's own page). The relay answers only to its own address (`127.0.0.1` or `localhost` on its port), takes WebSockets only from its own `Origin`, holds at most 32 sockets per preview, and refuses to be framed by another site. Its lens can be framed only by this machine's hub and dashboards (its policy names them); the app itself can be framed only by its lens, the hub and the dashboard that offered it when it is loaded from its own origin, and by any other page on this machine (any port) on the app's own headers, which are the app's to set. - **Give the box a domain of its own**, not a subdomain of one you use for anything else. A page an agent serves on an instance's name can write cookies for the whole domain, and when too many pile up the gateway clears them, which signs you out of everything else on that domain too. Treat an instance's name as untrusted for the same reason: an agent can answer on an unused port and appear as a tab. A preview's own name relies on this too: it can only be framed, or get its `SameSite` cookies, from a same-site page if the box's name is a registrable domain of its own (not a subdomain of a public suffix like `*.onrender.com`). - **Passkey prompts from an agent's page.** Passkeys belong to `box.example.com`, the parent of every instance's name, so a page an agent serves on an instance's or a preview's name can raise a genuine passkey prompt for the box. It can't sign in with one, since the gateway only accepts sign-ins from the box's own page. But approving a prompt you didn't start reveals your passkey's user ID, and approving a create could replace your passkey in your password manager, locking you out until `devteam-reclaim`. Only approve a prompt you started on the box's own page, and keep a second passkey registered. -- **A preview's framing headers give way once.** The gateway drops an app's `X-Frame-Options` and sets its `frame-ancestors` for one kind of load only: the app framed from its own name, which is how its lens holds it. An app that sends no framing headers at all, as dev servers do by default, gets that policy too. The app then accepts only its lens, the hub and the instance that lists the preview as frame ancestors. The lens page itself can be framed by any of the box's instance names, where an agent's page can also sit (on an unused port), but the app inside it is refused there, so that page sees an empty frame. The list of previews is written by the instances, though, so the app can be read through its lens (its text, HTML, routes and console errors, as the app shows them to you with your cookies for that app) by a page on any instance name that lists the dev server's port as a preview of its own. A port two instances list is shown as ambiguous under every name and served under none, so an agent can't take over a dev server that a real instance lists; one that no instance lists can still be claimed. Keep secrets out of dev apps on the box, as for anything an agent can reach. A top-level load and every other frame get the app's own headers, and another site's frame is still refused. The framed app can still open a window of its own from your click (an OAuth popup, a link), unsandboxed, as it could when opened plain. The lens shares the app's origin, so the app can rewrite it, or lie through it about what is on the page: what reaches the supervisor from there is a description of the page, given to it as data, and it can't start, write or answer a thread. +- **A preview's framing headers give way once.** The gateway drops an app's `X-Frame-Options` and sets its `frame-ancestors` for one kind of load only: the app framed from its own name, which is how its lens holds it. On a laptop the kit's relay gives way the same way, for the same load only, to the lens, the hub and the dashboard that offered the preview. The dashboard computes the `frame-src` of a viewer for each request, from the relays open at that moment, so a relay opened later is outside the policy of a page already loaded; the viewer reloads once, and the stage here says to reload. An app that sends no framing headers at all, as dev servers do by default, gets that policy too. The app then accepts only its lens, the hub and the instance that lists the preview as frame ancestors. The lens page itself can be framed by any of the box's instance names, where an agent's page can also sit (on an unused port), but the app inside it is refused there, so that page sees an empty frame. The list of previews is written by the instances, though, so the app can be read through its lens (its text, HTML, routes and console errors, as the app shows them to you with your cookies for that app) by a page on any instance name that lists the dev server's port as a preview of its own. A port two instances list is shown as ambiguous under every name and served under none, so an agent can't take over a dev server that a real instance lists; one that no instance lists can still be claimed. Keep secrets out of dev apps on the box, as for anything an agent can reach. A top-level load and every other frame get the app's own headers, and another site's frame is still refused. The framed app can still open a window of its own from your click (an OAuth popup, a link), unsandboxed, as it could when opened plain. The lens shares the app's origin, so the app can rewrite it, or lie through it about what is on the page: what reaches the supervisor from there is a description of the page, given to it as data, and it can't start, write or answer a thread. - **The public name reads the session cookie** to choose the owner's own view of a page, and for nothing else. It never forwards the cookie or sets one, that view is never cached (`no-store, private`), and the plain page says `Vary: Cookie, Sec-Fetch-Dest`, so a cache in between can at worst hand the owner the plain page, and never hands that view to anyone else. Both pages open with `Cross-Origin-Opener-Policy: same-origin`, so a page that opened one in a popup can't reach it or read who is visiting. The choice is made from the instances list the gateway reads every 10 seconds, so it asks nothing while you wait and takes the same time whoever visits: a page on another site can't time it to learn you're signed in, and your instance doesn't learn when you open a page. An instance's viewer accepts the public name as a frame ancestor only through a link the owner's view itself made, good for a few minutes, and a published version's files only for that very published version. That is safe because nothing on the public name can frame a page: published pages are served with `frame-src 'none'`, and the owner's view frames only your own instance. The kit says who may frame its viewer and its published versions by policy only, never `X-Frame-Options`, so the gateway's addition is all it takes; the viewer keeps the owner's view's link in its own address and passes nothing of it on. A window the viewer opens from there gets no handle on it: the gateway's opener policy cuts it, and the dashboard opens every new tab without one anyway. - **Notification text is written by agents**, since an agent can appear as an instance, so it can be misleading. Tapping one is safe: it only ever opens the hub. - **The terminal can write to your clipboard.** Copying in tmux reaches your own computer's clipboard (OSC 52), and so can anything an agent prints there, within a few seconds of your typing or clicking in that terminal. Check what you paste from it into a shell. diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 23472a1..f3cf1ca 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -8,6 +8,18 @@ + + + +`; +// Its policy: its one script and its one style by hash, its own origin to frame, and who may hold it. +const previewLensPart = (tag) => new RegExp(`<${tag}>([\\s\\S]*?)`).exec(PREVIEW_LENS)[1]; +const previewLensHash = (tag) => `'sha256-${crypto.createHash('sha256').update(previewLensPart(tag)).digest('base64')}'`; +const previewLensCsp = (ancestors) => `default-src 'none'; script-src ${previewLensHash('script')}; style-src ${previewLensHash('style')}; frame-src 'self'; frame-ancestors ${ancestors}; base-uri 'none'; form-action 'none'`; +// The frame-ancestors directive of each policy in a header, and who may frame the page, replaced in +// every policy that says: the gateway's own (a test holds them equal), for the relay's one relaxation. +const FRAME_ANCESTORS = /(^|[;,])(\s*)frame-ancestors(?=[\s;,]|$)([^;,]*)/gi; +const replaceFrameAncestors = (csp, sources) => csp.replace(FRAME_ANCESTORS, (all, sep, sp, list) => `${sep}${sp}frame-ancestors ${sources}${/\s$/.test(list) ? ' ' : ''}`); +// Where a laptop dashboard's preview relays listen, one per preview its viewer has opened (spec §7): +// off the instance block and PREVIEW_PICK, and no new preview may take one (previewPort). +// DEVTEAM_RELAY_PORTS (low-high) is for tests, each in a range of its own. +const RELAY_PORTS = (() => { + const m = /^(\d{4,5})-(\d{4,5})$/.exec(process.env.DEVTEAM_RELAY_PORTS ?? ''); + const [lo, hi] = m && Number(m[1]) >= 1024 && Number(m[1]) <= Number(m[2]) && Number(m[2]) <= 65535 ? [Number(m[1]), Number(m[2])] : [4800, 4899]; + return Array.from({ length: hi - lo + 1 }, (_, i) => lo + i); +})(); +// The frames a page may be loaded in: a preview's is refused in another site's, as the gateway refuses it. +const PREVIEW_FRAMED = ['iframe', 'frame', 'object', 'embed', 'fencedframe']; // The dashboard's own: the last probe of each name. A preview command has no tick to ask, and // dashboard-data is a child process, so the dashboard hands it over in the environment. const previewUp = new Map(); @@ -6373,6 +6653,7 @@ async function cmdPreview() { let port; if (flags.port !== undefined) { port = previewPort(flagStr('port')); + if (await previewPortIsDashboard(port)) die(`preview: ${port} is a devteam dashboard`); if (!(await previewPortQuiet(port))) die(`preview: something already answers on port ${port}; stop it, or leave out --port and the kit picks one`); } else if (had?.run && PREVIEW_PICK.includes(had.port) && (await previewPortQuiet(had.port))) port = had.port; else port = await previewPickPort(name); @@ -6380,6 +6661,7 @@ async function cmdPreview() { } if (sub !== undefined && !listing) { const port = previewPort(sub); + if (await previewPortIsDashboard(port)) die(`preview: ${port} is a devteam dashboard`); const session = logWriter(); const label = previewLabel(); const { name, had } = await previewClaim(session, label); @@ -12229,8 +12511,9 @@ async function cmdDashboard() { process.on('unhandledRejection', (e) => { log(`crashed (unhandled rejection): ${e?.stack ?? e}`); process.stderr.write(`devteam dashboard crashed: ${e?.stack ?? e}\n`); process.exit(1); }); for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, () => { log(`stopped by ${sig}`); process.exit(0); }); const page = path.join(kitDir, 'dashboard', 'index.html'); - // The page's policy, at / and at /view/: its frames are its own pages, and only it and the hub frame it. - const PAGE_CSP = `frame-src 'self'; frame-ancestors 'self' http://127.0.0.1:${HUB_PORT}`; + // The page's policy, at / and at /view/: its frames are its own pages and, where a preview's lens + // is shown, exactly the origins that serve it (stageFrames, previewStage); only it and the hub frame it. + const pageCsp = (frames = []) => `frame-src ${['\'self\'', ...frames].join(' ')}; frame-ancestors 'self' http://127.0.0.1:${HUB_PORT}`; const self = fileURLToPath(import.meta.url); const ownerEnv = { ...process.env, DEVTEAM_CHANNEL: 'dashboard' }; // TMUX_PANE too: a dashboard started in the supervisor's pane would have ownerWriter refuse every @@ -12364,10 +12647,243 @@ async function cmdDashboard() { const runPreviewTick = () => { if (previewTicking) return; previewTicking = true; - previewTick().catch((e) => log(`previews: tick failed: ${e?.stack ?? e}`)).finally(() => { previewTicking = false; }); + previewTick().catch((e) => log(`previews: tick failed: ${e?.stack ?? e}`)).finally(() => { previewTicking = false; relaysTidy(); }); }; runPreviewTick(); setInterval(runPreviewTick, Number(process.env.DEVTEAM_PREVIEW_TICK_MS ?? 10000)).unref(); + // ----- the laptop's preview relay (spec §7): a loopback listener per preview, opened when its viewer + // is first asked for and closed with its record. It serves the lens on GET /.devteam/lens and + // forwards everything else to the app as the box's gateway does (proxyPreview, downstreamHeaders): + // localhost as Host and Origin, the app's loopback origin mapped to the relay's, and its framing + // headers given way for one load only, the app framed from its own origin, which is how its lens + // holds it. No session (it is loopback, like the dev server) and no cookie rules (no cookie of the + // owner's to protect): decision 1. Where it differs from the gateway, knowingly: it forwards a + // non-GET with a foreign Origin as it is (the app refuses it, or doesn't) and cross-site + // non-navigate fetches, which the gateway refuses; it passes clear-site-data on; and it answers a + // plain 502 when the app is down, where the gateway serves an HTML 503. + const relays = new Map(); + // who may hold the lens here: the hub, every instance in the block, and this dashboard (its port may + // be pinned outside the block), each once (decision 4) + const relayLensCsp = () => previewLensCsp([...new Set([`http://127.0.0.1:${HUB_PORT}`, ...PORT_RANGE.map((p) => `http://127.0.0.1:${p}`), `http://127.0.0.1:${port}`, `http://localhost:${port}`])].join(' ')); + const RELAY_MARK = 'x-devteam-relay'; + const RELAY_HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'referer', 'forwarded']); + const RELAY_LOOP = /\b(http|ws):\/\/(?:127\.0\.0\.1|localhost):(\d+)/g; + // Every route answers only on the relay's own address: a page elsewhere that points its own name at + // 127.0.0.1 (DNS rebinding) sends its own Host, and reaches neither the lens nor the app. + const relayOwnHost = (R, req) => [`127.0.0.1:${R.port}`, `localhost:${R.port}`].includes(req.headers.host ?? ''); + const relayUpHeaders = (req, R) => { + const h = {}; + for (const [k, v] of Object.entries(req.headers)) if (!RELAY_HOP.has(k) && !k.startsWith('x-forwarded-') && k !== RELAY_MARK) h[k] = v; + // set fresh on everything forwarded: a dashboard that ends up on the target refuses it everywhere + h[RELAY_MARK] = '1'; + h.host = `localhost:${R.app}`; + // the relay's own origin, by either of its names, is the app's; any other passes as it is, for the app to refuse + if (req.headers.origin === `http://${req.headers.host}`) h.origin = `http://localhost:${R.app}`; + return h; + }; + const relayDownHeaders = (h, R, framedBySelf) => { + const out = {}; + for (const [k, v] of Object.entries(h)) { + if (['connection', 'keep-alive', 'transfer-encoding'].includes(k)) continue; + out[k] = k !== 'content-security-policy' && k !== 'location' ? v : String(v).replace(RELAY_LOOP, (all, scheme, p) => (Number(p) !== R.app ? all : scheme === 'ws' ? R.own.replace(/^http/, 'ws') : R.own)); + } + if (framedBySelf) { + // the lens, the hub and this dashboard, which frames the lens: no other instance's page holds the app + const ancestors = `'self' http://127.0.0.1:${HUB_PORT} http://127.0.0.1:${port} http://localhost:${port}`; + delete out['x-frame-options']; + const csp = out['content-security-policy'] ? String(out['content-security-policy']) : ''; + if (csp.search(FRAME_ANCESTORS) >= 0) out['content-security-policy'] = replaceFrameAncestors(csp, ancestors); + // The app said nothing of frames by policy: by X-Frame-Options alone, or not at all (a dev + // server's default). Either way a policy of its own keeps every other instance's page out. + else out['content-security-policy'] = `${csp ? `${csp}, ` : ''}frame-ancestors ${ancestors}`; + } + return out; + }; + const relayRequest = (R) => (req, res) => { + const say = (code, text) => { res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); }; + if (!relayOwnHost(R, req)) return say(421, 'this relay answers only on its own address'); + // origin-form only, as the gateway takes it + if (!req.url.startsWith('/') || req.url.startsWith('//')) return say(400, 'bad request'); + let url; + try { url = new URL(req.url, R.own); } catch { return say(400, 'bad request'); } + // A service worker registered here would outlive this preview, and answer for the next one given its port. + if (req.headers['service-worker'] || req.headers['sec-fetch-dest'] === 'serviceworker') return say(403, 'no service workers on a preview'); + // framed only by a page of this machine, the lens too, so no other site lays the app under its own page + if (PREVIEW_FRAMED.includes(req.headers['sec-fetch-dest']) && !['same-origin', 'same-site'].includes(req.headers['sec-fetch-site'])) return say(403, 'not in another site\'s frame'); + // the lens: static, the gateway's bytes, and never the app's; any other method or path is the app's + if (req.method === 'GET' && url.pathname === '/.devteam/lens') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': relayLensCsp(), 'referrer-policy': 'no-referrer' }); + return res.end(PREVIEW_LENS); + } + const framedBySelf = req.headers['sec-fetch-dest'] === 'iframe' && req.headers['sec-fetch-site'] === 'same-origin'; + let current = null; + res.on('close', () => { if (!res.writableFinished) current?.destroy(); }); + // 127.0.0.1, then ::1 when that is refused; the body starts only once a connection is up, so a + // refused attempt has taken none of it. agent: false, a fresh connection each time, as the gateway's. + const attempt = (hosts) => { + let connected = false; + const up = http.request({ host: hosts[0], port: R.app, agent: false, method: req.method, path: req.url, headers: relayUpHeaders(req, R) }, (ur) => { + res.writeHead(ur.statusCode, { ...relayDownHeaders(ur.headers, R, framedBySelf), 'cache-control': 'no-store' }); + pipeline(ur, res, () => {}); + }); + current = up; + up.on('socket', (s) => { const go = () => { connected = true; pipeline(req, up, () => {}); }; if (s.connecting) s.once('connect', go); else go(); }); + up.on('error', (e) => { + if (!connected && e.code === 'ECONNREFUSED' && hosts.length > 1) return attempt(hosts.slice(1)); + if (res.headersSent) return res.destroy(); + if (res.destroyed) return undefined; + return say(502, `Nothing answers on port ${R.app}.`); + }); + }; + return attempt(['127.0.0.1', '::1']); + }; + // A preview's sockets (HMR, mostly), as previewUpgrade carries them on a box: on the relay's own + // address, from a page of its own origin under that name (a page elsewhere can't open the app's + // socket through it), a cap per preview so a client's reconnect loop can't exhaust the dashboard, + // and the bytes relayed raw after a 101, both ways. No cookie rules here (decision 1), so the app's + // 101 passes as it is; any other answer is relayed, then both sides end. + const RELAY_SOCKETS = 32; + const RELAY_LINGER_MS = 2000; + // ended, then closed shortly after whether or not the page ends its side: a page that holds on keeps nothing here + const relayEnd = (socket) => { socket.end(); setTimeout(() => socket.destroy(), RELAY_LINGER_MS).unref(); }; + const relayUpgrade = (R, req, socket, head) => { + const refuse = (code, msg) => { socket.write(`HTTP/1.1 ${code} ${msg}\r\nconnection: close\r\ncontent-length: 0\r\n\r\n`); relayEnd(socket); }; + socket.on('error', () => {}); + if (!relayOwnHost(R, req)) return refuse(421, 'Misdirected Request'); + if (req.headers.origin !== `http://${req.headers.host}`) return refuse(403, 'Forbidden'); + if (!req.url.startsWith('/') || req.url.startsWith('//')) return refuse(400, 'Bad Request'); + if (R.sockets.size >= RELAY_SOCKETS) return refuse(429, 'Too Many Requests'); + R.sockets.add(socket); + socket.once('close', () => R.sockets.delete(socket)); + // 127.0.0.1, then ::1 when that is refused, as a request's + const connect = (hosts) => { + const up = net.connect(R.app, hosts[0]); + let open = false; + let next = false; + // answered without an upgrade: the page's side ends on its own once the answer is written + let answered = false; + up.on('error', (e) => { if (!open && e.code === 'ECONNREFUSED' && hosts.length > 1) next = true; else if (!answered) socket.destroy(); }); + up.once('close', () => { if (next && !socket.destroyed) connect(hosts.slice(1)); else if (!answered) socket.destroy(); }); + up.once('connect', () => { + open = true; + if (socket.destroyed) return up.destroy(); + socket.on('close', () => up.destroy()); + const h = relayUpHeaders(req, R); + h.connection = 'Upgrade'; + h.upgrade = req.headers.upgrade; + up.write(`GET ${req.url} HTTP/1.1\r\n${Object.entries(h).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`).join('\r\n')}\r\n\r\n`); + if (head?.length) up.write(head); + // The answer's head, held until it is whole: a 101 is relayed as it is, then the bytes both + // ways; anything else is the answer to one request, so it and its body (by content-length, + // else until the app ends it) are all the page gets, and both sides end after it. + let held = Buffer.alloc(0); + // an app that takes the socket and never answers holds two sockets for nothing: both go after 10 s without a head + const noHead = setTimeout(() => { up.destroy(); socket.destroy(); }, Number(process.env.DEVTEAM_RELAY_HEAD_MS) || 10000); + up.once('close', () => clearTimeout(noHead)); + socket.once('close', () => clearTimeout(noHead)); + const onHead = (chunk) => { + held = Buffer.concat([held, chunk]); + const end = held.indexOf('\r\n\r\n'); + if (end < 0) { if (held.length > 64 * 1024) { up.destroy(); socket.destroy(); } return; } + clearTimeout(noHead); + up.off('data', onHead); + const top = held.subarray(0, end).toString('latin1'); + const rest = held.subarray(end + 4); + if (/^HTTP\/1\.[01] 101\b/.test(top)) { + socket.write(held); + pipeline(up, socket, () => {}); + pipeline(socket, up, () => {}); + return; + } + answered = true; + const rows = top.split('\r\n').filter((row, i) => i === 0 || !/^(connection|keep-alive)[ \t]*:/i.test(row)); + socket.write(`${rows.join('\r\n')}\r\nconnection: close\r\n\r\n`, 'latin1'); + const len = /\r\ncontent-length:[ \t]*(\d+)[ \t]*(?:\r\n|$)/i.exec(top); + let left = len ? Number(len[1]) : Infinity; + let done = false; + const finish = () => { if (done) return; done = true; clearTimeout(cap); up.destroy(); relayEnd(socket); }; + const body = (b) => { const part = b.subarray(0, Math.min(b.length, left)); if (part.length) socket.write(part); left -= part.length; if (left <= 0) finish(); }; + const cap = setTimeout(finish, 5000); + up.on('data', body); + up.once('end', finish); + up.once('close', finish); + body(rest); + }; + up.on('data', onHead); + return undefined; + }); + }; + return connect(['127.0.0.1', '::1']); + }; + const relayCloseOne = (R) => { for (const s of R.sockets) s.destroy(); R.server?.closeAllConnections?.(); R.server?.close(); }; + const relayClose = (name) => { const R = relays.get(name); if (!R) return; relays.delete(name); relayCloseOne(R); }; + // A relay for this preview, on the first port of the range nothing holds; null when none is free. + const relayFor = (rec) => { + const had = relays.get(rec.name); + if (had?.app === rec.port) return had.ready; + if (had) relayClose(rec.name); + // a record (written by hand, or before this rule) naming this dashboard: never relayed + if (rec.port === port) return Promise.resolve(null); + const R = { name: rec.name, app: rec.port, port: null, own: null, server: null, sockets: new Set() }; + R.ready = (async () => { + // any dashboard answering there (this one's own port aside, another checkout's, or a record that names one): not an app + if (await previewPortIsDashboard(rec.port)) { log(`previews: ${rec.name}: port ${rec.port} answers as a devteam dashboard, not relayed`); if (relays.get(rec.name) === R) relays.delete(rec.name); return null; } + const held = new Set(readPreviews().map((r) => r.port)); + for (const p of RELAY_PORTS) { + // nothing answering there on either family (a dev server on ::1 would take localhost), and no record's + if (held.has(p) || [...relays.values()].some((x) => x !== R && x.port === p) || !(await portFree(p))) continue; + const srv = keepAlive(http.createServer()); + if (!(await new Promise((resolve) => { srv.once('error', () => resolve(false)); srv.listen(p, '127.0.0.1', () => resolve(true)); }))) continue; + Object.assign(R, { port: p, own: `http://127.0.0.1:${p}`, server: srv }); + srv.on('request', relayRequest(R)); + srv.on('upgrade', (req, socket, head) => relayUpgrade(R, req, socket, head)); + srv.on('error', (e) => log(`previews: relay for ${R.name} on ${p}: ${e?.message ?? e}`)); + // closed before it was up (its record gone meanwhile): not kept + if (relays.get(rec.name) !== R) { relayCloseOne(R); return null; } + return p; + } + // none free: nothing kept, so the next ask tries again + if (relays.get(rec.name) === R) relays.delete(rec.name); + return null; + })(); + relays.set(rec.name, R); + return R.ready; + }; + // a relay for a record gone, or one now on another port, closes on the tick after + // and one whose target now answers as a dashboard (one that came up there after the relay did) + const relaysTidy = () => { + const live = new Map(readPreviews().map((r) => [r.name, r.port])); + for (const [n, R] of [...relays]) { + if (live.get(n) !== R.app) relayClose(n); + else if (R.port) previewPortIsDashboard(R.app).then((is) => { if (is && relays.get(n) === R) { log(`previews: ${n}: port ${R.app} now answers as a devteam dashboard, relay closed`); relayClose(n); } }); + } + }; + // Where a preview's lens is, for its viewer (spec §7): its own name through the box's gateway, which + // serves the lens there; else this laptop's relay, opened only when open is set (/api/view's ask); + // null on a box whose gateway doesn't serve previews, where the loopback is out of the browser's + // reach (decision 7). opened: this ask opened the relay, so a page loaded before it lacks it in its + // policy (decision 6). + const previewStage = async (rec, { open = false } = {}) => { + if (previewServed()) return { origin: previewLink(rec), opened: false }; + if (previewBox()) return { origin: null, opened: false }; + const had = relays.get(rec.name); + if (had?.app === rec.port && had.port) return { origin: had.own, opened: false }; + if (!open) return { origin: null, opened: false }; + // one still opening was another ask's: this one waits for it, but didn't open it + const mine = had?.app !== rec.port; + const p = await relayFor(rec); + return p ? { origin: `http://127.0.0.1:${p}`, opened: mine } : { origin: null, opened: false }; + }; + // Where the dashboard's own stage may frame a preview's lens, as of this request (decision 5): each + // preview's own name through the box's gateway, or this laptop's relays open now, each as + // previewStage gives it. Never a wildcard: an agent can answer on any instance name. With none, + // 'self' alone, as before. A relay counts only while its record is there and names its app's port: + // one whose preview has stopped or moved, and which the tick hasn't closed yet, is framed no more. + const stageFrames = () => { + if (previewBox()) return previewServed() ? readPreviews().map(previewLink) : []; + const live = new Map(readPreviews().map((r) => [r.name, r.port])); + return [...relays].filter(([n, R]) => R.port && live.get(n) === R.app).map(([, R]) => R.own); + }; // Account rotation and limit stops (accounts-tick), one run at a time, as the supervisor; a line // it prints goes to the log once, however many ticks repeat it. Every dashboard asks to rotate, // and the claim decides which one does. @@ -12433,13 +12949,15 @@ async function cmdDashboard() { // name at 127.0.0.1 (DNS rebinding) sends its own Host, and gets nothing, not even a read. const okHost = [`127.0.0.1:${port}`, `localhost:${port}`, `${host}:${port}`].includes(req.headers.host ?? ''); if (!okHost) return send(res, 403, 'text/plain; charset=utf-8', 'the dashboard answers only on its own address'); + // a request a preview's relay forwarded (it sends localhost as Host and Origin, which the owner checks accept): never the owner's + if (req.headers[RELAY_MARK] !== undefined) return send(res, 403, 'text/plain; charset=utf-8', 'not through a preview relay'); if (req.method === 'GET' && req.url === '/api/hello') return send(res, 200, 'application/json', helloBody()); // frame-src 'self': a frame of this page (a review page, an HTML mock) may navigate itself, and // this keeps it on the dashboard; a sandbox alone would let it load any URL, query string and all. if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) { // frame-ancestors: only this dashboard and the machine's hub may frame it, so another site // can't lay it under its own page and steer a click onto an owner action. - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': PAGE_CSP }); + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': pageCsp(stageFrames()) }); return res.end(fs.readFileSync(page)); } if (req.method === 'GET' && req.url.startsWith('/api/data')) { @@ -12466,7 +12984,19 @@ async function cmdDashboard() { const vw = req.method === 'GET' ? /^\/view\/artifact\/([^/]+)$/.exec(u.pathname) : null; if (vw) { if (!LINK_ID.test(vw[1]) || !ARTIFACT_AT.test(u.searchParams.get('at') || 'main')) return send(res, 404, 'text/plain; charset=utf-8', 'not found'); - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'content-security-policy': PAGE_CSP }); + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'content-security-policy': pageCsp() }); + return res.end(fs.readFileSync(page)); + } + // A preview's viewer: the same page, framing the preview's lens where it is served (previewStage): + // frame-src names exactly that origin, the preview's own name on a box (the gateway rewrites no + // dev port) or this laptop's relay (decision 5). Never opens a relay: any page can navigate here + // (decision 3); the page's own /api/view does, and says so. + const vp = req.method === 'GET' ? /^\/view\/preview\/([^/]+)$/.exec(u.pathname) : null; + if (vp) { + const rec = PREVIEW_NAME.test(vp[1]) ? readPreviews().find((r) => r.name === vp[1]) : null; + if (!rec) return send(res, 404, 'text/plain; charset=utf-8', 'not found'); + const { origin } = await previewStage(rec); + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'content-security-policy': pageCsp(origin ? [origin] : []) }); return res.end(fs.readFileSync(page)); } // What the viewer's bar and its "changed since" read of what it shows (artifact describe), so a @@ -12479,6 +13009,13 @@ async function cmdDashboard() { if (site !== undefined && site !== 'same-origin') return send(res, 403, 'application/json', JSON.stringify({ error: "not for another site's page" })); const slug = u.searchParams.get('slug') ?? ''; const at = u.searchParams.get('at') || 'main'; + if (u.searchParams.get('kind') === 'preview') { + const rec = readPreviews().find((r) => r.name === u.searchParams.get('name') && PREVIEW_NAME.test(r.name)); + if (!rec) return send(res, 404, 'application/json', JSON.stringify({ error: 'no such preview here' })); + // the one place a relay opens: this same-origin ask, never a GET of /view/preview/ (decision 3) + const { origin, opened } = await previewStage(rec, { open: true }); + return send(res, 200, 'application/json', JSON.stringify({ name: rec.name, label: rec.label, session: rec.session, project: rec.project, up: previewUp.get(rec.name) ?? null, origin, opened, lens: origin ? `${origin}/.devteam/lens` : null, served: previewServed() })); + } if (u.searchParams.get('kind') !== 'artifact' || !LINK_ID.test(slug) || !ARTIFACT_AT.test(at)) return send(res, 400, 'application/json', JSON.stringify({ error: 'bad kind, slug or at' })); const key = `${slug}|${at}`; let job = describing.get(key); diff --git a/docs/superpowers/plans/2026-11-11-supervisor-threads-previews.md b/docs/superpowers/plans/2026-11-11-supervisor-threads-previews.md index 23eef11..fdad384 100644 --- a/docs/superpowers/plans/2026-11-11-supervisor-threads-previews.md +++ b/docs/superpowers/plans/2026-11-11-supervisor-threads-previews.md @@ -32,6 +32,7 @@ - For a load framed from its own origin (`sec-fetch-dest: iframe`, `sec-fetch-site: same-origin`) only, `x-frame-options` goes and every `frame-ancestors` becomes `'self' http://127.0.0.1: http://127.0.0.1:`. An app with no `frame-ancestors` (a dev server's default) gets that as a policy of its own, on that load. - `GET /.devteam/lens` (any query) is the lens. `HEAD`, `POST` and `/.devteam/lens/…` go to the app. - A WebSocket is relayed raw after its handshake, with the same `Host` and `Origin` rewrite, only from the relay's own `Origin`, at most 32 a preview. + - Where it diverges from the gateway on purpose (a laptop's relay holds no session, and a dev app is the owner's own): it forwards a non-GET with a foreign `Origin` as it is, for the app to refuse, and cross-site non-navigate fetches (the gateway refuses both); it passes `clear-site-data` (the gateway strips it); and it answers a plain-text 502 when the app is down, where the gateway serves an HTML 503. - What it doesn't mirror, and why: no session check (the relay listens on 127.0.0.1 only, like the dev server, and holds nothing more), no cookie filtering (no session cookie to protect, and the app's cookies are already `127.0.0.1`'s), and no `Strict-Transport-Security`. 2. **The relay answers only on its own `Host`** (`127.0.0.1:` or `localhost:`), as the dashboard does, so a page that rebinds a name to 127.0.0.1 gets nothing. 3. **One relay per preview, on 4800-4899** (the spec's 4500-4599 moved: see below). Its port is the first in the range that nothing answers on (`portFree`, on 127.0.0.1 and `::1`), that no preview record here holds, and that it can bind. Instances on one laptop share the range and never collide. It opens only from `/api/view?kind=preview` (same-origin, the page's own first ask), never from a GET of `/view/preview/`, which any page can make (a navigation carries no `sec-fetch-site` check). It closes when the record goes or its port changes, from the preview tick. If no port is free, nothing is kept for it, so the next ask tries again. diff --git a/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md b/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md index fd5d7ff..12d24ed 100644 --- a/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md +++ b/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md @@ -170,7 +170,7 @@ In the dashboard's artifact viewer the overlay sits over the existing frame (z-i **Previews use #176's lens and relay**, unchanged in substance: -- The **preview lens** is a small static document at `/.devteam/lens` on the preview's own origin that frames the app and, being same-origin, reads its DOM for the viewer over `postMessage` (the same messages as the artifact lens, plus `route`, `go` and `errors`). Its CSP is hash-only with `frame-ancestors` the hub and the instance origins (every ancestor is checked, and the in-page viewer is hub → dashboard → lens). That policy is the same bytes on every preview's name, because the kit's relay serves the lens too and can't know the box's origins, so any instance name may frame the lens; the app's policy below is what ties it to one instance. On a box the gateway serves it; on a laptop the kit's **relay** (a loopback listener per preview, ports 4500–4599, opened when its viewer is first requested and closed with the record) forwards to the app with `Host: localhost:` and serves the lens. +- The **preview lens** is a small static document at `/.devteam/lens` on the preview's own origin that frames the app and, being same-origin, reads its DOM for the viewer over `postMessage` (the same messages as the artifact lens, plus `route`, `go` and `errors`). Its CSP is hash-only with `frame-ancestors` the hub and the instance origins (every ancestor is checked, and the in-page viewer is hub → dashboard → lens). That policy is the same bytes on every preview's name, because the kit's relay serves the lens too and can't know the box's origins, so any instance name may frame the lens; the app's policy below is what ties it to one instance. On a box the gateway serves it; on a laptop the kit's **relay** (a loopback listener per preview, ports 4800–4899, opened only by the dashboard's own `/api/view` ask (never by a GET of the viewer's address) and closed with the record) forwards to the app with `Host: localhost:` and serves the lens. - The app is framed with `sandbox` minus top navigation. The app's `X-Frame-Options` and `frame-ancestors` are relaxed in exactly one case: a framed load from its own origin (`sec-fetch-dest: iframe`, `sec-fetch-site: same-origin`), which is the lens's. There the app's `frame-ancestors` becomes `'self'`, the hub and the origin of the one instance whose hello lists the preview: `'self'` alone would refuse the app inside its own lens, whose parents are the dashboard and the hub. A lens framed by any other instance's page gets an empty frame. An app that said nothing of frames by policy (`X-Frame-Options` alone, or no framing header at all, a dev server's default) gets that same `frame-ancestors` as a policy of its own, on that load only. A name two instances list, a dev port two instances list (every name on it), or a name the hub doesn't list, is never proxied, so it never reaches the relaxation. The hub's list is agent-written, so the app can be read through its lens by a page on any instance name that lists the dev server's port as a preview of its own; the shared-port rule means that can't be a port a real instance lists, and a dev port that no instance lists can still be claimed. - The lens's source lives once in `devteam.mjs` and once in `cloud/gateway.mjs`; a test asserts they are byte-identical, as the CDN list and type map are. - In the dashboard, the viewer shows previews in the in-page stage too, framing the same lens. diff --git a/test/browser/dev-links.mjs b/test/browser/dev-links.mjs index ee56f3b..c1c2c8d 100644 --- a/test/browser/dev-links.mjs +++ b/test/browser/dev-links.mjs @@ -261,14 +261,16 @@ function makeApp() { const app = path.join(repo, '.worktrees', 'demo-01-page'); git(['worktree', 'add', '-q', '-b', 'ticket/demo-01-page', app, 'HEAD']); fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ name: 'dev-links-app', private: true, type: 'module' })); - fs.writeFileSync(path.join(app, 'index.html'), 'Vite page
\n'); + fs.writeFileSync(path.join(app, 'index.html'), 'Vite page
\n'); writeMain(app, 'hello one'); say('installing vite (npm, the one network step)'); execFileSync('npm', ['install', '--no-audit', '--no-fund', '--silent', 'vite@5'], { cwd: app, stdio: 'inherit', env: { ...process.env, PATH: `${nodeBin}${path.delimiter}${process.env.PATH}`, TMUX_TMPDIR: tmuxDir } }); return app; } -function writeMain(app, text) { - fs.writeFileSync(path.join(app, 'main.js'), `document.getElementById('app').textContent = ${JSON.stringify(text)};\nif (import.meta.hot) import.meta.hot.accept();\n`); +// top: an edit that also moves the element down, so a pin on it has to follow it +function writeMain(app, text, { top = null } = {}) { + const move = top === null ? '' : `document.getElementById('app').style.marginTop = '${top}px';\n`; + fs.writeFileSync(path.join(app, 'main.js'), `document.getElementById('app').textContent = ${JSON.stringify(text)};\n${move}if (import.meta.hot) import.meta.hot.accept();\n`); } function startDashboard(port) { const p = spawn(process.execPath, [DT, 'dashboard', '--port', String(port)], { cwd: repo, env, stdio: 'ignore' }); @@ -287,10 +289,14 @@ async function throughCard({ hubUrl, dashUrl, name, previewUrl, socketPrefix, ap const clip = await browser.readClipboard(dashUrl); assert.equal(clip, previewUrl); stepOk(`Copy link put ${clip} on the clipboard, from the hub's frame`); + // Open: the viewer, in a tab of its own; the page itself is the link the card copies const before = new Set(browser.pages().map((s) => s.targetId)); - await browser.click(hubUrl, dashUrl, `${card} a[target=_blank]`); - const tab = await until(() => browser.pages().find((s) => !before.has(s.targetId) && (s.url ?? '').startsWith(previewUrl)), 'Open to make a new tab at the preview'); - stepOk(`Open made a new tab at ${tab.url}`); + await browser.click(hubUrl, dashUrl, `${card} a.av-open`); + const viewerTab = await until(() => browser.pages().find((s) => !before.has(s.targetId) && /\/view\/preview\//.test(s.url ?? '')), 'Open to make a new tab at the viewer'); + stepOk(`Open made a new tab at the viewer, ${viewerTab.url}`); + await browser.cdp.send('Target.closeTarget', { targetId: viewerTab.targetId }); + const tab = await browser.newPage(previewUrl); + stepOk(`the copied link opens in a tab of its own: ${tab.url}`); await until(() => browser.evalIn(previewUrl, `document.getElementById('app')?.textContent === 'hello one'`), 'the Vite page to render'); stepOk('the new tab shows the Vite page'); const hmr = await until(() => tab.sockets.find((w) => w.url.startsWith(socketPrefix) && w.status === 101), 'the HMR socket to open'); @@ -323,6 +329,84 @@ async function laptop({ HUB, DASH, app }) { return { name, previewPort, hub }; } +// ===== threads on a running app: the viewer through the laptop's relay, a pin through hot reload ===== +async function previewThreads({ DASH, name, previewPort, app }) { + const viewer = `http://127.0.0.1:${DASH}/view/preview/${name}`; + const tab = await browser.newPage(viewer); + // a relay this tab's own ask opened makes it load once more (decision 6): either way its lens says ready, on / + await until(() => browser.evalMain(viewer, `TS.ready && TS.route === '/'`), 'the lens through the relay'); + const lensUrl = await browser.evalIn(viewer, `document.getElementById('pv-frame').src`); + assert.match(lensUrl, /^http:\/\/127\.0\.0\.1:48\d\d\/\.devteam\/lens\?route=%2F$/); + const relay = new URL(lensUrl); + assert.notEqual(relay.port, String(previewPort), 'the relay is not Vite\'s own port'); + assert.equal(await browser.evalMain(viewer, 'TS.origin'), relay.origin, 'what goes to the lens goes to the relay only'); + const inApp = (expr) => browser.evalIn(lensUrl, `(() => { const o = document.getElementById('app').getBoundingClientRect(), d = document.getElementById('app').contentDocument; return ${expr}; })()`); + await until(() => inApp(`d && d.getElementById('app') && d.getElementById('app').textContent`), 'the Vite page inside the lens'); + assert.equal(await inApp('d.location.origin'), relay.origin, 'the app framed on the relay, not on its own port'); + stepOk(`the viewer frames the lens on the relay (${relay.origin}), and the lens the Vite app, both on the relay`); + + const hmr = await until(() => tab.sockets.find((w) => w.url.startsWith(`ws://127.0.0.1:${relay.port}/`) && w.status === 101), 'the HMR socket through the relay'); + assert.ok(!tab.sockets.some((w) => w.url.startsWith(`ws://127.0.0.1:${previewPort}/`)), 'no socket straight to Vite'); + stepOk(`the app's HMR socket opened through the relay: ${hmr.url.split('?')[0]} (101), none straight to Vite on ${previewPort}`); + + // Shift and point at the app's element, then click: a thread on it, at its route + const f = await browser.evalIn(viewer, `(() => { const r = document.getElementById('pv-frame').getBoundingClientRect(); return { x: r.left, y: r.top }; })()`); + const el = await inApp(`(() => { const r = d.getElementById('app').getBoundingClientRect(); return { x: o.left + r.left + 10, y: o.top + r.top + r.height / 2 }; })()`); + await browser.cdp.send('Page.bringToFront', {}, tab.sessionId).catch(() => {}); + // a click in the viewer's bar makes sure of the keyboard + const bar = await browser.evalIn(viewer, `(() => { const r = document.getElementById('av-title').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`); + await browser.mouseAt(tab, bar.x, bar.y, 0, true); + await until(() => browser.evalIn(viewer, 'document.hasFocus()'), 'the keyboard in the viewer'); + await browser.shift(tab, true); + await until(() => browser.evalIn(viewer, `!document.getElementById('av-over').hidden`), 'the overlay, Shift held'); + await browser.mouseAt(tab, f.x + el.x, f.y + el.y, 8); + await until(() => browser.evalMain(viewer, `!document.getElementById('tl-hl').hidden && TS.hit?.anchor?.selector === '#app' && document.getElementById('tl-chip').textContent.length > 0`), 'the app\'s element named'); + stepOk(`Shift and point over the Vite app: the lens names its element (#app), outlined, the chip "${await browser.evalIn(viewer, `document.getElementById('tl-chip').textContent`)}"`); + await browser.mouseAt(tab, f.x + el.x, f.y + el.y, 8, true); + await browser.shift(tab, false); + await until(() => browser.evalMain(viewer, `TL.draft?.subject?.kind === 'preview' && TL.draft.subject.anchor?.selector === '#app'`), 'a thread about to start on it'); + await browser.evalIn(viewer, `document.getElementById('tl-c-text').focus()`); + await browser.typeIn(tab, 'Say hello louder'); + await browser.enter(tab); + const th = await until(() => JSON.parse(dt(['thread', 'list', '--json'])).find((x) => x.subject?.kind === 'preview' && x.subject.name === name), 'the thread in the log'); + assert.deepEqual([th.subject.route, th.subject.anchor?.selector, th.turns[0].text], ['/', '#app', 'Say hello louder']); + // Escape, from the card's own box, closes it: the pin stays, and the app's link is clear of the card + for (const type of ['keyDown', 'keyUp']) await browser.cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }, tab.sessionId); + await until(() => browser.evalIn(viewer, `document.getElementById('tl-card').hidden`), 'the card closed'); + // the pin, against the element it is on: the lens found it (a rect for this thread), not the point alone + const where = async () => { + const p = await browser.evalMain(viewer, `(() => { const b = document.querySelector('#av-pins .tl-pin'); return b && TS.rects.get(${JSON.stringify(th.id)})?.rect ? { left: parseFloat(b.style.left), top: parseFloat(b.style.top) } : null; })()`); + if (!p) return null; + const e = await inApp(`(() => { const r = d.getElementById('app').getBoundingClientRect(); return { x: o.left + r.left, y: o.top + r.top }; })()`); + return { dx: p.left - e.x, dy: p.top - e.y, top: e.y }; + }; + const before = await until(where, 'its pin, on the element the lens found'); + stepOk(`a Shift+click on the Vite app started a thread on a preview (${name}) at route /, on #app, pinned there`); + + // an edit that moves the element: hot-reloaded through the relay's socket, and the pin goes with it + const boot = await inApp('d.documentElement.dataset.boot'); + const seen = hmr.frames.length; + writeMain(app, 'hello again, hot', { top: 120 }); + await until(() => hmr.frames.slice(seen).some((x) => /"type":"update"/.test(x)), 'an update frame on the relay\'s socket'); + await until(() => inApp(`d.getElementById('app').textContent === 'hello again, hot'`), 'the hot reload inside the lens'); + assert.equal(await inApp('d.documentElement.dataset.boot'), boot, 'a hot update, not a navigation'); + const after = await until(async () => { const w = await where(); return w && w.top > before.top && Math.abs(w.dx - before.dx) <= 1 && Math.abs(w.dy - before.dy) <= 1 ? w : null; }, 'the pin to follow its element'); + stepOk(`an edit hot-reloaded inside the lens (an update frame through the relay, no navigation), the element moved ${Math.round(after.top - before.top)}px, and the pin re-resolved onto it`); + + // a real click on the app's own link: the app navigates, and the stage's route follows + const next = await inApp(`(() => { const r = d.getElementById('next').getBoundingClientRect(); return { x: o.left + r.left + r.width / 2, y: o.top + r.top + r.height / 2 }; })()`); + const nx = f.x + next.x; + const ny = f.y + next.y; + assert.equal(await browser.evalIn(viewer, `document.elementFromPoint(${nx}, ${ny})?.id`), 'pv-frame', 'the app\'s link is under nothing of the viewer\'s'); + await browser.mouseAt(tab, nx, ny, 0, true); + await until(() => browser.evalMain(viewer, `TS.route === '/next' && document.getElementById('av-where').textContent.includes('/next')`), 'the route to follow the app'); + assert.equal(await inApp('d.location.pathname'), '/next'); + await until(() => browser.evalIn(viewer, `!document.querySelector('#av-pins .tl-pin')`), 'the pin, on /, gone from /next'); + stepOk('a click on a link inside the app took it to /next: the stage\'s route and its bar say so, and the pin on / is not drawn there'); + assert.ok(!tab.sockets.some((w) => w.url.startsWith(`ws://127.0.0.1:${previewPort}/`)), 'still no socket straight to Vite'); + await browser.cdp.send('Target.closeTarget', { targetId: tab.targetId }); +} + // ===== the gateway half: a real gateway on box.localhost, a real passkey (a virtual authenticator) ===== async function viaGateway({ DASH, HUB, app, G, SP, goal }) { const pub = `http://box.localhost:${G}`; @@ -447,9 +531,16 @@ async function threads({ DASH, pane }) { stepOk(`the supervisor's turn showed in the card after ${ms} ms`); const Q = dt(['thread', 'ask', T, '--question', 'Narrow the goal to EU orders?', '--choices', '[{"label":"Yes"},{"label":"Not yet"}]', '--recommendation', 'Yes', '--goal', G, '--stake', '2', '--urgency', '1'], SUP); await until(() => browser.evalIn(dash, `!!document.getElementById('t-q-${Q}')`), 'the question in the card', 30000); - await browser.click(dash, dash, `#t-q-${Q} input[value="Yes"]`); - await browser.click(dash, dash, `#t-q-${Q} button[data-answer="${Q}"]`); - await until(() => JSON.parse(dt(['questions', '--id', Q, '--json']))[0].status === 'answered', 'the answer in the log'); + // the card rebuilds every 10 s, so each attempt finds its elements again; a click that lands on a rebuilt card is tried again + const answered = () => JSON.parse(dt(['questions', '--id', Q, '--json']))[0].status === 'answered'; + await until(async () => { + if (answered()) return true; + await browser.click(dash, dash, `#t-q-${Q} input[value="Yes"]`); + if (!(await browser.evalIn(dash, `!!document.querySelector('#t-q-${Q} input[value="Yes"]')?.checked`))) return false; + try { await browser.click(dash, dash, `#t-q-${Q} button[data-answer="${Q}"]`); } catch (e) { if (!/^no /.test(e.message)) throw e; } + await sleep(300); + return answered(); + }, 'the answer in the log', 30000); await until(() => browser.evalIn(dash, `!document.getElementById('q-${Q}') || document.getElementById('q-${Q}').classList.contains('done')`), 'the Inbox copy answered', 20000); stepOk('a question asked in the thread, answered in the card, is answered in the Inbox'); const pinAt = () => browser.evalIn(dash, `(() => { const p = document.querySelector('.tl-pin'), g = document.querySelector('${card}'); if (!p || !g) return null; const a = p.getBoundingClientRect(), b = g.getBoundingClientRect(); return Math.round(a.left + a.width / 2 - b.right) + ',' + Math.round(a.top + a.height / 2 - b.top); })()`); @@ -804,7 +895,8 @@ async function main() { startDashboard(DASH); browser = await new Browser().start(path.join(base, 'chrome')); // the laptop half leaves the hub open, framing this same dashboard: closed, so a step here reads the thread tab's page - if (!only) { const lap = await laptop({ HUB, DASH, app }); await browser.cdp.send('Target.closeTarget', { targetId: lap.hub.targetId }); } + // and the preview it offered, still running, is the app the viewer's threads are on + if (!only) { const lap = await laptop({ HUB, DASH, app }); await previewThreads({ DASH, name: lap.name, previewPort: lap.previewPort, app }); await browser.cdp.send('Target.closeTarget', { targetId: lap.hub.targetId }); } const goal = await threads({ DASH, pane }); if (!only) await viaGateway({ HUB, DASH, app, G: block + 30, SP: block + 20, goal }); say('all steps passed'); diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index 236ed17..6a94b6b 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -1246,8 +1246,8 @@ test("the thread layer's rules: what the pointer is on, a subject that fits, whe const html = fs.readFileSync(path.join(kit, 'dashboard/index.html'), 'utf8'); const src = html.slice(html.indexOf('/* threads-core start */'), html.indexOf('/* threads-core end */')); assert.ok(src.length > 500, 'the threads-core block is marked in index.html'); - const ctx = vm.createContext({ TextEncoder }); - vm.runInContext(`${src}\n;globalThis.api = { T_CAPS, T_ANCHOR_CAPS, T_SUBJECT_KEYS, T_ERRORS, T_ERROR_MAX, T_AT_MAX, T_BOX_CAPABILITY, T_BOX_CAPABILITY_TAIL, tUncap, tBox, tAnchor, T_TYPES, T_REF, T_REF_MAX, T_KIND, T_HOME, T_WAIT_MS, T_TURN_MAX, T_ENCODED_MAX, T_SUBJECT_MAX, T_TITLE_MAX, tBytes, tCut, tLine, tSubjectAt, tFit, tCardState, tPlace, tWhere, tPins, tUnread, T_LENS_SAYS, tLensMessage, tArtPath, tFrameTitle, tFrameOn, tKey };`, ctx); + const ctx = vm.createContext({ TextEncoder, URL }); + vm.runInContext(`${src}\n;globalThis.api = { T_CAPS, T_ANCHOR_CAPS, T_SUBJECT_KEYS, T_ERRORS, T_ERROR_MAX, T_AT_MAX, T_BOX_CAPABILITY, T_BOX_CAPABILITY_TAIL, tUncap, tBox, tAnchor, T_TYPES, T_REF, T_REF_MAX, T_KIND, T_HOME, T_WAIT_MS, T_TURN_MAX, T_ENCODED_MAX, T_SUBJECT_MAX, T_TITLE_MAX, tBytes, tCut, tLine, tSubjectAt, tFit, tCardState, tPlace, tWhere, tPins, tUnread, T_LENS_SAYS, tLensMessage, tArtPath, tFrameTitle, tFrameOn, tKey, tGoRoute };`, ctx); const A = ctx.api; const plain = (x) => JSON.parse(JSON.stringify(x)); // the page and the kit name the same things, and the page sends no ref the log refuses @@ -1302,8 +1302,12 @@ test("the thread layer's rules: what the pointer is on, a subject that fits, whe assert.ok(A.tBytes(JSON.stringify(fit)) <= 2500); assert.equal(fit.errors.length, 3, 'the errors kept while the HTML can give'); assert.ok(fit.anchor.html.length < 1024); - const noHtml = A.tFit({ ...big, anchor: { tag: 'button' }, errors: ['\u0001'.repeat(200), '\u0001'.repeat(200), '\u0001'.repeat(200)] }); + // no HTML to give: the errors give next (each '"' is two bytes in the log's JSON; a control + // character, which was six, is dropped now, as from an anchor's words) + const q = (n) => '"'.repeat(n); + const noHtml = A.tFit({ ...big, route: q(300), anchor: { tag: q(40), role: q(40), text: q(80), name: q(80), selector: q(200) }, errors: [q(200), q(200), q(200)] }); assert.ok(A.tBytes(JSON.stringify(noHtml)) <= 2500 && noHtml.errors.length < 3); + assert.deepEqual(plain(A.tFit({ ...big, errors: ['\u0001'.repeat(200)] })).errors, [''], 'no control character kept'); assert.ok(A.tBytes(JSON.stringify(A.tFit({ kind: 'item', ref: `goal:${G}`, quote: '"'.repeat(1500) }))) <= 2500); assert.deepEqual(plain(A.tFit(hit.subject)), plain(hit.subject), 'a small subject is left alone'); // every field to its own cap, whatever the total: what a lens says is the framed page's, never trusted @@ -1405,6 +1409,18 @@ test("the thread layer's rules: what the pointer is on, a subject that fits, whe assert.equal(msg({ lens: 1, type: 'rects', rects: Array.from({ length: 5000 }, (_, i) => ({ key: `t-${i}`, rect: null })) }).rects.length, 200, 'at most 200'); assert.deepEqual(plain(msg({ lens: 1, type: 'key', key: 'Delete', shift: 1 })), { type: 'key', key: 'Shift', shift: false }); assert.deepEqual(plain(msg({ lens: 1, type: 'errors', errors: ['a', 'b', 'c', 'd'.repeat(300), 5] })).errors.map((e) => A.tBytes(e)), [1, 1, 200], 'the last three, each cut'); + // a preview's route and errors are its app's words: no control or bidi character, as an anchor's + assert.deepEqual(plain(msg({ lens: 1, type: 'route', route: '/che\u202eckout\u0007' })), { type: 'route', route: '/checkout' }); + assert.deepEqual(plain(msg({ lens: 1, type: 'errors', errors: [`Failed at ${cap} \u2066x\n`] })).errors, ['Failed at [link removed] x']); + assert.equal(plain(msg({ lens: 1, type: 'hit-reply', id: 'h1', anchor, route: '/a\u202eb' })).route, '/ab'); + const pvFit = plain(A.tFit({ kind: 'preview', name: 'cool-tiger', route: '/a\u202eb', errors: ['x\u2067y'], point: { x: 1, y: 2 } })); + assert.deepEqual([pvFit.route, pvFit.errors], ['/ab', ['xy']], 'and what the log is sent, likewise'); + // where a preview's lens may be told to go: a path of the app's own, and nothing else + const relay = 'http://127.0.0.1:4862'; + for (const [r, want] of [['/', '/'], ['/checkout?step=2#pay', '/checkout?step=2#pay'], ['/a/../b', '/b'], ['/a b', '/a%20b']]) assert.equal(A.tGoRoute(r, relay), want, r); + for (const r of ['//evil.example/x', '/\\evil.example', '/\t/evil.example', 'https://evil.example/', 'javascript:alert(1)', 'checkout', '', '/.devteam/lens', '/x/%2e%2e/.devteam/lens', '/\u202ex', `/${'a'.repeat(300)}`, null, 7]) assert.equal(A.tGoRoute(r, relay), null, JSON.stringify(r)); + assert.equal(A.tGoRoute('/x', 'https://cool-tiger.box.example.com'), '/x', "a box's preview, by its own name"); + assert.equal(A.tGoRoute('//evil.example', null), null, 'with no origin known, still only a path'); // the page inside an artifact, under any of its prefixes assert.equal(A.tArtPath('/artifact/main/pricing-mock/', 'pricing-mock'), 'index.html'); assert.equal(A.tArtPath('/c/a.b.c/artifact/tree/abc123/pricing-mock/sub/p.html', 'pricing-mock'), 'sub/p.html'); @@ -1519,6 +1535,98 @@ addEventListener('message', (e) => { const m = e.data; if (e.source === parent & }; // What an artifact's own scripts post to the page, kept for a test. const OWN_MESSAGES = "(() => { window.__own = []; addEventListener('message', (e) => { if (e.data && e.data.own) window.__own.push(e.data); }); })();"; +// A dev server of the test's own: what it was asked (method, url, Host, Origin) is kept; its framing +// headers are the test's (none, as dev servers send by default); it redirects /go to its own origin, +// and answers a WebSocket upgrade with one line, then echoes what it is sent until the other end +// ends (on /no-ws it answers 400 instead and holds its side open: what it is sent after is kept as +// after, and noWsEnded says the other end ended). sockets: those open now. +// pages: a page of the test's own for a path (its query aside), in place of that one. +async function fakeApp(headers = {}, pages = {}) { + const A = { seen: [], marks: [], headers, sockets: new Set() }; + A.srv = http.createServer((q, s) => { + // the relay's own looks (once on opening, then each tick) to know the target isn't a dashboard are not the page's traffic + if (q.url === '/api/hello' && !Object.hasOwn(pages, '/api/hello')) { s.writeHead(404); return s.end(); } + A.seen.push({ method: q.method, url: q.url, host: q.headers.host, origin: q.headers.origin ?? null }); + A.marks.push(q.headers['x-devteam-relay'] ?? null); + if (q.url === '/go') { s.writeHead(302, { location: `http://localhost:${A.port}/there` }); return s.end(); } + s.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...A.headers }); + const own = q.url.split('?')[0]; + s.end(Object.hasOwn(pages, own) ? pages[own] : `App

${q.url}

`); + }); + A.srv.on('upgrade', (q, socket) => { + A.seen.push({ method: 'UPGRADE', url: q.url, host: q.headers.host, origin: q.headers.origin ?? null }); + A.sockets.add(socket); + socket.on('close', () => A.sockets.delete(socket)); + socket.on('error', () => {}); + // /silent: it takes the connection and never says a word, as a hung app would, ending its own side when the other goes + if (q.url.startsWith('/silent')) { socket.on('end', () => socket.end()); return socket.resume(); } + // /no-ws: it won't upgrade, says why, and keeps its side open (as a keep-alive server would) + if (q.url.startsWith('/no-ws')) { socket.on('data', (d) => { A.after = (A.after ?? '') + d; }); socket.on('end', () => { A.noWsEnded = true; }); return socket.write('HTTP/1.1 400 Bad Request\r\ncontent-type: text/plain\r\ncontent-length: 7\r\nconnection: keep-alive\r\n\r\nno, ws\nand then more bytes the relay never sends\n'); } + socket.write('HTTP/1.1 101 Switching Protocols\r\nupgrade: websocket\r\nconnection: Upgrade\r\n\r\nhello from the app\n'); + socket.on('data', (d) => socket.write(d)); + // as a WebSocket server does: the other end gone, it ends its own + socket.on('end', () => socket.end()); + }); + await new Promise((r) => A.srv.listen(0, '127.0.0.1', r)); + A.port = A.srv.address().port; + A.close = () => { for (const s of A.sockets) s.destroy(); A.srv.closeAllConnections?.(); A.srv.close(); }; + return A; +} +// An HTTP/1.1 request by hand (fetch can't set Host or keep a socket open): the status line and headers. +function rawRequest(port, lines) { + return new Promise((resolve, reject) => { + const c = net.connect(port, '127.0.0.1', () => c.write(`${lines.join('\r\n')}\r\n\r\n`)); + let got = ''; + c.on('data', (d) => { got += d; if (/\r\n\r\n[\s\S]*\n/.test(got) || /^HTTP\/1\.1 [45]/.test(got)) { c.destroy(); resolve(got); } }); + c.on('error', reject); + setTimeout(() => { c.destroy(); resolve(got); }, 3000); + }); +} +// A socket by hand, kept open: what it has been sent so far, and closed once either end has closed it. +// halfOpen: it never ends its own side, so only the other end's destroy closes it. +function rawSocket(port, lines, { halfOpen = false } = {}) { + const c = net.connect({ port, host: '127.0.0.1', allowHalfOpen: halfOpen }, () => c.write(`${lines.join('\r\n')}\r\n\r\n`)); + const S = { c, got: '', closed: false }; + c.once('close', () => { S.closed = true; }); + c.on('data', (d) => { S.got += d; }); + c.on('error', () => {}); + return S; +} +// The laptop's relays, in a range of this file's own block of ports (lib/ports.mjs), off every other +// test file's and checkout's: the ports test holds all of it. +const RELAY_LO = PORTS + 62; +const RELAY_HI = PORTS + 79; +const RELAY_ENV = { DEVTEAM_RELAY_PORTS: `${RELAY_LO}-${RELAY_HI}` }; +// The dashboard framed by a page of its own origin (a preview's app navigating its lens frame, or +// an artifact's page its own, to the dashboard's address) draws nothing and runs nothing. +test('the dashboard inside a frame of its own origin draws one line and runs nothing', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 120000 }, async (t) => { + const undo = []; + t.after(async () => { let err = null; for (const f of undo.reverse()) { try { await f(); } catch (e) { err ??= e; } } if (err) throw err; }); + const F = threadsFixture('framed-dash'); + undo.push(() => F.done()); + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const dash = startDashboard(F.old, F.env({}), ['--port', String(port)]); + undo.push(() => dash.stop()); + const chrome = await openChrome(); + undo.push(() => chrome.close()); + const p = await chrome.newPage(); + undo.push(() => p.close()); + await until(() => /dashboard on http/.test(dash.out()), 'the dashboard'); + await p.send('Page.enable'); + await p.send('Runtime.enable'); + await p.send('Page.addScriptToEvaluateOnNewDocument', { source: COUNT_FETCH }); + const soon = soonIn(p); + await p.send('Page.navigate', { url: `${B}/` }); + await soon(`document.readyState === 'complete' && typeof DATA !== 'undefined' && !!document.querySelector('.app')`, 'the dashboard itself'); + for (const route of ['/', '/#inbox', '/view/artifact/x']) { + const seen = await p.ev(`new Promise((resolve) => { const f = document.createElement('iframe'); f.id = 'probe'; setTimeout(() => { const w = f.contentWindow, d = f.contentDocument; resolve({ framed: !!w.frameElement, text: d.body.textContent.trim(), app: !!d.querySelector('.app, #artifact-view'), booted: typeof w.DATA !== 'undefined' || typeof w.TS !== 'undefined', asked: w.__asked ?? null }); f.remove(); }, 2500); f.src = ${JSON.stringify(route)}; document.body.append(f); })`); + assert.deepEqual(seen, { framed: true, text: 'Open the dashboard in its own tab', app: false, booted: false, asked: [] }, `framed at ${route}`); + } + // the page itself, not framed, still runs + assert.equal(await p.ev(`!!document.querySelector('.app') && typeof DATA !== 'undefined'`), true); +}); + test('the viewer: one artifact on a stage the size of the tab, the threads and hello polled, never /api/data', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 180000 }, async (t) => { const undo = []; t.after(async () => { let err = null; for (const f of undo.reverse()) { try { await f(); } catch (e) { err ??= e; } } if (err) throw err; }); @@ -1527,7 +1635,18 @@ test('the viewer: one artifact on a stage the size of the tab, the threads and h const T = landArtifact(F, 'viewer-mock', VIEWER_MOCK); const port = await freePort(); const B = `http://127.0.0.1:${port}`; - const dash = startDashboard(F.old, F.env({}), ['--port', String(port)]); + // A page of another origin to frame the viewer from, as the hub and the owner's wrapper do: the + // dashboard's frame-ancestors name the hub's address, so a small server stands in as the hub. + const hubPort = await freePort(); + const hub = http.createServer((q, r) => { + const src = new URL(q.url, 'http://x').searchParams.get('src'); + if (!q.url.startsWith('/frame') || !src) { r.writeHead(404); return r.end(); } + r.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + r.end(`parent`); + }); + await new Promise((r) => hub.listen(hubPort, '127.0.0.1', r)); + undo.push(() => new Promise((r) => { hub.closeAllConnections?.(); hub.close(r); })); + const dash = startDashboard(F.old, F.env({ DEVTEAM_HUB_PORT: String(hubPort) }), ['--port', String(port)]); undo.push(() => dash.stop()); const chrome = await openChrome(); undo.push(() => chrome.close()); @@ -1638,41 +1757,40 @@ test('the viewer: one artifact on a stage the size of the tab, the threads and h assert.deepEqual(await p.ev('[DATA, document.title]'), [null, 'Viewer mock · devteam']); }); + // the viewer framed from a page of another origin (the hub's address), read by its own script world + const soonV = soonIn({ ev: (e) => p.evIn(B, e) }); await t.test('framed without the keyboard, the viewer says to click it first; with it, nothing', async () => { - await go(`${B}/api/hello`); - await soon(`document.readyState === 'complete'`, 'a page of the same origin'); + await go(`http://127.0.0.1:${hubPort}/frame?src=${encodeURIComponent('/view/artifact/viewer-mock')}`); + await soonV(`document.getElementById('av-title')?.textContent === 'Viewer mock'`, 'the framed viewer'); // a real click, and what it should do: a headless tab can be left without the window's focus, so // a click that didn't move the keyboard is tried once more with the tab brought to the front const clickFor = async (x, y, expr, what) => { await p.clickAt(x, y); - if (await soonIn(p, 5000)(expr, what).then(() => true, () => false)) return; + if (await soonIn({ ev: (e) => p.evIn(B, e) }, 5000)(expr, what).then(() => true, () => false)) return; await p.send('Page.bringToFront'); await p.clickAt(x, y); - await soon(expr, what); + await soonV(expr, what); }; - await p.ev(`(() => { const f = document.createElement('iframe'); f.id = 'v'; f.style.cssText = 'width:900px;height:600px'; f.src = '/view/artifact/viewer-mock'; document.body.append(f); return true; })()`); - await soon(`document.getElementById('v').contentDocument?.getElementById('av-title')?.textContent === 'Viewer mock'`, 'the framed viewer'); await p.ev('document.body.focus(); window.focus(); true'); - await soon(`!document.getElementById('v').contentDocument.getElementById('av-hint').hidden`, 'the hint, the top page having the keyboard'); - assert.equal(await p.ev(`document.getElementById('v').contentDocument.getElementById('av-hint').textContent`), 'Click the page, then hold Shift to point at something'); + await soonV(`!document.getElementById('av-hint').hidden`, 'the hint, the top page having the keyboard'); + assert.equal(await p.evIn(B, `document.getElementById('av-hint').textContent`), 'Click the page, then hold Shift to point at something'); // a real click on the framed viewer's bar, as the hint says const bar = await p.ev(`(() => { const r = document.getElementById('v').getBoundingClientRect(); return { x: r.left + 450, y: r.top + 20 }; })()`); - await clickFor(bar.x, bar.y, `document.getElementById('v').contentDocument.getElementById('av-hint').hidden`, 'gone once the viewer has it'); + await clickFor(bar.x, bar.y, `document.getElementById('av-hint').hidden`, 'gone once the viewer has it'); // a click on the top page, outside the viewer, takes the keyboard back: the hint again - await clickFor(1100, 700, `!document.getElementById('v').contentDocument.getElementById('av-hint').hidden`, 'the hint, the keyboard gone'); + await clickFor(1100, 700, `!document.getElementById('av-hint').hidden`, 'the hint, the keyboard gone'); // a click on the artifact itself, in the viewer's own frame, gives it to the viewer's page - const art = await p.ev(`(() => { const v = document.getElementById('v').getBoundingClientRect(), f = document.getElementById('v').contentDocument.getElementById('av-frame').getBoundingClientRect(); return { x: v.left + f.left + 200, y: v.top + f.top + 200 }; })()`); - await clickFor(art.x, art.y, `document.getElementById('v').contentDocument.activeElement === document.getElementById('v').contentDocument.getElementById('av-frame')`, 'the artifact has the keyboard'); - await soon(`document.getElementById('v').contentDocument.getElementById('av-hint').hidden`, 'gone once the artifact has it'); + const v = await p.ev(`(() => { const r = document.getElementById('v').getBoundingClientRect(); return { x: r.left, y: r.top }; })()`); + const f = await p.evIn(B, `(() => { const r = document.getElementById('av-frame').getBoundingClientRect(); return { x: r.left, y: r.top }; })()`); + await clickFor(v.x + f.x + 200, v.y + f.y + 200, `document.activeElement === document.getElementById('av-frame')`, 'the artifact has the keyboard'); + await soonV(`document.getElementById('av-hint').hidden`, 'gone once the artifact has it'); }); await t.test("framed, as the owner wrapper frames it, a published tree this checkout lacks isn't swapped for main: the gateway grants only that tree", async () => { - await go(`${B}/api/hello`); const gone = '0'.repeat(40); - await p.ev(`(() => { const f = document.createElement('iframe'); f.id = 'v'; f.style.cssText = 'width:900px;height:600px'; f.src = '/view/artifact/viewer-mock?at=tree:${gone}'; document.body.append(f); return true; })()`); - const inV = (e) => `(() => { const d = document.getElementById('v').contentDocument; return ${e}; })()`; - await soon(inV(`!d.getElementById('av-note').hidden && !!d.getElementById('av-frame').getAttribute('src')`), 'the framed viewer, its note'); - assert.deepEqual(await p.ev(inV(`[d.getElementById('av-note').textContent, d.getElementById('av-frame').getAttribute('src'), d.defaultView.eval('TS.at')]`)), ["The published version isn't in this checkout; open it from the Library.", `/artifact/tree/${gone}/viewer-mock/`, `tree:${gone}`]); + await go(`http://127.0.0.1:${hubPort}/frame?src=${encodeURIComponent(`/view/artifact/viewer-mock?at=tree:${gone}`)}`); + await soonV(`!document.getElementById('av-note').hidden && !!document.getElementById('av-frame').getAttribute('src')`, 'the framed viewer, its note'); + assert.deepEqual(await p.evIn(B, `[document.getElementById('av-note').textContent, document.getElementById('av-frame').getAttribute('src'), TS.at]`), ["The published version isn't in this checkout; open it from the Library.", `/artifact/tree/${gone}/viewer-mock/`, `tree:${gone}`]); }); await t.test("an artifact /api/view can't read says so, and shows the version asked for: only nothing there falls back to main", async () => { @@ -2233,6 +2351,426 @@ test('the Library\'s Open and a question\'s [[name]] open the artifact\'s viewer await soon(`!document.getElementById('artifact-view').hidden`, 'the stage'); assert.deepEqual(await link('#av-tab'), ['/view/artifact/viewer-mock', '_blank', 'noopener noreferrer', 'Open in new tab']); }); +test("the laptop's relay: the lens on GET alone, the app with localhost as its Host and Origin, its framing given way only to its lens", async (t) => { + const F = threadsFixture('relay-http'); + t.after(() => F.done()); + const app = await fakeApp(); + t.after(() => app.close()); + // an app that refuses every frame, and names its own socket's origin in its policy + app.headers = { 'x-frame-options': 'DENY', 'content-security-policy': `default-src 'self'; frame-ancestors 'none'; connect-src ws://localhost:${app.port}` }; + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'relay']))[1]; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const d = startDashboard(F.old, F.env({ DEVTEAM_PREVIEW_TICK_MS: '500', ...RELAY_ENV }), ['--port', String(port)]); + t.after(() => d.stop()); + await until(() => new RegExp(`dashboard on http://127\\.0\\.0\\.1:${port}\\b`).test(d.out()), 'the dashboard'); + // another site's page can't open one, and a GET of anything else here opens none + assert.equal((await fetch(`${B}/api/view?kind=preview&name=${name}`, { headers: { 'sec-fetch-site': 'cross-site' } })).status, 403, 'another site\'s page can\'t open one'); + assert.equal((await fetch(`${B}/api/view?kind=preview&name=quiet-otter`)).status, 404, 'no such preview'); + // asked several times at once: one ask opened it, and only that one says so + const asks = await Promise.all(Array.from({ length: 6 }, async () => (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json())); + const v = asks.find((a) => a.opened) ?? asks[0]; + assert.deepEqual(asks.map((a) => a.opened).sort(), [false, false, false, false, false, true], 'exactly one ask opened it'); + assert.ok(asks.every((a) => a.origin === v.origin), 'all of them the one relay'); + assert.deepEqual((({ origin, opened }) => ({ origin, opened }))(await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json()), { origin: v.origin, opened: false }, 'the next finds it open'); + const R = Number(/^http:\/\/127\.0\.0\.1:(\d+)$/.exec(v.origin)?.[1]); + assert.ok(R >= RELAY_LO && R <= RELAY_HI, `a relay in the range it was given: ${v.origin}`); + const rel = `http://127.0.0.1:${R}`; + // the lens, as the gateway serves it, under a policy for the hub and every instance here + let r = await fetch(`${rel}/.devteam/lens?route=%2F`); + assert.equal(r.status, 200); + const G = await import('../cloud/gateway.mjs'); + assert.equal(await r.text(), G.PREVIEW_LENS); + // the hub, every instance in the block, and this dashboard itself (pinned here outside the block), each once + const instances = Array.from({ length: 25 }, (_, i) => `http://127.0.0.1:${PORTS + 1 + i}`); + assert.equal(r.headers.get('content-security-policy'), G.previewLensCsp([...new Set([`http://127.0.0.1:${PORTS}`, ...instances, `http://127.0.0.1:${port}`, `http://localhost:${port}`])].join(' '))); + assert.deepEqual(app.seen.filter((x) => x.url !== '/api/hello'), [], 'the lens never asks the app (the relay\'s one look at /api/hello, to know it isn\'t a dashboard, aside)'); + // any other method or path there is the app's + for (const [method, p] of [['HEAD', '/.devteam/lens'], ['POST', '/.devteam/lens'], ['GET', '/.devteam/lens/x'], ['GET', '/.devteam/lensx']]) { + r = await fetch(`${rel}${p}`, { method }); + assert.equal(r.status, 200, `${method} ${p}`); + assert.deepEqual([app.seen.at(-1).method, app.seen.at(-1).url], [method, p]); + } + // the app sees localhost, as the gateway gives it: its Host, and an Origin that was the relay's + await fetch(`${rel}/page?x=1`, { method: 'POST', headers: { origin: rel }, body: 'x' }); + assert.deepEqual(app.seen.at(-1), { method: 'POST', url: '/page?x=1', host: `localhost:${app.port}`, origin: `http://localhost:${app.port}` }); + assert.equal(await statusWithHost(R, '/page', `localhost:${R}`, 'POST', { origin: `http://localhost:${R}` }), 200); + assert.deepEqual(app.seen.at(-1), { method: 'POST', url: '/page', host: `localhost:${app.port}`, origin: `http://localhost:${app.port}` }, 'the relay\'s other name for itself too'); + await fetch(`${rel}/page`, { method: 'POST', headers: { origin: 'http://evil.example' }, body: 'x' }); + assert.equal(app.seen.at(-1).origin, 'http://evil.example', 'another Origin passes as it is, for the app to refuse'); + await fetch(`${rel}/page`, { method: 'POST', headers: { origin: B }, body: 'x' }); + assert.equal(app.seen.at(-1).origin, B, 'the dashboard\'s own Origin is not the relay\'s'); + // its own origin in a policy or a redirect is the relay's + r = await fetch(`${rel}/go`, { redirect: 'manual' }); + assert.equal(r.headers.get('location'), `${rel}/there`); + // a top-level load: the app's own framing headers + r = await fetch(`${rel}/`, { headers: { 'sec-fetch-dest': 'document', 'sec-fetch-site': 'none' } }); + assert.equal(r.headers.get('x-frame-options'), 'DENY'); + assert.match(r.headers.get('content-security-policy'), new RegExp(`frame-ancestors 'none'; connect-src ws://127\\.0\\.0\\.1:${R}$`)); + // framed from its own origin, which is how its lens holds it: given way to the lens, the hub and this dashboard + const ancestors = `'self' http://127.0.0.1:${PORTS} http://127.0.0.1:${port} http://localhost:${port}`; + r = await fetch(`${rel}/`, { headers: { 'sec-fetch-dest': 'iframe', 'sec-fetch-site': 'same-origin' } }); + assert.equal(r.headers.get('x-frame-options'), null); + assert.equal(r.headers.get('content-security-policy'), `default-src 'self'; frame-ancestors ${ancestors}; connect-src ws://127.0.0.1:${R}`); + for (const h of [{ 'sec-fetch-dest': 'iframe', 'sec-fetch-site': 'same-site' }, { 'sec-fetch-dest': 'frame', 'sec-fetch-site': 'same-origin' }, { 'sec-fetch-dest': 'document', 'sec-fetch-site': 'same-origin' }, { 'sec-fetch-dest': 'document', 'sec-fetch-site': 'cross-site' }]) assert.equal((await fetch(`${rel}/`, { headers: h })).headers.get('x-frame-options'), 'DENY', JSON.stringify(h)); + // another site's frame (or one that doesn't say whose it is) is refused outright, the lens too, as the gateway refuses it + const before = app.seen.length; + for (const dest of ['iframe', 'frame', 'object', 'embed', 'fencedframe']) { + for (const site of ['cross-site', 'none', null]) { + for (const p of ['/', '/.devteam/lens']) { + r = await fetch(`${rel}${p}`, { headers: { 'sec-fetch-dest': dest, ...(site ? { 'sec-fetch-site': site } : {}) } }); + assert.equal(r.status, 403, `${dest} ${site} ${p}`); + assert.equal(await r.text(), 'not in another site\'s frame'); + } + } + } + assert.equal(app.seen.length, before, 'none of those reached the app'); + // an app that says nothing of frames gets that policy as its own, on that load only + app.headers = {}; + r = await fetch(`${rel}/`, { headers: { 'sec-fetch-dest': 'iframe', 'sec-fetch-site': 'same-origin' } }); + assert.equal(r.headers.get('content-security-policy'), `frame-ancestors ${ancestors}`); + assert.equal((await fetch(`${rel}/`)).headers.get('content-security-policy'), null); + // only on its own address (DNS rebinding), and no service worker, which would outlive this preview on its port + const seen = app.seen.length; + assert.equal(await statusWithHost(R, '/', `rebound.example:${R}`), 421); + assert.equal(await statusWithHost(R, '/.devteam/lens', `rebound.example:${R}`), 421); + assert.equal(await statusWithHost(R, '/sw.js', `127.0.0.1:${R}`, 'GET', { 'service-worker': 'script' }), 403); + assert.equal(await statusWithHost(R, '/sw.js', `127.0.0.1:${R}`, 'GET', { 'sec-fetch-dest': 'serviceworker' }), 403); + assert.equal(app.seen.length, seen, 'none of those reached the app'); + // a dev server can't take a relay's port: 4800-4899 unless told otherwise, and then the range it's given + assert.match(F.fails(['preview', '4801'], F.SUP), /4801 is where the dashboard's preview relays listen \(4800-4899\)/); + assert.match(F.fails(['preview', String(RELAY_LO + 1)], { ...F.SUP, ...RELAY_ENV }), new RegExp(`${RELAY_LO + 1} is where the dashboard's preview relays listen \\(${RELAY_LO}-${RELAY_HI}\\)`)); + const moved = /preview (\S+) /.exec(F.sup(['preview', '4801', '--label', 'moved'], RELAY_ENV))[1]; + F.sup(['preview', 'stop', moved]); + // a record from before the range stays a preview: only a new registration is refused + const old = path.join(F.old, 'docs/devteam/run/previews', 'quiet-otter.json'); + write(old, JSON.stringify({ v: 1, name: 'quiet-otter', project: 'relay-http', session: 'supervisor', label: 'old', port: 4802, cwd: F.old, at: new Date().toISOString(), expires: new Date(Date.now() + 86400000).toISOString() })); + assert.ok(JSON.parse(F.sup(['preview', 'list', '--json'])).some((x) => x.name === 'quiet-otter' && x.port === 4802), 'still listed'); + fs.rmSync(old); + // the record gone, the relay closes + F.sup(['preview', 'stop', name]); + await until(async () => { try { await fetch(`${rel}/`); return false; } catch { return true; } }, 'the relay closed with its record'); +}); +test("the laptop's relay: a port anything holds is passed over, none free keeps nothing, and a record moved closes its relay", async (t) => { + const F = threadsFixture('relay-ports'); + t.after(() => F.done()); + const app = await fakeApp(); + t.after(() => app.close()); + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'ports']))[1]; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const d = startDashboard(F.old, F.env({ DEVTEAM_PREVIEW_TICK_MS: '500', ...RELAY_ENV }), ['--port', String(port)]); + t.after(() => d.stop()); + await until(() => new RegExp(`dashboard on http://127\\.0\\.0\\.1:${port}\\b`).test(d.out()), 'the dashboard'); + const view = async () => (({ origin, opened }) => ({ origin, opened }))(await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json()); + // every port of the range held (those free now, by this test) + const hold = (p, h) => new Promise((resolve) => { const s = net.createServer(); s.once('error', () => resolve(null)); s.listen(p, h, () => resolve(s)); }); + const held = new Map(); + t.after(() => { for (const s of held.values()) s.close(); }); + for (let p = RELAY_LO; p <= RELAY_HI; p++) { const s = await hold(p, '127.0.0.1'); if (s) held.set(p, s); } + assert.ok(held.size >= 3, `three ports of ${RELAY_LO}-${RELAY_HI} free to test with: ${held.size}`); + assert.deepEqual(await view(), { origin: null, opened: false }, 'none free'); + assert.deepEqual(await view(), { origin: null, opened: false }, 'and asked again, it looks again'); + // three let go: the first now a dev server's on ::1 alone (localhost), the second an old record's + const [a, b, c] = [...held.keys()]; + for (const p of [a, b, c]) { await new Promise((r) => held.get(p).close(r)); held.delete(p); } + const six = await hold(a, '::1'); + if (six) held.set(-1, six); + const rec = path.join(F.old, 'docs/devteam/run/previews', 'quiet-otter.json'); + write(rec, JSON.stringify({ v: 1, name: 'quiet-otter', project: 'relay-ports', session: 'supervisor', label: 'old', port: b, cwd: F.old, at: new Date().toISOString(), expires: new Date(Date.now() + 86400000).toISOString() })); + const v = await view(); + assert.deepEqual(v, { origin: `http://127.0.0.1:${six ? c : a}`, opened: true }, 'the first port nothing holds'); + assert.equal((await fetch(`${v.origin}/x`)).status, 200); + assert.equal(app.seen.at(-1).url, '/x'); + // its record now names another port: the relay closes on the tick after, and the next ask opens one for it + const app2 = await fakeApp(); + t.after(() => app2.close()); + const file = path.join(F.old, 'docs/devteam/run/previews', `${name}.json`); + write(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), port: app2.port })); + await until(async () => { try { await fetch(`${v.origin}/`); return false; } catch { return true; } }, 'the relay closed with its record\'s port'); + const v2 = await view(); + assert.equal(v2.opened, true); + await fetch(`${v2.origin}/moved`); + assert.equal(app2.seen.at(-1).url, '/moved'); +}); +test("the laptop's relay never serves a dashboard: not this one's own port, not any port that answers as one, and none registers", async (t) => { + const F = threadsFixture('relay-dash'); + t.after(() => F.done()); + // a page that answers a dashboard's hello, as another checkout's would + // (a process of its own: the CLI calls below block this one's event loop) + const child = spawn(process.execPath, ['-e', `const s = require('http').createServer((q, r) => { if (q.url !== '/api/hello') process.stderr.write('x'); r.end('{"devteam":1,"viewer":1}'); }).listen(0, '127.0.0.1', () => console.log(s.address().port));`], { stdio: ['ignore', 'pipe', 'pipe'] }); + t.after(() => child.kill()); + let stray = 0; + child.stderr.on('data', (c) => { stray += c.length; }); + const other = { port: Number(await new Promise((resolve) => child.stdout.once('data', resolve))) }; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const d = startDashboard(F.old, F.env({ DEVTEAM_PREVIEW_TICK_MS: '500', ...RELAY_ENV }), ['--port', String(port)]); + t.after(() => d.stop()); + await until(() => new RegExp(`dashboard on http://127\\.0\\.0\\.1:${port}\\b`).test(d.out()), 'the dashboard'); + // a new registration is refused for either + assert.match(F.fails(['preview', String(port)], F.SUP), /devteam dashboard|own dashboard/); + assert.match(F.fails(['preview', String(other.port)], F.SUP), /is a devteam dashboard/); + assert.match(F.fails(['preview', '--run', 'true', '--port', String(other.port)], F.SUP), /is a devteam dashboard/); + // records made another way (by hand, or before the rule) are listed, and never relayed + for (const [n, pt] of [['quiet-otter', port], ['calm-heron', other.port]]) { + write(path.join(F.old, 'docs/devteam/run/previews', `${n}.json`), JSON.stringify({ v: 1, name: n, project: 'relay-dash', session: 'supervisor', label: n, port: pt, cwd: F.old, at: new Date().toISOString(), expires: new Date(Date.now() + 86400000).toISOString() })); + const r = await (await fetch(`${B}/api/view?kind=preview&name=${n}`)).json(); + assert.deepEqual([r.origin, r.opened], [null, false], `${n} is not relayed`); + } + assert.equal(stray, 0, 'nothing but the hello reached it'); +}); +test("what the relay forwards carries a mark every owner route refuses, and a target that turns into a dashboard closes its relay", async (t) => { + const F = threadsFixture('relay-mark'); + t.after(() => F.done()); + const pages = { '/': 'App' }; + const app = await fakeApp({}, pages); + t.after(() => app.close()); + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'mark']))[1]; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const d = startDashboard(F.old, F.env({ DEVTEAM_PREVIEW_TICK_MS: '500', ...RELAY_ENV }), ['--port', String(port)]); + t.after(() => d.stop()); + await until(() => new RegExp(`dashboard on http://127\\.0\\.0\\.1:${port}\\b`).test(d.out()), 'the dashboard'); + const v = await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json(); + const rel = v.origin; + // a normal app sees all its traffic, each request marked, whatever the page sent + const n = app.marks.length; + await fetch(`${rel}/a`, { headers: { 'x-devteam-relay': '0' } }); + await fetch(`${rel}/b`, { method: 'POST', body: 'x', headers: { origin: rel } }); + assert.deepEqual(app.marks.slice(n), ['1', '1'], 'set fresh, a page\'s own copy replaced'); + assert.equal(app.seen.at(-1).url, '/b'); + // the dashboard refuses a marked request on every route, with localhost as Host and Origin as the relay sends them + const as = (route, method = 'GET') => fetch(`${B}${route}`, { method, headers: { host: `localhost:${port}`, origin: `http://localhost:${port}`, 'x-devteam-relay': '1', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, ...(method === 'POST' ? { body: '{}' } : {}) }); + for (const [route, method] of [['/api/action', 'POST'], ['/api/answer', 'POST'], ['/api/threads', 'GET'], ['/api/data', 'GET'], ['/api/hello', 'GET'], ['/api/view?kind=preview&name=' + name, 'GET'], ['/', 'GET'], ['/view/preview/' + name, 'GET']]) { + const r = await statusWithHost(port, route, `localhost:${port}`, method, { origin: `http://localhost:${port}`, 'x-devteam-relay': '1', 'sec-fetch-site': 'same-origin' }); + assert.equal(r, 403, `${method} ${route}`); + } + assert.equal(await statusWithHost(port, '/api/hello', `localhost:${port}`), 200, 'unmarked, the same host is served'); + // the target now answers as a dashboard: the tick closes the relay + pages['/api/hello'] = '{"devteam":1,"viewer":1}'; + await until(async () => { try { await fetch(`${rel}/x`); return false; } catch { return true; } }, 'the relay closed once its target answered as a dashboard'); + assert.ok(!d.out().includes('Error'), d.out()); +}); +test("a preview can't be registered on a port settings.md pins for this checkout's dashboard", async (t) => { + const F = threadsFixture('pinned-dash'); + t.after(() => F.done()); + const pin = await freePort(); + const sf = path.join(F.old, 'docs/devteam/project/settings.md'); + fs.writeFileSync(sf, fs.readFileSync(sf, 'utf8').replace('"gates": {}', `"gates": {}, "dashboard_port": ${pin}`)); + assert.match(F.fails(['preview', String(pin)], F.SUP), /own dashboard/); +}); +test("the laptop's relay carries a preview's WebSockets: from its own Origin only, with localhost as the app's Host and Origin", async (t) => { + const F = threadsFixture('relay-ws'); + t.after(() => F.done()); + const app = await fakeApp(); + t.after(() => app.close()); + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'ws']))[1]; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const d = startDashboard(F.old, F.env({ DEVTEAM_PREVIEW_TICK_MS: '500', ...RELAY_ENV }), ['--port', String(port)]); + t.after(() => d.stop()); + await until(() => new RegExp(`dashboard on http://127\\.0\\.0\\.1:${port}\\b`).test(d.out()), 'the dashboard'); + const v = await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json(); + const R = Number(/:(\d+)$/.exec(v.origin)[1]); + const ask = (origin, host = `127.0.0.1:${R}`, url = '/hmr?token=1') => ['GET ' + url + ' HTTP/1.1', `Host: ${host}`, 'Connection: Upgrade', 'Upgrade: websocket', 'Sec-WebSocket-Version: 13', 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', ...(origin ? [`Origin: ${origin}`] : [])]; + const upgrade = (origin, host) => rawRequest(R, ask(origin, host)); + const got = await upgrade(v.origin); + assert.match(got, /^HTTP\/1\.1 101 /); + assert.match(got, /hello from the app/, 'the app\'s bytes after the handshake'); + assert.deepEqual(app.seen.at(-1), { method: 'UPGRADE', url: '/hmr?token=1', host: `localhost:${app.port}`, origin: `http://localhost:${app.port}` }); + // the relay's other name for itself, from a page on that name + assert.match(await upgrade(`http://localhost:${R}`, `localhost:${R}`), /^HTTP\/1\.1 101 /); + assert.deepEqual(app.seen.at(-1), { method: 'UPGRADE', url: '/hmr?token=1', host: `localhost:${app.port}`, origin: `http://localhost:${app.port}` }); + const n = app.seen.length; + assert.match(await upgrade('http://evil.example'), /^HTTP\/1\.1 403 /, 'another page\'s socket'); + assert.match(await upgrade(B), /^HTTP\/1\.1 403 /, 'the dashboard\'s page'); + assert.match(await upgrade(null), /^HTTP\/1\.1 403 /, 'no Origin'); + assert.match(await upgrade(`http://localhost:${R}`), /^HTTP\/1\.1 403 /, 'a page on its other name, to this one'); + assert.match(await rawRequest(R, ['GET /hmr HTTP/1.1', `Host: rebound.example:${R}`, 'Connection: Upgrade', 'Upgrade: websocket', `Origin: ${v.origin}`]), /^HTTP\/1\.1 421 /, 'DNS rebinding'); + assert.match(await rawRequest(R, ask(v.origin, `127.0.0.1:${R}`, 'http://elsewhere.example/hmr')), /^HTTP\/1\.1 400 /, 'origin-form only'); + assert.equal(app.seen.length, n, 'none of those reached the app'); + // both ways, after the handshake: the handshakes above let go first, so the app's count is this one's + await until(() => app.sockets.size === 0, 'the handshakes above let go'); + const one = rawSocket(R, ask(v.origin)); + await until(() => /hello from the app\n/.test(one.got), 'the handshake'); + await until(() => app.sockets.size === 1, 'the app holds it'); + one.c.write('ping from the page\n'); + await until(() => /ping from the page\n/.test(one.got), 'the app\'s echo'); + // the app ends it: so does the relay, and the dashboard carries on + for (const s of app.sockets) s.destroy(); + await until(() => one.closed, 'the page\'s side closed with the app\'s'); + // the page ends it: so does the relay, to the app + const two = rawSocket(R, ask(v.origin)); + await until(() => app.sockets.size === 1, 'the app holds the next'); + two.c.destroy(); + await until(() => app.sockets.size === 0, 'the app\'s side closed with the page\'s'); + // 32 at once, and no more; one let go makes room (none of the above still holds a slot) + assert.equal(app.sockets.size, 0); + const held = []; + t.after(() => { for (const s of held) s.c.destroy(); }); + for (let i = 0; i < 32; i++) held.push(rawSocket(R, ask(v.origin))); + await until(() => held.every((s) => /hello from the app/.test(s.got)), 'thirty-two sockets'); + const n2 = app.seen.length; + assert.match(await upgrade(v.origin), /^HTTP\/1\.1 429 /, 'a thirty-third'); + assert.equal(app.seen.length, n2, 'which never reached the app'); + held.shift().c.destroy(); + await until(async () => /^HTTP\/1\.1 101 /.test(await upgrade(v.origin)), 'room once one closed'); + // the app gone: the socket is closed, unanswered, and the dashboard still answers + const app2 = await fakeApp(); + const gone = app2.port; + app2.close(); + const file = path.join(F.old, 'docs/devteam/run/previews', `${name}.json`); + const rec = JSON.parse(fs.readFileSync(file, 'utf8')); + // the relay closes with its record: every socket it carries goes with it, to the page and to the app + F.sup(['preview', 'stop', name]); + await until(() => held.every((s) => s.closed), 'the pages\' sides closed with the relay'); + await until(() => app.sockets.size === 0, 'the app\'s sides closed with the relay'); + write(file, JSON.stringify({ ...rec, port: gone })); + const v2 = await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json(); + assert.equal(v2.opened, true); + const R2 = Number(/:(\d+)$/.exec(v2.origin)[1]); + const lost = rawSocket(R2, ask(v2.origin, `127.0.0.1:${R2}`)); + await until(() => lost.closed, 'closed, with nothing to carry it to'); + assert.equal(lost.got, '', 'nothing to answer it'); + assert.equal((await fetch(`${B}/api/view?kind=preview&name=${name}`)).status, 200, 'the dashboard carries on'); +}); +test("the laptop's relay ends a socket the app won't upgrade once it has answered, and one it refuses even if the page holds on", async (t) => { + const F = threadsFixture('relay-ws-end'); + t.after(() => F.done()); + const app = await fakeApp(); + t.after(() => app.close()); + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'ws']))[1]; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const d = startDashboard(F.old, F.env({ DEVTEAM_PREVIEW_TICK_MS: '500', ...RELAY_ENV }), ['--port', String(port)]); + t.after(() => d.stop()); + await until(() => new RegExp(`dashboard on http://127\\.0\\.0\\.1:${port}\\b`).test(d.out()), 'the dashboard'); + const v = await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json(); + const R = Number(/:(\d+)$/.exec(v.origin)[1]); + const ask = (url, origin = v.origin) => ['GET ' + url + ' HTTP/1.1', `Host: 127.0.0.1:${R}`, 'Connection: Upgrade', 'Upgrade: websocket', 'Sec-WebSocket-Version: 13', 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', `Origin: ${origin}`]; + // A page that never ends its own side sees the relay's close only when it next writes: so it + // writes, every 100 ms, until that close comes back. Without it the relay would read on for good. + const pester = (S) => () => { if (!S.closed) S.c.write('more from the page\n'); return S.closed; }; + // a 400 to the handshake: the page gets that answer, its body and no more, and both sides end, + // though the page holds its own open and the app would keep going + const no = rawSocket(R, ask('/no-ws'), { halfOpen: true }); + await until(() => no.got.includes('no, ws\n'), 'the answer'); + await until(pester(no), 'the page\'s side closed after the answer'); + assert.match(no.got, /^HTTP\/1\.1 400 Bad Request\r\n/); + assert.match(no.got, /\r\nconnection: close\r\n/i); + assert.doesNotMatch(no.got, /keep-alive/i); + assert.ok(no.got.endsWith('\r\n\r\nno, ws\n'), JSON.stringify(no.got)); + await until(() => app.noWsEnded, 'the relay\'s side to the app ended too'); + assert.equal(app.after, undefined, 'nothing of the page\'s reached the app after the answer'); + // a refused socket is closed, not just ended, so a page that never ends its side holds nothing here + const n = app.seen.length; + const refused = rawSocket(R, ask('/hmr', 'http://evil.example'), { halfOpen: true }); + await until(() => /^HTTP\/1\.1 403 /.test(refused.got), 'the refusal'); + await until(pester(refused), 'the refused socket closed though the page held on'); + assert.match(refused.got, /^HTTP\/1\.1 403 /); + assert.equal(app.seen.length, n, 'which never reached the app'); +}); +test("the laptop's relay gives up on an app that takes a WebSocket and sends no answer, destroying both sides", async (t) => { + const F = threadsFixture('relay-ws-head'); + t.after(() => F.done()); + const app = await fakeApp(); + t.after(() => app.close()); + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'ws']))[1]; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const d = startDashboard(F.old, F.env({ DEVTEAM_PREVIEW_TICK_MS: '500', DEVTEAM_RELAY_HEAD_MS: '600', ...RELAY_ENV }), ['--port', String(port)]); + t.after(() => d.stop()); + await until(() => new RegExp(`dashboard on http://127\\.0\\.0\\.1:${port}\\b`).test(d.out()), 'the dashboard'); + const v = await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json(); + const R = Number(/:(\d+)$/.exec(v.origin)[1]); + const S = rawSocket(R, ['GET /silent HTTP/1.1', `Host: 127.0.0.1:${R}`, 'Connection: Upgrade', 'Upgrade: websocket', 'Sec-WebSocket-Version: 13', 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', `Origin: ${v.origin}`], { halfOpen: true }); + await until(() => app.sockets.size === 1, 'the app to hold the relay\'s side'); + const t0 = Date.now(); + // the page never ends its own side, so the relay's close shows only when it next writes + await until(() => { if (!S.closed) S.c.write('more from the page\n'); return S.closed; }, 'the page\'s side closed with no head'); + assert.ok(Date.now() - t0 < 5000, 'within moments of the limit, not the page\'s own patience'); + await until(() => app.sockets.size === 0, 'the app\'s side closed too'); +}); +// A preview's viewer: on this laptop the relay's origin, once the page's own ask has opened it (a GET +// of the viewer never does); on a box its own name through the gateway, or nothing when the gateway +// doesn't serve previews. The dashboard's page names exactly those, never a wildcard. +test("a preview's viewer: its route, frame-src naming exactly where its lens is, and the dashboard's own frames", async (t) => { + const F = threadsFixture('preview-view'); + t.after(() => F.done()); + const app = await fakeApp(); + t.after(() => app.close()); + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'Checkout']))[1]; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + let d = null; + t.after(() => d?.stop()); + const start = async (env) => { + d = startDashboard(F.old, F.env(env), ['--port', String(port)]); + await until(() => new RegExp(`dashboard on http://127\\.0\\.0\\.1:${port}\\b`).test(d.out()), 'the dashboard'); + }; + await start(RELAY_ENV); + const csp = async (p) => (await fetch(`${B}${p}`)).headers.get('content-security-policy'); + const tail = `frame-ancestors 'self' http://127.0.0.1:${PORTS}`; + const viewer = `/view/preview/${name}`; + // before any relay: a GET of the viewer opens none, and every page frames only itself, as before + let r = await fetch(`${B}${viewer}`); + assert.equal(r.status, 200); + assert.equal(await r.text(), fs.readFileSync(path.join(kit, 'dashboard/index.html'), 'utf8')); + assert.equal(r.headers.get('content-security-policy'), `frame-src 'self'; ${tail}`, 'a navigation opens no relay'); + assert.deepEqual([r.headers.get('x-frame-options'), r.headers.get('referrer-policy'), r.headers.get('cache-control')], [null, 'no-referrer', 'no-store']); + assert.equal(await csp('/'), `frame-src 'self'; ${tail}`); + assert.equal((await fetch(`${B}${viewer}`)).headers.get('content-security-policy'), `frame-src 'self'; ${tail}`, 'nor does a second'); + // the page's own ask opens it, and says so: a page loaded before it reloads (the viewer) or says to (the stage). + // up is the tick's word, as hello has it: the app answers, so once the first tick is done, true + await until(async () => (await (await fetch(`${B}/api/hello`)).json()).previews?.find((x) => x.name === name)?.up === true, 'the tick to find the app up'); + const v = await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json(); + const relay = /^http:\/\/127\.0\.0\.1:(\d+)$/.exec(v.origin ?? ''); + assert.ok(relay && Number(relay[1]) >= RELAY_LO && Number(relay[1]) <= RELAY_HI, v.origin); + assert.deepEqual(v, { name, label: 'Checkout', session: v.session, project: v.project, up: true, origin: relay[0], opened: true, lens: `${relay[0]}/.devteam/lens`, served: false }); + assert.equal(typeof v.session, 'string'); + assert.equal(typeof v.project, 'string'); + assert.equal((await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json()).opened, false, 'the next ask finds it open'); + // from then on the viewer and the dashboard name that relay, and nothing else; an artifact's viewer frames only its own + assert.equal(await csp(viewer), `frame-src 'self' ${relay[0]}; ${tail}`); + assert.equal(await csp('/'), `frame-src 'self' ${relay[0]}; ${tail}`); + assert.equal(await csp('/index.html'), `frame-src 'self' ${relay[0]}; ${tail}`); + assert.equal(await csp('/view/artifact/some-mock'), `frame-src 'self'; ${tail}`); + // only a preview here, by its own name; what was asked is never said back + for (const bad of ['/view/preview/no-such', '/view/preview/Not_A_Name', `${viewer}/x`, '/view/preview/', `/view/preview/${name}%3Cb%3E`, `/view/preview/%3Cscript%3E`]) { + const b = await fetch(`${B}${bad}`); + assert.deepEqual([b.status, await b.text()], [404, 'not found'], bad); + } + assert.equal((await fetch(`${B}${viewer}`, { method: 'POST' })).status, 404, 'a GET only: a POST is no route here'); + // a relay whose record moved to another port (or went) is framed no more, at once: not on the tick + // that closes it (every 10 s here), on the next page asked for + const file = path.join(F.old, 'docs/devteam/run/previews', `${name}.json`); + const was = fs.readFileSync(file, 'utf8'); + write(file, JSON.stringify({ ...JSON.parse(was), port: app.port === 65535 ? app.port - 1 : app.port + 1 })); + assert.equal(await csp('/'), `frame-src 'self'; ${tail}`, 'its record names another port'); + fs.rmSync(file); + assert.equal(await csp('/'), `frame-src 'self'; ${tail}`, 'its record gone'); + write(file, was); + assert.equal((await fetch(`${B}/api/view?kind=preview&name=no-such`)).status, 404); + await d.stop(); + // on a box whose gateway serves previews: the preview's own name, which the gateway leaves as it is + await start({ DEVTEAM_PUBLIC_URL: 'https://box.example.com', DEVTEAM_PREVIEWS: '1', ...RELAY_ENV }); + const own = `https://${name}.box.example.com`; + assert.equal(await csp(viewer), `frame-src 'self' ${own}; ${tail}`); + assert.equal(await csp('/'), `frame-src 'self' ${own}; ${tail}`, 'each preview\'s own name, never a wildcard'); + const other = /preview (\S+) /.exec(F.sup(['preview', String(app.port + 1 > 65535 ? app.port - 1 : app.port + 1), '--label', 'Other']))[1]; + assert.equal(await csp('/'), `frame-src 'self' ${[name, other].sort().map((n) => `https://${n}.box.example.com`).join(' ')}; ${tail}`, 'every preview on the box, read per request'); + assert.equal(await csp(viewer), `frame-src 'self' ${own}; ${tail}`, 'a viewer frames its own preview only'); + assert.deepEqual((({ origin, lens, served, opened }) => [origin, lens, served, opened])(await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json()), [own, `${own}/.devteam/lens`, true, false]); + F.sup(['preview', 'stop', other]); + assert.equal(await csp('/'), `frame-src 'self' ${own}; ${tail}`, 'a preview stopped is framed no more'); + await d.stop(); + // a box whose gateway doesn't: nothing to frame, and the viewer says so; the page's ask opens no relay + await start({ DEVTEAM_PUBLIC_URL: 'https://box.example.com', ...RELAY_ENV }); + assert.equal(await csp(viewer), `frame-src 'self'; ${tail}`); + assert.deepEqual((({ origin, lens, served, opened }) => [origin, lens, served, opened])(await (await fetch(`${B}/api/view?kind=preview&name=${name}`)).json()), [null, null, false, false]); + assert.equal(await csp('/'), `frame-src 'self'; ${tail}`); +}); // The thread layer in Chrome, on a project of its own: aiming (this task), the card (Task 11), the // pins and the dock (Task 12), questions and resolving (Task 13). One page; the subtests run in order. test('threads on the dashboard page: Shift and point, the card, the pins and the dock', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 300000 }, async (t) => { @@ -2775,6 +3313,382 @@ test("a goal at its target: the card's Mark met is an owner action, same-origin assert.match(html, /g\.status === 'ready' && g\.at_target \? `
`; +test('Shift over a preview, through the relay: what the app has under the pointer, a thread on it, its pin, and go', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 300000 }, async (t) => { + const undo = []; + t.after(async () => { let err = null; for (const f of undo.reverse()) { try { await f(); } catch (e) { err ??= e; } } if (err) throw err; }); + const F = threadsFixture('preview-stage'); + undo.push(() => F.done()); + // an app that refuses every frame, as many do: only its lens's own loads may hold it + const app = await fakeApp({ 'x-frame-options': 'DENY', 'content-security-policy': "frame-ancestors 'none'" }, { '/': APP_PAGE, '/second': APP_PAGE }); + undo.push(() => app.close()); + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'Checkout']))[1]; + const port = await freePort(); + const B = `http://127.0.0.1:${port}`; + const dash = startDashboard(F.old, F.env(RELAY_ENV), ['--port', String(port)]); + undo.push(() => dash.stop()); + const chrome = await openChrome(); + undo.push(() => chrome.close()); + const p = await chrome.newPage(); + undo.push(() => p.close()); + await until(() => /dashboard on http/.test(dash.out()), 'the dashboard'); + await p.send('Page.enable'); + await p.send('Runtime.enable'); + await p.send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false }); + await p.send('Page.addScriptToEvaluateOnNewDocument', { source: POLL_FAST }); + await p.send('Page.addScriptToEvaluateOnNewDocument', { source: OWN_MESSAGES }); + // how often the tab's own page has loaded (a viewer reloads once for a relay its own ask opened) + await p.send('Page.addScriptToEvaluateOnNewDocument', { source: "if (window === top) try { sessionStorage.__loads = String(Number(sessionStorage.__loads || 0) + 1); } catch {}" }); + const soon = soonIn(p); + const frameAt = async (x, y) => { const r = await p.ev(`(() => { const r = document.getElementById('pv-frame').getBoundingClientRect(); return { x: r.left, y: r.top }; })()`); return { x: r.x + x, y: r.y + y }; }; + const keysHere = () => p.ev(`(() => { document.activeElement?.blur?.(); window.focus(); return document.activeElement === document.body; })()`); + const threads = () => JSON.parse(F.owner(['thread', 'list', '--all', '--json'])); + // a word to the app inside the lens, and its answer once it has done it + let said = 0; + const tell = async (m) => { const n = ++said; await p.ev(`document.getElementById('pv-frame').contentWindow.frames[0].postMessage(${JSON.stringify({ ...m, n })}, '*'), true`); return soon(`window.__own.find((x) => x.own === ${JSON.stringify(`${m.own}-done`)} && x.n === ${n})`, m.own); }; + const patient = (ms) => p.ev(`TS.waitMs = ${ms}`); + const heard = () => p.ev(`window.__own.filter((m) => m.own === 'heard').map((m) => m.data)`); + const aFrame = () => p.ev('new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))'); + const frameMs = () => p.ev('new Promise((r) => { const t = performance.now(); requestAnimationFrame(() => r(performance.now() - t)); })'); + + await t.test('the first look opens the relay, which the page loaded without: the stage says to reload', async () => { + await p.send('Page.navigate', { url: `${B}/#preview/${name}` }); + await soon(`document.getElementById('av-note').textContent === 'Reload to view this preview' && !document.getElementById('av-note').hidden`, 'the note'); + assert.equal(await p.ev('TS.ready'), false, 'the lens is outside this page\'s policy'); + // only the lens's own refusal says to reload: another frame's is not this note's to say + await p.ev(`(() => { const n = document.getElementById('av-note'); n.hidden = true; n.textContent = ''; document.dispatchEvent(new SecurityPolicyViolationEvent('securitypolicyviolation', { violatedDirective: 'frame-src', blockedURI: 'http://127.0.0.1:1/other', originalPolicy: '' })); return true; })()`); + assert.equal(await p.ev(`document.getElementById('av-note').hidden`), true, 'a refusal of another address says nothing'); + // the lens's address with a longer port after it is another address, not the lens + await p.ev(`(() => { document.dispatchEvent(new SecurityPolicyViolationEvent('securitypolicyviolation', { violatedDirective: 'frame-src', blockedURI: new URL(document.getElementById('pv-frame').src).origin + '9/.devteam/lens', originalPolicy: '' })); return true; })()`); + assert.equal(await p.ev(`document.getElementById('av-note').hidden`), true, 'a refusal of a longer port says nothing'); + await p.ev(`(() => { document.dispatchEvent(new SecurityPolicyViolationEvent('securitypolicyviolation', { violatedDirective: 'frame-src', blockedURI: new URL(document.getElementById('pv-frame').src).origin + '/.devteam/lens', originalPolicy: '' })); return true; })()`); + assert.equal(await p.ev(`document.getElementById('av-note').textContent`), 'Reload to view this preview', 'the lens\'s own does'); + assert.equal(await p.ev(`document.getElementById('av-title').textContent`), 'Checkout'); + }); + + await t.test('the stage here frames the lens on the relay, sandboxed with no top navigation, and the lens frames the app', async () => { + await p.send('Page.reload'); + await soon(`TS.kind === 'preview' && TS.ready && TS.route === '/'`, 'the lens to say ready'); + assert.equal(await p.ev(`document.getElementById('av-note').hidden`), true, 'no note once the page names the relay'); + const f = await p.ev(`(() => { const f = document.getElementById('pv-frame'); return [f.hidden, f.getAttribute('sandbox'), new URL(f.src).pathname, document.getElementById('av-frame').hidden, document.getElementById('av-frame').hasAttribute('src')]; })()`); + assert.deepEqual(f, [false, 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-pointer-lock allow-downloads', '/.devteam/lens', true, false]); + assert.doesNotMatch(f[1], /top-navigation/); + const relay = /^http:\/\/127\.0\.0\.1:(\d+)$/.exec(await p.ev('TS.origin')); + assert.ok(relay && Number(relay[1]) >= RELAY_LO && Number(relay[1]) <= RELAY_HI, 'the relay, in its range'); + assert.equal(await p.ev('TS.target'), relay[0], 'what goes to the lens goes to its origin only'); + assert.deepEqual(await p.ev(`[document.getElementById('av-title').textContent, document.getElementById('av-tab').getAttribute('href')]`), ['Checkout', `/view/preview/${name}`]); + assert.match(await p.ev(`document.getElementById('av-where').textContent`), /^from \S+ · \/( · (live|not answering))?$/); + }); + + await t.test("the app's own window can say what it likes: it is not the stage's frame, so it is taken for nothing", async () => { + await soon(`window.__own.some((m) => m.own === 'app-said' && m.at === '/')`, 'the app has spoken'); + assert.equal(await p.ev('TS.route'), '/', 'its route is its lens\'s word, not its own'); + assert.equal(await p.ev(`TS.errors.includes('forged by the app')`), false); + }); + + await t.test("a Shift+click on the app's button starts a thread on it: its name, route, viewport, errors and anchor", async () => { + await patient(2000); + await soon(`TS.errors.length === 1`, "the app's error, from its lens"); + const at = await frameAt(30, 15); + await p.hold('Shift', true); + await soon(`!document.getElementById('av-over').hidden`, 'the overlay'); + await p.move(at.x, at.y, 8); + await soon(`document.getElementById('tl-chip').textContent === 'button "Buy now"'`, 'the chip'); + await soon(`TS.hit?.anchor?.selector === '#buy'`, "the lens's answer for the hover"); + await p.clickAt(at.x, at.y, 8); + await p.hold('Shift', false); + await soon(`TL.draft?.subject?.kind === 'preview'`, 'a thread about to start'); + const s = await p.ev('TL.draft.subject'); + assert.deepEqual([s.name, s.route, s.anchor.selector, s.anchor.text, typeof s.viewport.w, typeof s.viewport.h, s.point], [name, '/', '#buy', 'Buy now', 'number', 'number', { x: 30, y: 15 }]); + // the app's own words, as its lens passed them on: the capability cut, the bidi override gone + assert.deepEqual(s.errors, ['Failed at [link removed] on purpose']); + await p.type('Make it green'); + await p.key('Enter'); + const th = await until(() => threads().find((x) => x.subject?.kind === 'preview'), 'the thread'); + assert.deepEqual([th.title, th.subject.name, th.subject.route, th.subject.anchor.selector, th.subject.errors], ['button "Buy now"', name, '/', '#buy', ['Failed at [link removed] on purpose']]); + await soon(`!!document.querySelector('#av-pins .tl-pin')`, 'its pin'); + await p.ev('tlClose()'); + await patient(150); + await keysHere(); + }); + + await t.test('with the keyboard in the app, its lens tells Shift, and that raises the overlay', async () => { + const blank = await frameAt(600, 500); + await p.clickAt(blank.x, blank.y); + await soon(`document.activeElement === document.getElementById('pv-frame')`, 'the frame has the keyboard'); + await p.hold('Shift', true); + await soon(`!document.getElementById('av-over').hidden && TS.raised`, 'raised by what the lens said'); + await p.hold('Shift', false); + await p.move(blank.x + 5, blank.y + 5); + await soon(`document.getElementById('av-over').hidden && !TS.raised`, 'lowered by the pointer, Shift up'); + await keysHere(); + }); + + await t.test('another route: the pin goes; the thread from the dock goes back to its route, by go, and go goes nowhere else', async () => { + const T = threads().find((x) => x.subject?.kind === 'preview').id; + await p.ev(`tsPost({ type: 'go', route: '/second' })`); + await soon(`TS.route === '/second' && !document.querySelector('#av-pins .tl-pin')`, 'no pin on another route'); + // a route from the log is the log's: only one of the app's own paths is ever asked for + const bad = ['//evil.example/x', 'https://evil.example/', '/\\evil.example', '/\t/evil.example', 'javascript:alert(1)', '/.devteam/lens', 'second', '']; + for (const r of bad) await p.ev(`tlGoTo(${JSON.stringify({ kind: 'preview', name, route: r })}), true`); + await p.ev(`tlOpen('${T}')`); + await soon(`TS.route === '/' && !!document.querySelector('#av-pins .tl-pin')`, 'back on its route, its pin with it'); + const gone = (await heard()).filter((m) => m.type === 'go').map((m) => m.route); + assert.deepEqual([...new Set(gone)].sort(), ['/', '/second'], 'only the app\'s own paths'); + await p.ev('tlClose()'); + }); + + await t.test("messages from the preview's frame are taken only from the lens's own origin", async () => { + const say = (origin, data) => p.ev(`window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify({ lens: 1, ...data })}, origin: ${JSON.stringify(origin)}, source: document.getElementById('pv-frame').contentWindow })), true`); + await say('http://evil.example', { type: 'route', route: '/evil' }); + await say('http://evil.example', { type: 'errors', errors: ['evil'] }); + await say('null', { type: 'route', route: '/opaque' }); + await aFrame(); + assert.deepEqual(await p.ev(`[TS.route, TS.errors.includes('evil')]`), ['/', false], 'another origin: nothing taken'); + // the same word from the lens's origin is taken, so it is the origin that kept the others out + const own = await p.ev('TS.origin'); + await say(own, { type: 'route', route: '/from-its-origin' }); + assert.equal(await p.ev('TS.route'), '/from-its-origin'); + await say(own, { type: 'route', route: '/' }); + await soon(`TS.route === '/' && !!document.querySelector('#av-pins .tl-pin')`, 'its route again'); + }); + + await t.test("the app can take its lens's voice: what it says is still the page's", async () => { + await keysHere(); + await tell({ own: 'possess' }); + await soon(`TS.ready && TS.route === '/liar'`, 'its route, in its lens\'s voice'); + assert.equal(await p.ev('TS.hit'), null, 'a reply to an id the page never minted is kept nowhere'); + assert.deepEqual(await p.ev(`[document.getElementById('av-over').hidden, TS.raised]`), [true, false], 'its Shift, with the keyboard on the dashboard, raises nothing'); + await soon(`!document.querySelector('#av-pins .tl-pin')`, 'no pin on its route, once it is drawn'); + }); + + await t.test('with the keyboard in the frame, its say-so raises the overlay, which only the owner lowers', async () => { + const blank = await frameAt(600, 500); + await p.clickAt(blank.x, blank.y); + await soon(`document.activeElement === document.getElementById('pv-frame')`, 'the frame has the keyboard'); + const raise = { own: 'forge', say: [{ lens: 1, type: 'key', key: 'Shift', shift: true }] }; + await tell(raise); + await soon('TS.raised', 'its say-so, believed while it has the keyboard'); + await p.move(blank.x + 5, blank.y + 5); + await soon(`document.getElementById('av-over').hidden && !TS.raised`, 'gone once the pointer moves without Shift'); + await p.hold('Shift', true); + await tell(raise); + await soon('TS.raised', 'raised'); + await tell({ own: 'forge', say: [{ lens: 1, type: 'key', key: 'Shift', shift: false }, { lens: 1, type: 'key', key: 'Escape', shift: false }] }); + assert.deepEqual(await p.ev(`[TS.raised, document.getElementById('av-over').hidden]`), [true, false], 'its "Shift up" lowers nothing while the owner holds it'); + await p.hold('Shift', false); + await p.move(blank.x, blank.y); + await soon('!TS.raised', 'lowered'); + await keysHere(); + }); + + await t.test("an answer about somewhere else isn't believed: the point alone", async () => { + await patient(2000); + await tell({ own: 'mode', mode: 'far' }); + const at = await frameAt(100, 140); + await p.hold('Shift', true); + await soon(`!document.getElementById('av-over').hidden`, 'the overlay'); + await p.move(at.x, at.y, 8); + await soon(`TS.hit && TS.hit.anchor === null`, 'its answer, dropped to the point'); + await soon(`document.getElementById('tl-chip').textContent === ${JSON.stringify(`${name} · /liar`)}`, 'the chip names the page'); + await p.clickAt(at.x, at.y, 8); + await p.hold('Shift', false); + await soon(`TL.draft?.subject?.point?.x === 100`, 'the point'); + assert.deepEqual(await p.ev(`[TL.draft.subject.anchor ?? null, TL.draft.subject.point, TL.draft.subject.route]`), [null, { x: 100, y: 140 }, '/liar']); + await p.key('Escape'); + await tell({ own: 'mode', mode: 'lie' }); + await patient(150); + await keysHere(); + }); + + await t.test('a question answered 5,000 times is answered once, 5,000 routes are drawn once, and the page keeps up', async () => { + await tell({ own: 'mode', mode: 'flood' }); + const at = await frameAt(100, 140); + await p.hold('Shift', true); + await soon(`!document.getElementById('av-over').hidden`, 'the overlay'); + await p.move(at.x, at.y, 8); + const { id } = await soon(`window.__own.find((x) => x.own === 'flooded')`, 'the flood'); + assert.deepEqual(await p.ev(`[TS.hit?.anchor?.text, TS.minted.has(${JSON.stringify(id)})]`), ['0', false], 'its first answer, and the question let go of'); + let ms = await frameMs(); + assert.ok(ms < 2000, `a frame drawn in ${ms} ms`); + await p.hold('Shift', false); + await tell({ own: 'mode', mode: 'lie' }); + await p.ev(`(() => { window.__draws = 0; const d = tlDraw; tlDraw = () => { window.__draws++; d(); }; return true; })()`); + await tell({ own: 'routes' }); + ms = await frameMs(); + assert.ok(ms < 2000, `a frame drawn in ${ms} ms`); + await aFrame(); + assert.equal(await p.ev('TS.route'), '/liar', 'its last word'); + assert.ok(await p.ev('window.__draws') < 200, 'drawn once a frame, not once a route'); + // the same, saying ready (a route each) rather than route: the page it ends on is the one with a thread + const resolves = `window.__own.filter((m) => m.own === 'heard' && m.data.type === 'resolve').length`; + await p.ev('window.__draws = 0'); + await tell({ own: 'forge', say: [...Array.from({ length: 5000 }, (_, i) => ({ lens: 1, type: 'ready', path: '/', route: i % 2 ? '/a' : '/b', viewport: { w: 10, h: 10 } })), { lens: 1, type: 'route', route: '/' }] }); + ms = await frameMs(); + assert.ok(ms < 2000, `a frame drawn in ${ms} ms`); + await aFrame(); + assert.equal(await p.ev('TS.route'), '/', 'its last word'); + assert.ok(await p.ev('window.__draws') < 200, 'drawn once a frame, not once a ready'); + await soon(`document.querySelector('#av-pins .tl-pin')`, 'the pin on the route it ended on'); + // a ready about the route it is on, once its watch was asked for, is heard no more: a load is heard once + await new Promise((r) => setTimeout(r, 300)); + const settled = await p.ev(resolves); + await tell({ own: 'forge', say: Array.from({ length: 50 }, () => ({ lens: 1, type: 'ready', path: '/', route: '/', viewport: { w: 10, h: 10 } })) }); + await aFrame(); + await new Promise((r) => setTimeout(r, 300)); + assert.equal(await p.ev(resolves), settled, 'nothing asked of the lens again'); + await tell({ own: 'forge', say: [{ lens: 1, type: 'route', route: '/liar' }] }); + await soon(`TS.route === '/liar'`, 'back on its last word'); + await keysHere(); + }); + + await t.test("its lies are its words: shown as text, cut of the box's capability and of bidi, and never markup", async () => { + await patient(2000); + const at = await frameAt(60, 60); + await p.hold('Shift', true); + await soon(`!document.getElementById('av-over').hidden`, 'the overlay'); + await p.move(at.x, at.y, 8); + await soon(`TS.hit?.anchor?.role === 'note'`, 'its answer'); + const chip = 'note ""'; + await soon(`document.getElementById('tl-chip').textContent === ${JSON.stringify(chip)}`, 'the chip, drawn as text'); + await p.clickAt(at.x, at.y, 8); + await p.hold('Shift', false); + await soon(`TL.draft?.subject?.route === '/liar'`, 'a thread about to start'); + const s = await p.ev('TL.draft.subject'); + assert.equal(s.anchor.text, 'Pay at [link removed] now'); + assert.doesNotMatch(JSON.stringify(s), /1767225600000/, 'no capability in the subject'); + await p.ev(`document.querySelector('#tl-card details')?.setAttribute('open', ''); true`); + assert.deepEqual(await p.ev(`[document.querySelectorAll('#tl img').length, window.__pwned ?? null]`), [0, null], "its markup never became the page's"); + await p.type('Who wrote this'); + await p.key('Enter'); + const th = await until(() => threads().find((x) => x.subject?.route === '/liar'), 'the thread'); + assert.doesNotMatch(JSON.stringify(th), /1767225600000/, 'nothing written carries it'); + assert.equal(th.subject.anchor.text, 'Pay at [link removed] now'); + await p.ev('tlClose()'); + await patient(150); + await keysHere(); + }); + + await t.test("nothing sent to the preview's frame carries the owner's words: questions only, by ids the page minted", async () => { + const all = await heard(); + assert.ok(all.some((m) => m.type === 'hello') && all.some((m) => m.type === 'hit') && all.some((m) => m.type === 'resolve') && all.some((m) => m.type === 'go'), JSON.stringify([...new Set(all.map((m) => m.type))])); + assert.deepEqual([...new Set(all.map((m) => m.type))].filter((k) => !['hello', 'hit', 'resolve', 'scroll-by', 'go'].includes(k)), [], 'only questions'); + assert.deepEqual(all.filter((m) => m.id !== undefined && !(typeof m.id === 'string' && /^[hr]\d+$/.test(m.id))), [], 'string ids only'); + for (const w of ['Make it green', 'Who wrote this']) assert.ok(all.every((m) => !JSON.stringify(m).includes(w)), `never the owner's words: ${w}`); + }); + + await t.test('closed, the stage lets go of the lens', async () => { + await p.ev(`location.hash = '#library'`); + await soon(`document.getElementById('artifact-view').hidden && !document.getElementById('pv-frame').hasAttribute('src') && document.getElementById('pv-frame').hidden && TS.kind === null && TS.name === null && TS.route === null`, 'closed'); + }); + + await t.test('the viewer tab for a preview: the same stage, the whole tab, its state from hello', async () => { + await p.ev(`sessionStorage.__loads = '0'`); + await p.send('Page.navigate', { url: `${B}/view/preview/${name}` }); + await soon(`TS.kind === 'preview' && TS.ready && !!document.querySelector('#av-pins .tl-pin')`, 'the viewer, the lens, the pin'); + assert.equal(await p.ev(`document.documentElement.classList.contains('viewer')`), true); + await soon(`/ · live$/.test(document.getElementById('av-where').textContent)`, 'its state, from hello'); + assert.equal(await p.ev('sessionStorage.__loads'), '1', 'its relay was open: no reload'); + }); + + await t.test("a viewer whose own ask opened the relay loads once more, and then shows it", async () => { + const app2 = await fakeApp({}, { '/': APP_PAGE }); + undo.push(() => app2.close()); + const name2 = /preview (\S+) /.exec(F.sup(['preview', String(app2.port), '--label', 'Other']))[1]; + await p.ev(`sessionStorage.__loads = '0'`); + await p.send('Page.navigate', { url: `${B}/view/preview/${name2}` }); + await soon(`TS.kind === 'preview' && TS.name === ${JSON.stringify(name2)} && TS.ready && TS.route === '/'`, 'the second preview, through its own relay'); + assert.equal(await p.ev('sessionStorage.__loads'), '2', 'once more, and only once'); + assert.equal(await p.ev(`sessionStorage.getItem(${JSON.stringify(`devteam.preview.reloaded.${name2}`)})`), null, 'and ready to again, the next time one opens'); + assert.equal(await p.ev(`document.getElementById('av-note').hidden`), true); + }); + + await t.test("an artifact's viewer never shows a preview: not by its route, not by a preview's thread", async () => { + const T = threads().find((x) => x.subject?.kind === 'preview' && x.subject.route === '/').id; + await p.send('Page.navigate', { url: `${B}/view/artifact/some-mock#preview/${name}` }); + await soon(`TS.kind === 'artifact' && TL.threads.some((x) => x.id === ${JSON.stringify(T)})`, 'the artifact viewer, with the threads'); + await p.ev(`tlOpen(${JSON.stringify(T)})`); + await aFrame(); + assert.deepEqual(await p.ev(`[TS.kind, document.getElementById('pv-frame').hasAttribute('src'), document.getElementById('pv-frame').hidden, location.hash]`), ['artifact', false, true, `#preview/${name}`]); + }); +}); + +test("the Library's preview card opens the stage here and the viewer in a tab; a question's [[name]] opens the viewer", { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 180000 }, async (t) => { + const undo = []; + t.after(async () => { let err = null; for (const f of undo.reverse()) { try { await f(); } catch (e) { err ??= e; } } if (err) throw err; }); + const F = threadsFixture('preview-links'); + undo.push(() => F.done()); + const app = await fakeApp(); + undo.push(() => app.close()); + const name = /preview (\S+) /.exec(F.sup(['preview', String(app.port), '--label', 'Checkout']))[1]; + F.sup(['ask', '--question', 'Does [[flow]] feel right?', '--choices', '[{"label":"Yes"},{"label":"No"}]', '--links', JSON.stringify([{ name: 'flow', title: 'The flow', preview: name }]), '--stake', '1', '--urgency', '1']); + const port = await freePort(); + const dash = startDashboard(F.old, F.env({}), ['--port', String(port)]); + undo.push(() => dash.stop()); + const chrome = await openChrome(); + undo.push(() => chrome.close()); + const p = await chrome.newPage(); + undo.push(() => p.close()); + await until(() => /dashboard on http/.test(dash.out()), 'the dashboard'); + await p.send('Page.enable'); + await p.send('Runtime.enable'); + await p.send('Page.navigate', { url: `http://127.0.0.1:${port}/#library` }); + const soon = async (expr, what) => { const end = Date.now() + 30000; for (;;) { const v = await p.ev(expr); if (v) return v; if (Date.now() > end) assert.fail(`timed out: ${what}`); await new Promise((r) => setTimeout(r, 50)); } }; + const link = (sel) => p.ev(`(() => { const a = document.querySelector(${JSON.stringify(sel)}); return a && [a.getAttribute('href'), a.target, a.rel, a.textContent]; })()`); + const card = `[data-preview="${name}"]`; + await soon(`!!document.querySelector('${card} a.av-open')`, 'the card'); + assert.deepEqual(await link(`${card} a.av-open`), [`/view/preview/${name}`, '_blank', 'noopener noreferrer', 'Open']); + assert.deepEqual(await link(`${card} .art-t > a`), [`#preview/${name}`, '', '', 'Checkout']); + assert.equal(await p.ev(`document.querySelector('${card} [data-copy]').dataset.copy`), `http://127.0.0.1:${app.port}/`, 'Copy link is still its own link'); + // where the stage was opened from is taken once the preview is known, and not at all for an ask the owner left + await p.ev(`(() => { const f = window.fetch; window.fetch = (u, ...a) => (String(u).includes('/api/view') ? new Promise((r) => setTimeout(r, 400)).then(() => f(u, ...a)) : f(u, ...a)); return true; })()`); + await p.ev(`location.hash = '#preview/${name}'`); + await p.ev(`location.hash = '#library'`); + await new Promise((r) => setTimeout(r, 900)); + assert.deepEqual(await p.ev('[AV_FROM, avFocus, document.getElementById("artifact-view").hidden]'), [false, null, true], 'an ask the owner left behind records nothing and opens nothing'); + await p.ev(`location.hash = '#inbox'`); + await soon(`!!document.querySelector('.qref a')`, 'the [[flow]]'); + assert.deepEqual(await link('.qref a'), [`/view/preview/${name}`, '_blank', 'noopener noreferrer', 'The flow']); +}); // The Loop's sidepeeks (the OODA spec, PR 3): every card opens one when clicked anywhere on it; an // observation's says where it came from and where it sits; a signal opens by its key, / and all. diff --git a/test/gateway.test.mjs b/test/gateway.test.mjs index 0302a03..db2e194 100644 --- a/test/gateway.test.mjs +++ b/test/gateway.test.mjs @@ -951,6 +951,21 @@ test('public. serves artifacts with the kit\'s own type map, CDN list and p for (const p of ['index.html', 'a/b.js', '.env', 'a/.git/x', 'a//b', 'a/../b', 'a%2eb', 'é.png', '__proto__']) assert.equal(G.publicPathOk(p), kitOk(p), p); }); +test("the kit's preview lens and framing rules are the gateway's own: the laptop's relay serves the same bytes", () => { + const src = fs.readFileSync(path.join(kit, 'scripts', 'devteam.mjs'), 'utf8'); + const kitLens = /^const PREVIEW_LENS = String\.raw`([^`]*)`;$/m.exec(src)?.[1]; + assert.ok(kitLens, 'the kit holds the lens as one String.raw'); + assert.equal(kitLens, G.PREVIEW_LENS, 'byte for byte'); + const gw = fs.readFileSync(path.join(here, '..', 'cloud', 'gateway.mjs'), 'utf8'); + for (const name of ['FRAME_ANCESTORS', 'replaceFrameAncestors']) { + const one = (text) => new RegExp(`^(?:export )?const ${name} = (.*)$`, 'm').exec(text)?.[1]; + assert.ok(one(src), `the kit has ${name}`); + assert.equal(one(src), one(gw), name); + } + const kitCsp = vm.runInNewContext(`${/^const previewLensPart = .*$/m.exec(src)[0]}\n${/^const previewLensHash = .*$/m.exec(src)[0]}\n${/^const previewLensCsp = .*$/m.exec(src)[0]}\npreviewLensCsp('http://a http://b')`, { PREVIEW_LENS: kitLens, crypto }); + assert.equal(kitCsp, G.previewLensCsp('http://a http://b'), 'the same policy for the same ancestors'); +}); + test('the gateway computes git\'s blob and tree ids from bytes, sha1 and sha256', () => { const files = { 'index.html': '', 'a/b.js': 'b', 'a/c/d.css': 'd', 'a-b.txt': 'x', 'a.txt': 'y', 'run.sh': 'echo' }; for (const fmt of ['sha1', 'sha256']) { diff --git a/test/lib/chrome.mjs b/test/lib/chrome.mjs index 6faa7b9..0a577f6 100644 --- a/test/lib/chrome.mjs +++ b/test/lib/chrome.mjs @@ -58,7 +58,13 @@ export async function openChrome() { await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error('could not reach the page')); }); let seq = 0; const waiting = new Map(); - ws.onmessage = (m) => { const o = JSON.parse(m.data); const w = waiting.get(o.id); if (!w) return; waiting.delete(o.id); if (o.error) w.reject(new Error(`${w.method}: ${o.error.message}`)); else w.resolve(o.result); }; + // the page's script worlds by origin (Runtime.enable sends them), for evIn: a frame of another origin is out of its parent's reach + const worlds = new Map(); + ws.onmessage = (m) => { const o = JSON.parse(m.data); + if (o.method === 'Runtime.executionContextCreated' && o.params.context.auxData?.isDefault) worlds.set(o.params.context.id, o.params.context.origin); + else if (o.method === 'Runtime.executionContextDestroyed') worlds.delete(o.params.executionContextId); + else if (o.method === 'Runtime.executionContextsCleared') worlds.clear(); + const w = waiting.get(o.id); if (!w) return; waiting.delete(o.id); if (o.error) w.reject(new Error(`${w.method}: ${o.error.message}`)); else w.resolve(o.result); }; // A page that stops answering fails the call: an await nobody will ever settle hangs the run. const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; @@ -73,6 +79,18 @@ export async function openChrome() { if (r.exceptionDetails) throw new Error(`${expression}: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`); return r.result.value; }; + // ev, in the newest page of that origin (a frame under a page of another one) + const evIn = async (origin, expression) => { + // newest first, and one that is really at that origin: a frame's first, empty document has a world of its own + for (const id of [...worlds].filter(([, o]) => o === origin).map(([i]) => i).reverse()) { + const at = await send('Runtime.evaluate', { expression: 'location.href', returnByValue: true, contextId: id }).catch(() => null); + if (!String(at?.result?.value).startsWith(origin)) continue; + const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, contextId: id }); + if (r.exceptionDetails) throw new Error(`${expression}: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`); + return r.result.value; + } + throw new Error(`no page of ${origin} yet`); + }; const key = async (k, modifiers = 0) => { const printable = k.length === 1; const common = { key: k, code: CODES[k] ?? (printable ? `Key${k.toUpperCase()}` : k), modifiers, windowsVirtualKeyCode: KEYS[k] ?? k.toUpperCase().charCodeAt(0) }; @@ -97,7 +115,7 @@ export async function openChrome() { await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, modifiers, button: 'left', buttons: 1, clickCount: 1 }); await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, modifiers, button: 'left', buttons: 0, clickCount: 1 }); }; - return { send, ev, key, type, click, hold, at, move, clickAt, close: () => ws.close() }; + return { send, ev, evIn, key, type, click, hold, at, move, clickAt, close: () => ws.close() }; }; return { newPage, close }; } diff --git a/test/lib/ports.mjs b/test/lib/ports.mjs index 537ea8c..f078519 100644 --- a/test/lib/ports.mjs +++ b/test/lib/ports.mjs @@ -1,6 +1,6 @@ // Each test process owns a block of 100 ports between 20000 and 32699 (below 32768, where Linux // starts handing out ephemeral ports; macOS starts at 49152): the file-wide hub and instance block at PORTS, the ports test at -// PORTS+30, the hub test at PORTS+60. A block is claimed with a lock file in the temp dir, so the +// PORTS+30, the hub test at PORTS+60. The dashboard test's preview relays listen at PORTS+62..79, inside the hub test's instance block: they share it, so those two never run at once (serial only). A block is claimed with a lock file in the temp dir, so the // suite's files running side by side, and another checkout's suite, never share one. The lock goes // when the process exits; one left by a process that died is cleared once it is an hour old. import fs from 'node:fs';