mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) Three gaps the census named, and the last of them is why the page needed a second grant. `expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with an empty array and the list painted "Host not found" over the host the shell had just opened it for. `init` gains an optional `host`, and `host-store.web.ts` serves it: the profile the screens read, with no device token and no public key, because the bridge already carries the connection those would have opened. The native writes it cannot make — pairing, renaming, recency — settle rather than throw, since recency orders a list the page never shows. AsyncStorage's web build is `window.localStorage`, and the page has none worth having: Android turns DOM storage off and on iOS the origin host is the session id, so a pin set in the page was gone on the next remount. The builder aliases the module to a page store whose values are the app's own — `init` primes the allowlisted keys, a write is applied locally and posted over a new `storage` grant, and the app is where it lands. The allowlist is two keys and is the whole fence: everything the app stores shares one namespace, the hybrid shell flag included, so a page that could write any of it could turn the feature on for a build that never offered it. A key outside the list is refused and, crucially, not kept locally either — a pin that looks set and is not is the failure the grant exists to avoid. The bridge host is built only once both have been read, because `init` is answered once per `ready` and carries them: a host that started without them would have to be torn down to carry them, and the list would already have mounted against a host it could not name. `Alert.alert` on a failed host removal is a silent no-op in React Native Web, so inside the page that failure had no surface at all. It routes to the error the list already shows, on both platforms. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the storage-write ref after the commit The last render-phase ref write in this hook joins the commit-phase effect. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the confirm setter this callback already calls in its deps A `useState` setter is stable, so the identity of the callback is unchanged; the list now says what the body reads. Reported on the line this branch rewrote. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4) Five, from one round of review. A removal that fails no longer writes the identity error: that one is an early return over the header, the list and the overlays, with nothing to dismiss it and nothing left to render the confirm it re-opens. It goes to a dismissible line above the list instead, on both platforms, cleared by the next confirmed refresh. `init` reads the allowlisted keys on every answer rather than capturing them at mount, so a document that reloads inside one mount is primed from after its own writes. The read stays synchronous: the page refuses every member until `init` lands and the golden recorder mounts a screen in the same turn it drains one, so a promise here moves the first render of every bridged replay. A profile read that rejects is now a shell failure with a diagnostic instead of a `ready` session with no host behind it and a page asking forever. The page bounds a value by the envelope's own constant rather than caching what the wire drops. And a write is held to the keys this page was handed, so one host's page cannot rewrite another's pinned list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): state the two test fixtures' types instead of asserting them The casting gate reads a `SAFETY:` rationale off the line directly above the assertion, and a wrapped comment puts a comment there instead. Two of the four were not assertions worth keeping at all: a hoisted fixture says its own type, and the router comes from the mock the file already installs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the two fixtures in place, not through a widened binding `const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate refuses, and rightly: the evidence is discarded and then invented again. The assertion belongs at the literal, with its rationale on the line above it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the key a refused page write reached for The last diagnostic still falling through to the line about a view outliving its host, on the branch that added it. The key is the evidence: it says which host's pinned list the page was reaching into. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe this branch's storage writes too Same merge, one branch further: a probe here also collects what the page asked the screen to write. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the page the app's storage as it stands, not one init late The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes two of them from its own native screens. The shell's map was only re-read after a ready, so a native write between two readies reached the init after next: the drawer opened on the repo the user left. The map is now module-scoped and every writer of an allowlisted key notes it as it writes, so the init that answers a ready already carries it. The store read only seats the map, and a read that started before a write no longer puts the older value back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the banner's dismiss without an assertion Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the storage mirror in the storage tree The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the dependency the wrong way round: the shell is what is built on the app's storage, not the other way. The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
594 lines
26 KiB
JavaScript
594 lines
26 KiB
JavaScript
import { createServer } from 'node:http'
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { chromium } from 'playwright-core'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
|
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
|
|
|
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
|
|
|
// Why a real browser: the route tree is handed to expo-router's own ExpoRoot through a synthesized
|
|
// RequireContext. Nothing short of mounting it proves that object is the shape ExpoRoot reads.
|
|
const HOST_ROUTE = '/h/render-check-host'
|
|
|
|
// What the double answers `ready` with. Asserted on the document, so a page that mounted against
|
|
// some other session, or against none, fails here rather than on a phone.
|
|
const SHELL_SESSION_ID = 'render-check-session'
|
|
const SHELL_BUILD_ID = 'render-check-build'
|
|
// The host the shell opened the page for. Without it `expo-secure-store` is {} on web and the list
|
|
// paints "Host not found" over a host that is right there.
|
|
const SHELL_HOST = {
|
|
id: 'render-check-host',
|
|
name: 'Render Check Host',
|
|
endpoint: 'ws://render-check',
|
|
lastConnected: 1
|
|
}
|
|
|
|
// The sharded `test` job does not install mobile dependencies, so the page cannot be built there.
|
|
// The CSP suite below needs none of them and still runs. pr.yml's mobile_web_app job runs both.
|
|
const bundles = mobileWebAppDependenciesPresent()
|
|
const describeRender = bundles ? describe : describe.skip
|
|
|
|
let scratch
|
|
let server
|
|
let browser
|
|
let origin
|
|
let routeChunks = {}
|
|
let cspHeader = null
|
|
let bridgeVersion = null
|
|
let faultGrant = null
|
|
|
|
/**
|
|
* Chunk paths the server answers with a module that throws on evaluation.
|
|
*
|
|
* The one way to reproduce the failure the boundary exists for: a route chunk that never arrives
|
|
* intact. Building a second bundle around a throwing route would test a synthetic tree; poisoning
|
|
* one file of the real bundle keeps everything else exactly what ships.
|
|
*/
|
|
const poisonedChunks = new Set()
|
|
const POISON_MESSAGE = 'render check poisoned this route chunk'
|
|
|
|
/**
|
|
* Both CSP constants are a list of quoted directives with `//` comments between them, and those
|
|
* comments quote directive text. Dropping comment lines first is what keeps a comment out of the
|
|
* header this test serves.
|
|
*/
|
|
export function parseCspDirectives(source, startMarker, endMarker) {
|
|
const start = source.indexOf(startMarker)
|
|
const end = source.indexOf(endMarker)
|
|
if (start === -1 || end < start) {
|
|
throw new Error(`could not find ${startMarker} .. ${endMarker}`)
|
|
}
|
|
const body = source
|
|
.slice(start, end)
|
|
.split('\n')
|
|
.filter((line) => !line.trimStart().startsWith('//'))
|
|
.join('\n')
|
|
const directives = [...body.matchAll(/"([^"]+)"/g)].map((match) => match[1])
|
|
if (directives.length < 10) {
|
|
throw new Error('could not parse the shell CSP')
|
|
}
|
|
return directives.join('; ')
|
|
}
|
|
|
|
/**
|
|
* The envelope version the page speaks, read from the contract rather than written down twice. A
|
|
* bumped `v` would otherwise reach this file as a 30s timeout naming nothing.
|
|
*/
|
|
async function readBridgeProtocolVersion() {
|
|
const source = await readFile(
|
|
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
|
|
'utf8'
|
|
)
|
|
const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source)
|
|
if (!match) {
|
|
throw new Error('could not read BRIDGE_PROTOCOL_VERSION')
|
|
}
|
|
return Number(match[1])
|
|
}
|
|
|
|
/** The grant the shell offers every page, read from the same source for the same reason. */
|
|
async function readBridgeFaultGrant() {
|
|
const source = await readFile(
|
|
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
|
|
'utf8'
|
|
)
|
|
const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source)
|
|
if (!match) {
|
|
throw new Error('could not read BRIDGE_FAULT_GRANT')
|
|
}
|
|
return match[1]
|
|
}
|
|
|
|
/**
|
|
* The shell's half of the bridge, as the page's channel sees it.
|
|
*
|
|
* The entry mounts nothing until `init` lands, so a render check with no shell renders no route at
|
|
* all. This answers `ready` and refuses everything else: a real reply would make this file the
|
|
* place domain behaviour is decided, and every screen below already has a state for an RPC that
|
|
* failed. The one message that matters here is the one that lets the tree mount.
|
|
*/
|
|
function installShellDouble({ version, sessionId, buildId, route, host, storage, faultGrant }) {
|
|
// Where the page's own fault reports land. Read back after the render, so a route that threw
|
|
// under the boundary names itself instead of timing out as a page that never mounted.
|
|
globalThis.__orcaRenderCheckFaults = []
|
|
const channel = {
|
|
postMessage: (json) => {
|
|
const frame = JSON.parse(json)
|
|
const answer = (message) => {
|
|
// A microtask, not a task: the page posts `ready` while its script is still running, and
|
|
// this keeps the answer behind it without moving a timer the page's backoff reads.
|
|
queueMicrotask(() => {
|
|
channel.onmessage?.({ data: JSON.stringify(message) })
|
|
})
|
|
}
|
|
if (frame.type === 'ready') {
|
|
answer({
|
|
v: version,
|
|
type: 'init',
|
|
sessionId,
|
|
buildId,
|
|
connection: {
|
|
state: 'connected',
|
|
reconnectAttempt: 0,
|
|
lastConnectedAt: 1,
|
|
lastInboundAt: 1,
|
|
generation: 0
|
|
},
|
|
grants: {
|
|
rpc: { maxPendingRequests: 64, maxSubscriptions: 32 },
|
|
native: [faultGrant]
|
|
},
|
|
// Omitted for a shell too old to name one, which is the case the page has a panel for.
|
|
...(route === null ? {} : { route }),
|
|
...(host === null ? {} : { host }),
|
|
storage
|
|
})
|
|
return
|
|
}
|
|
if (frame.type === 'notify' && frame.name === faultGrant) {
|
|
globalThis.__orcaRenderCheckFaults.push(frame.error.message)
|
|
return
|
|
}
|
|
if (frame.type === 'request' || frame.type === 'subscribe') {
|
|
answer({
|
|
v: version,
|
|
type: 'error',
|
|
id: frame.id,
|
|
error: {
|
|
category: 'RenderCheckShellDouble',
|
|
message: 'the render check answers no RPC',
|
|
isRpcDeliveryUnknown: false
|
|
}
|
|
})
|
|
}
|
|
},
|
|
onmessage: null
|
|
}
|
|
globalThis.orcaBridge = channel
|
|
}
|
|
|
|
/**
|
|
* The shipped policy, read from the Kotlin source so this test cannot drift from what the shell
|
|
* actually sends. Parsed rather than imported: the constant lives in a JVM module.
|
|
*/
|
|
async function readShellCsp() {
|
|
const source = await readFile(
|
|
join(
|
|
projectDir,
|
|
'mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt'
|
|
),
|
|
'utf8'
|
|
)
|
|
return parseCspDirectives(source, 'listOf(', ').joinToString')
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
cspHeader = await readShellCsp()
|
|
bridgeVersion = await readBridgeProtocolVersion()
|
|
faultGrant = await readBridgeFaultGrant()
|
|
if (!bundles) {
|
|
return
|
|
}
|
|
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-render-'))
|
|
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
|
|
const { outDir } = built
|
|
routeChunks = built.routeChunks
|
|
server = createServer((request, response) => {
|
|
const path = new URL(request.url, 'http://localhost').pathname
|
|
// A browser asks for this on its own and the shell's WebView never does. The bundle carries
|
|
// no icon, so a 404 would put a console error in every check that runs against a full Chrome
|
|
// -- which is what CI resolves -- and none against the bundled headless shell.
|
|
if (path === '/favicon.ico') {
|
|
response.writeHead(204)
|
|
response.end()
|
|
return
|
|
}
|
|
// A route path serves the entrypoint and the page routes client-side. A path naming a file
|
|
// has to come out of the bundle or 404, the same as the shell's manifest map: answering it
|
|
// with the document instead would hide a publicPath the script cannot fetch from.
|
|
const namesAFile = path.slice(path.lastIndexOf('/')).includes('.')
|
|
const file = namesAFile ? path.slice(1) : 'index.html'
|
|
readFile(join(outDir, file)).then(
|
|
(real) => {
|
|
// The real bytes with a throw in front: the module still links, so the importer resolves
|
|
// every export it asked for and then evaluation throws. A body replaced outright fails at
|
|
// link instead, which is a different failure from the one the boundary is here for.
|
|
const bytes = poisonedChunks.has(path)
|
|
? `throw new Error(${JSON.stringify(POISON_MESSAGE)});\n${real.toString('utf8')}`
|
|
: real
|
|
const headers = {
|
|
'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html'
|
|
}
|
|
// The document carries the shell's real policy, so a directive the page violates fails
|
|
// here rather than on a phone. Assets carry none, exactly as the native handler does.
|
|
if (file === 'index.html' && cspHeader) {
|
|
headers['content-security-policy'] = cspHeader
|
|
}
|
|
response.writeHead(200, headers)
|
|
response.end(bytes)
|
|
},
|
|
() => {
|
|
response.writeHead(404)
|
|
response.end()
|
|
}
|
|
)
|
|
})
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
origin = `http://127.0.0.1:${String(server.address().port)}`
|
|
// CI runs this against the runner's Google Chrome rather than paying for a browser download,
|
|
// the same reason and the same override shape as the orcad browser-provider job.
|
|
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
|
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
|
|
}, 180_000)
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
server?.close()
|
|
if (scratch) {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
// expo-router's Unmatched screen mounts cleanly and paints text, so "no errors, some html" stays
|
|
// green with every host route unreachable. Each route below names content only it can produce.
|
|
const UNMATCHED = 'Unmatched Route'
|
|
|
|
/**
|
|
* A page with every signal the checks below read: uncaught errors, console errors, and the script
|
|
* paths the browser actually fetched. The last one is how a client-side navigation proves it
|
|
* pulled the next route's chunk rather than painting out of what the entry already had.
|
|
*
|
|
* No `shellRoute` installs no double at all, which is the page that never mounts; a null one
|
|
* installs a shell that named no screen.
|
|
*/
|
|
async function openPage({ shellRoute, shellHost = SHELL_HOST, shellStorage = {} } = {}) {
|
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
|
if (shellRoute !== undefined) {
|
|
// At document start, where the native shell installs the real channel: the entry reads it
|
|
// while its own script runs, so a channel added after `load` would already be too late.
|
|
await page.addInitScript(installShellDouble, {
|
|
version: bridgeVersion,
|
|
sessionId: SHELL_SESSION_ID,
|
|
buildId: SHELL_BUILD_ID,
|
|
route: shellRoute,
|
|
host: shellHost,
|
|
storage: shellStorage,
|
|
faultGrant
|
|
})
|
|
}
|
|
const errors = []
|
|
const scripts = []
|
|
let reportUncaught = () => {}
|
|
// An uncaught error from the entry means nothing will ever mount. Racing it against the wait
|
|
// reports that error in a second instead of a 30s timeout that names nothing -- which is what a
|
|
// native-only route module, throwing at import before React runs, looks like from here.
|
|
// Resolved rather than rejected: this one settles during goto, before anything awaits it.
|
|
const uncaught = new Promise((resolve) => {
|
|
reportUncaught = resolve
|
|
})
|
|
page.on('pageerror', (error) => {
|
|
errors.push(`${error.name}: ${error.message}`)
|
|
reportUncaught(error)
|
|
})
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
errors.push(`console.error: ${message.text()}`)
|
|
}
|
|
})
|
|
page.on('response', (response) => {
|
|
const path = new URL(response.url()).pathname
|
|
if (response.status() === 200 && path.endsWith('.js')) {
|
|
scripts.push(path)
|
|
}
|
|
})
|
|
return { page, errors, scripts, uncaught }
|
|
}
|
|
|
|
/**
|
|
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
|
|
* the route manifest defers every screen behind `import()`, so the entry's `mounted` signal lands
|
|
* while the route's chunk is still being fetched and the body is briefly empty. Waiting for the
|
|
* string the caller is about to assert is what makes the check about the route and not the timing.
|
|
*/
|
|
async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
|
|
const named = (cause, what) =>
|
|
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`, { cause })
|
|
const race = async (wait) =>
|
|
Promise.race([
|
|
wait.then(
|
|
() => null,
|
|
(error) => error
|
|
),
|
|
uncaught
|
|
])
|
|
// The entry's own signal, not "#root has children": an error boundary or a half-painted tree
|
|
// also fills #root, and this only lands once expo-router's tree below the wrapper has committed.
|
|
// Polled on a timer rather than Playwright's default animation frames, which a page that never
|
|
// paints never delivers.
|
|
const cause = await race(
|
|
page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
|
|
timeout: 30_000,
|
|
polling: 250
|
|
})
|
|
)
|
|
if (cause) {
|
|
const state = await page.evaluate(
|
|
() => document.documentElement.dataset.orcaWebEntry ?? 'absent'
|
|
)
|
|
throw named(cause, `never mounted (entry ${state})`)
|
|
}
|
|
const paintCause = await race(
|
|
page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
|
|
timeout: 30_000,
|
|
polling: 250
|
|
})
|
|
)
|
|
if (paintCause) {
|
|
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
|
|
}
|
|
// Folded into the errors the caller already asserts empty: a throw the boundary caught paints
|
|
// nothing and logs nothing a `pageerror` listener hears, so this is the only place it shows up.
|
|
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
|
|
errors.push(`page fault: ${fault}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens the document the way the shell does — at `/`, the one path it serves — and lets the page
|
|
* route itself from what the double names. Navigating straight to the route would hide exactly the
|
|
* step this check exists to prove.
|
|
*/
|
|
async function render(route, awaitText, { shellRoute = { pathname: route }, ...shell } = {}) {
|
|
const opened = await openPage({ shellRoute, ...shell })
|
|
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
|
await waitForRoute(opened, route, awaitText)
|
|
const text = await opened.page.evaluate(() => document.body.innerText)
|
|
// What the page believes it is: read off the document rather than off the double, so a tree that
|
|
// mounted without a session, or against a session it invented, is not a passing render.
|
|
const session = await opened.page.evaluate(() => ({
|
|
sessionId: document.documentElement.dataset.orcaWebSessionId ?? null,
|
|
buildId: document.documentElement.dataset.orcaWebBuildId ?? null
|
|
}))
|
|
// The document is served at "/" and the page rewrites its own path before it renders; without
|
|
// that, every route below would be expo-router's Unmatched screen.
|
|
const url = await opened.page.evaluate(() => location.pathname + location.search)
|
|
await opened.page.close()
|
|
// A CSP refusal reaches the page as a console error, so the caller's empty-errors assertion is
|
|
// also the policy assertion; name it here so a failure says which one broke.
|
|
return {
|
|
errors: opened.errors,
|
|
cspErrors: opened.errors.filter((entry) => entry.includes('Content Security Policy')),
|
|
text,
|
|
session,
|
|
url
|
|
}
|
|
}
|
|
|
|
/** The entry's state and what it painted, for a page that is never going to mount a route tree. */
|
|
async function renderWithoutTree({ shellRoute } = {}) {
|
|
const { page, errors } = await openPage({ shellRoute })
|
|
// Read straight after `load` and not polled: the entry decides this synchronously, inside the
|
|
// script `load` waits for, so a state that is not settled by now is never going to settle.
|
|
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
|
const entry = await page.evaluate(() => document.documentElement.dataset.orcaWebEntry ?? 'absent')
|
|
const rootChildren = await page.evaluate(() => document.getElementById('root').childElementCount)
|
|
const text = await page.evaluate(() => document.body.innerText)
|
|
const url = await page.evaluate(() => location.pathname + location.search)
|
|
await page.close()
|
|
return { entry, errors, rootChildren, text, url }
|
|
}
|
|
|
|
describe('the shell policy this page is tested under', () => {
|
|
it('is the same on both platforms, so one render check covers both', async () => {
|
|
const swift = await readFile(
|
|
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
|
|
'utf8'
|
|
)
|
|
expect(parseCspDirectives(swift, 'static let header = [', '].joined')).toBe(cspHeader)
|
|
})
|
|
|
|
it('reads directives from the source and not from the comments around them', () => {
|
|
const source = [
|
|
'static let header = [',
|
|
" // React Native Web needs \"style-src 'self' 'unsafe-inline'\" and nothing more.",
|
|
' "default-src \'none\'",',
|
|
' "script-src \'self\'",',
|
|
" \"style-src 'self' 'unsafe-inline'\",",
|
|
' "img-src \'self\'",',
|
|
' "connect-src \'self\'",',
|
|
' "worker-src \'none\'",',
|
|
' "frame-src \'none\'",',
|
|
' "child-src \'none\'",',
|
|
' "object-src \'none\'",',
|
|
' "base-uri \'none\'",',
|
|
' "form-action \'none\'",',
|
|
' "frame-ancestors \'none\'"',
|
|
'].joined'
|
|
].join('\n')
|
|
const parsed = parseCspDirectives(source, 'static let header = [', '].joined')
|
|
expect(parsed.split('; ')[0]).toBe("default-src 'none'")
|
|
expect(parsed.split('; ').filter((entry) => entry.includes('unsafe-inline'))).toEqual([
|
|
"style-src 'self' 'unsafe-inline'"
|
|
])
|
|
})
|
|
|
|
it('still refuses inline script, which is the directive that matters', () => {
|
|
expect(cspHeader).toContain("script-src 'self';")
|
|
expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'")
|
|
})
|
|
})
|
|
|
|
describeRender('the page server this check runs against', () => {
|
|
it('404s a file path the bundle does not contain', async () => {
|
|
// Without this the document answers every path, and a publicPath the script cannot fetch
|
|
// from still renders, because the script is fetched from the one prefix that is served.
|
|
expect((await fetch(`${origin}/wrong-prefix/entry.js`)).status).toBe(404)
|
|
expect((await fetch(`${origin}/assets/not-a-real-hash.js`)).status).toBe(404)
|
|
})
|
|
|
|
it('answers the icon a browser asks for without an error', async () => {
|
|
expect((await fetch(`${origin}/favicon.ico`)).status).toBe(204)
|
|
})
|
|
|
|
it('still serves the document at every route depth', async () => {
|
|
for (const route of ['/', HOST_ROUTE, `${HOST_ROUTE}/tasks`]) {
|
|
const response = await fetch(`${origin}${route}`)
|
|
expect(response.status, route).toBe(200)
|
|
expect(await response.text(), route).toContain('<div id="root">')
|
|
}
|
|
})
|
|
})
|
|
|
|
describeRender('the Route A page in a real browser', () => {
|
|
it('mounts the worktree list route, not the unmatched screen', async () => {
|
|
const { errors, cspErrors, text, session, url } = await render(HOST_ROUTE, SHELL_HOST.name)
|
|
expect(cspErrors).toEqual([])
|
|
expect(errors).toEqual([])
|
|
// The tree that mounted is the one the shell handed a session to, and it says which.
|
|
expect(session).toEqual({ sessionId: SHELL_SESSION_ID, buildId: SHELL_BUILD_ID })
|
|
// The document was served at `/`; the page put itself on the route the shell named.
|
|
expect(url).toBe(HOST_ROUTE)
|
|
// The host the shell named, read through host-store.web.ts off `init.host`. Only that route's
|
|
// own component names the host; "Host not found" is what it paints without one.
|
|
expect(text).toContain(SHELL_HOST.name)
|
|
expect(text).not.toContain('Host not found')
|
|
expect(text).not.toContain(UNMATCHED)
|
|
}, 60_000)
|
|
|
|
it('routes a nested dynamic segment through the same context', async () => {
|
|
const { errors, cspErrors, text, session } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
|
|
expect(cspErrors).toEqual([])
|
|
expect(errors).toEqual([])
|
|
expect(session.sessionId).toBe(SHELL_SESSION_ID)
|
|
// app/h/[hostId]/tasks.tsx paints its header and its GitHub filter row.
|
|
expect(text).toContain('Tasks')
|
|
expect(text).toContain('Issues')
|
|
expect(text).not.toContain(UNMATCHED)
|
|
}, 60_000)
|
|
|
|
it('renders the unmatched route rather than crashing on a path with no module', async () => {
|
|
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`, UNMATCHED)
|
|
expect(cspErrors).toEqual([])
|
|
expect(errors).toEqual([])
|
|
// Asserted positively so the two negatives above are known to discriminate.
|
|
expect(text).toContain(UNMATCHED)
|
|
}, 60_000)
|
|
|
|
it('carries the params the shell named into the url the screen reads', async () => {
|
|
const { errors, url } = await render(HOST_ROUTE, SHELL_HOST.name, {
|
|
shellRoute: { pathname: HOST_ROUTE, params: { from: 'render check' } }
|
|
})
|
|
expect(errors).toEqual([])
|
|
expect(url).toBe(`${HOST_ROUTE}?from=render+check`)
|
|
}, 60_000)
|
|
|
|
it('paints the not-found state when the shell named no host, which is what makes the row real', async () => {
|
|
const { errors, text } = await render(HOST_ROUTE, 'Host not found', { shellHost: null })
|
|
expect(errors).toEqual([])
|
|
expect(text).toContain('Host not found')
|
|
expect(text).not.toContain(SHELL_HOST.name)
|
|
}, 60_000)
|
|
|
|
it('mounts nothing at all when no shell answered, which is what makes the rest real', async () => {
|
|
// Without this the checks above would pass against a page that ignores `init` entirely.
|
|
const { entry, errors, rootChildren } = await renderWithoutTree()
|
|
expect(entry).toBe('unbridged')
|
|
expect(rootChildren).toBe(0)
|
|
expect(errors).toEqual([])
|
|
}, 60_000)
|
|
|
|
it('says to update the app when the shell that opened it named no screen', async () => {
|
|
const { entry, errors, text, url } = await renderWithoutTree({ shellRoute: null })
|
|
expect(entry).toBe('shell-too-old')
|
|
expect(errors).toEqual([])
|
|
expect(text).toContain('Update Orca to open this workspace')
|
|
// Never the route tree at `/`: that is the Unmatched screen with a worse explanation.
|
|
expect(text).not.toContain(UNMATCHED)
|
|
expect(url).toBe('/')
|
|
}, 60_000)
|
|
|
|
it('tells the shell when a route chunk throws, rather than sitting on a blank page', async () => {
|
|
const chunk = routeChunks['./h/[hostId]/index.tsx']
|
|
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
|
poisonedChunks.add(`/assets/${chunk}`)
|
|
try {
|
|
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
|
|
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
|
const reported = await opened.page
|
|
.waitForFunction(
|
|
() => {
|
|
const faults = globalThis.__orcaRenderCheckFaults ?? []
|
|
return faults.length > 0 ? faults : null
|
|
},
|
|
{ timeout: 30_000, polling: 250 }
|
|
)
|
|
.then((handle) => handle.jsonValue())
|
|
// The message the poisoned module threw, carried across the bridge as the shell sees it. A
|
|
// boundary that caught the throw and reported something else would pass an "any fault" check.
|
|
expect(reported.join(' | ')).toContain(POISON_MESSAGE)
|
|
// And the screen never painted. The router's own shell commits before the deferred chunk
|
|
// rejects, so the entry does reach `mounted`; what the boundary takes away is everything
|
|
// below it, which is the difference between a reported failure and a blank page nobody hears.
|
|
const text = await opened.page.evaluate(() => document.body.innerText)
|
|
expect(text).not.toContain('Host not found')
|
|
expect(text).not.toContain(UNMATCHED)
|
|
await opened.page.close()
|
|
} finally {
|
|
poisonedChunks.delete(`/assets/${chunk}`)
|
|
}
|
|
}, 60_000)
|
|
|
|
it("fetches the next route's chunks on a client-side navigation", async () => {
|
|
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
|
|
const { page, errors, scripts } = opened
|
|
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
|
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
|
|
const loadedForFirstRoute = [...scripts]
|
|
// What the shell will do in C1.2: the document is fetched once and every later route is a
|
|
// history entry, so the tasks screen can only arrive as a chunk fetched now.
|
|
await page.evaluate((to) => {
|
|
history.pushState(null, '', to)
|
|
dispatchEvent(new PopStateEvent('popstate'))
|
|
}, `${HOST_ROUTE}/tasks`)
|
|
await waitForRoute(opened, `${HOST_ROUTE}/tasks`, 'Issues')
|
|
expect(new URL(page.url()).pathname).toBe(`${HOST_ROUTE}/tasks`)
|
|
const fetchedOnNavigation = scripts.filter((path) => !loadedForFirstRoute.includes(path))
|
|
// Not "some script arrived": the chunk the builder put the tasks route in, named by the
|
|
// builder rather than guessed from the bytes, which is the only thing that says the route
|
|
// came over the wire now and not out of what the first route had already loaded.
|
|
const tasksChunk = routeChunks['./h/[hostId]/tasks.tsx']
|
|
expect(tasksChunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
|
expect(fetchedOnNavigation, scripts.join(' ')).toContain(`/assets/${tasksChunk}`)
|
|
expect(loadedForFirstRoute).not.toContain(`/assets/${tasksChunk}`)
|
|
const text = await page.evaluate(() => document.body.innerText)
|
|
expect(text).toContain('Tasks')
|
|
expect(text).not.toContain(UNMATCHED)
|
|
expect(errors).toEqual([])
|
|
await page.close()
|
|
}, 60_000)
|
|
})
|