mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
Merge origin/ota-c7-5b-document-factory into ota-c7-5c-scope-threading (OTA phase C, C7.5c)
C7.5b's merge of main through2d697a4012: C7.10 item E's haptics over the bridge notify, item B's deferred mermaid page engine, #21908's closure pin and #21727's query-string patch. Two conflicts, both about files this branch deletes or the merge re-pins. `mobile/.oxlintrc.json` lists the generated files the linter skips. C7.5b's copy names its page factory, which ruling 25 deletes; item B's mermaid page engine has to stay. The resolution keeps the terminal engine and the mermaid page engine and drops the factory. `mobile/.gitignore` merged on its own and is the same shape. The session route's closure pin moves from 4,284 to 4,326, re-measured on this tree with the four postinstall generators run first, against C7.5b's own tree measured the same way in a scratch worktree at7ceb633df0— where it reproduces its 4,284 exactly, which is what makes the difference a reading rather than an arithmetic. The +42 is 43 modules in and one out. Out: `terminal-webview-document-factory.generated.ts`, one emitted file that carried the whole document. In: the document's 39 source modules the page now imports directly, the three page modules their tap group reaches (`terminal-webview-url-tap`, `terminal-path-tap`, `terminal-file-url-tap`) which the generator used to substitute as literals, and `terminal-text-scales`, the leaf the presets moved to so the WebView's bundle cannot reach AsyncStorage. Nothing generated is in the reading now. Red-first: with 4,284 still pinned, the case fails `expected [ …(4326) ] to have a length of 4284`. The bundle's own census is unchanged by the merge — main touched no document module — so its 47 inputs and its artifact-equals-build assertion hold without re-pinning. Terminal suite 64 files / 623 tests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -79,16 +79,22 @@ async function withScratch(run) {
|
||||
* this file under the 600-line cap.
|
||||
*/
|
||||
const EXPECTED_PAGE_ROUTES = [
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
|
||||
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'haptics'] },
|
||||
{
|
||||
pathname: '/h/[hostId]/agent-history/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'haptics']
|
||||
},
|
||||
{
|
||||
pathname: '/h/[hostId]/tasks',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
|
||||
},
|
||||
{
|
||||
pathname: '/h/[hostId]/files/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
},
|
||||
{ pathname: '/h/[hostId]/files/[worktreeId]', grants: ['navigate', 'storage', 'externalLink'] },
|
||||
{
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'externalLink']
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -530,15 +536,17 @@ describe('the Phase C budget', () => {
|
||||
})
|
||||
|
||||
it('derives the chunk ceiling from the route count, not from a measured number', async () => {
|
||||
// A chunk is emitted per distinct set of importers, so the count is combinatorial rather than
|
||||
// one per route. Measured while building this: 8 routes emit 23 chunks, 10 emit 40, 12 emit
|
||||
// 47, 14 emit 53 -- about 3 more per route at the top. The ceiling allows 4 and starts 16
|
||||
// above zero, so the next few routes land under it instead of failing on a pinned number.
|
||||
// A chunk is emitted per distinct set of importers, so the count is not a function of the
|
||||
// route count alone. Re-measured on this head, by copying the route tree and dropping routes
|
||||
// from the end of the sorted key list -- both siblings of each, because deleting a .web.tsx
|
||||
// alone leaves the native file for the builder to resolve and measures a different closure.
|
||||
// The 14-route reading is the real tree and includes the one script the deferred mermaid
|
||||
// artifact costs.
|
||||
for (const [routes, measured] of [
|
||||
[8, 23],
|
||||
[10, 40],
|
||||
[12, 47],
|
||||
[14, 53]
|
||||
[8, 32],
|
||||
[10, 43],
|
||||
[12, 61],
|
||||
[14, 69]
|
||||
]) {
|
||||
expect(mobileWebAppBundleMaxChunks(routes), `${String(routes)} routes`).toBeGreaterThan(
|
||||
measured
|
||||
@@ -546,6 +554,29 @@ describe('the Phase C budget', () => {
|
||||
}
|
||||
expect(mobileWebAppBundleMaxChunks(14)).toBe(72)
|
||||
expect(mobileWebAppBundleMaxChunks(15) - mobileWebAppBundleMaxChunks(14)).toBe(4)
|
||||
// Between four and nine more per route above, so the ceiling is a bound and not a fit -- and
|
||||
// at 14 routes it is a close one. 69 measured against 72, with the last two routes having cost
|
||||
// the 8 the ceiling grants for two: the next route that shares less than its neighbours fails
|
||||
// here, which is what this is for.
|
||||
expect(mobileWebAppBundleMaxChunks(14) - mobileWebAppBundleMaxChunks(12)).toBe(8)
|
||||
})
|
||||
|
||||
it('refuses an engine chunked along its own lazy boundaries, and passes one artifact', () => {
|
||||
// The two builds this ceiling has to tell apart, both measured at 14 routes.
|
||||
//
|
||||
// The page reaches mermaid through one pre-bundled artifact and the bundle emits 69 scripts
|
||||
// (68 of them the page's own split, one the deferred engine). Importing the package instead
|
||||
// emitted 172: mermaid lazily imports each of its own diagram types and esbuild splits along
|
||||
// those boundaries, all of it inside the generation the phone has already downloaded. The
|
||||
// route term is the only term precisely so that the second of those fails here -- a ceiling
|
||||
// raised to admit 172 would have admitted any split at all.
|
||||
const ROUTES = 14
|
||||
const WITH_ONE_ARTIFACT = 69
|
||||
const CHUNKED_ALONG_THE_ENGINE = 172
|
||||
expect(WITH_ONE_ARTIFACT).toBeLessThanOrEqual(mobileWebAppBundleMaxChunks(ROUTES))
|
||||
expect(CHUNKED_ALONG_THE_ENGINE).toBeGreaterThan(mobileWebAppBundleMaxChunks(ROUTES))
|
||||
// And the assets that came with it: 215 against 112, of the 256 the shell will load.
|
||||
expect(mobileWebAppBundleMaxAssets(ROUTES, 42)).toBeLessThan(CHUNKED_ALONG_THE_ENGINE + 42 + 1)
|
||||
})
|
||||
|
||||
it('derives the asset ceiling so the chunk ceiling is always the one that trips first', () => {
|
||||
@@ -586,7 +617,8 @@ describe('the Phase C budget', () => {
|
||||
it('fails the build when the derived ceiling passes what the phone will accept', async () => {
|
||||
// The shell hands back null for a manifest over its own ceiling, so a derived ceiling above
|
||||
// that ships a green build no device can open. At the 42 images the tree carries, 4r + 16 +
|
||||
// 42 + 1 crosses 256 at 50 routes, which Phase C reaches.
|
||||
// 42 + 1 crosses 256 at 50 routes, which Phase C reaches. A deferred engine kept to one
|
||||
// artifact leaves that where it is; the 103-script version of it moved the crossing to 24.
|
||||
expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS)
|
||||
expect(assertAssetCeilingFitsShell(49, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(255)
|
||||
expect(() => assertAssetCeilingFitsShell(50, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
|
||||
|
||||
@@ -48,7 +48,7 @@ const SHELL_HOST = {
|
||||
lastConnected: 1
|
||||
}
|
||||
/** Exactly what the preview declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */
|
||||
const PREVIEW_GRANTS = ['navigate', 'storage', 'externalLink']
|
||||
const PREVIEW_GRANTS = ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
|
||||
/**
|
||||
* A terminal artifact, which is the only preview this screen lets anyone edit.
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* The haptics seam, and how a census tells a page function that asks the shell from one that does
|
||||
* nothing.
|
||||
*
|
||||
* `haptics.web.ts` used to be five empty bodies, which is a shape no scan can distinguish from a
|
||||
* file it failed to read: an empty result and a green census meant the same thing. Now each of the
|
||||
* five posts one `native.haptics.trigger` notify carrying its own kind, so the census measures the
|
||||
* kinds it found — and runs the same walk over the native sibling, where the same five functions
|
||||
* exist and none of them posts, as the control that says the walk can tell the two apart.
|
||||
*
|
||||
* Shared rather than restated in each route's census, for the reason
|
||||
* `mobile-web-app-external-link-seam.mjs` is: two spellings of one rule drift.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import ts from 'typescript-api'
|
||||
|
||||
/** The seam as the web build resolves it: `.web.ts` wins under the builder's `resolveExtensions`. */
|
||||
export const HAPTICS_SEAM = 'src/platform/haptics.web.ts'
|
||||
|
||||
/** Its native sibling, which the page must never resolve to — it imports `expo-haptics`. */
|
||||
export const HAPTICS_NATIVE = 'src/platform/haptics.ts'
|
||||
|
||||
/** The module that declares the kinds, so a census reads them instead of listing them again. */
|
||||
export const HAPTICS_KINDS_MODULE = 'src/mobile-web-shell/bridge/bridge-haptics-notify.ts'
|
||||
|
||||
const KINDS_CONST = 'BRIDGE_HAPTICS_KINDS'
|
||||
const PUBLISH_FUNCTION = 'publishHapticsNotifier'
|
||||
|
||||
const parse = (source, fileName) =>
|
||||
ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true)
|
||||
|
||||
/**
|
||||
* The kinds the notify admits, read off the tuple that declares them.
|
||||
*
|
||||
* Parsed rather than matched, so a mention of the name in a comment or a docstring is not a
|
||||
* declaration, and so the quote style is settled for free.
|
||||
*/
|
||||
export function bridgeHapticsKinds(source, fileName = 'bridge-haptics-notify.ts') {
|
||||
const parsed = parse(source, fileName)
|
||||
for (const statement of parsed.statements) {
|
||||
if (!ts.isVariableStatement(statement)) {
|
||||
continue
|
||||
}
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== KINDS_CONST) {
|
||||
continue
|
||||
}
|
||||
// `as const` wraps the literal in an assertion expression; the tuple is inside it.
|
||||
const initializer =
|
||||
declaration.initializer !== undefined && ts.isAsExpression(declaration.initializer)
|
||||
? declaration.initializer.expression
|
||||
: declaration.initializer
|
||||
if (initializer === undefined || !ts.isArrayLiteralExpression(initializer)) {
|
||||
continue
|
||||
}
|
||||
return initializer.elements
|
||||
.filter((element) => ts.isStringLiteral(element))
|
||||
.map((element) => element.text)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the module-level binding `publishHapticsNotifier` assigns, or null in a module that
|
||||
* publishes nothing.
|
||||
*
|
||||
* Derived rather than assumed: the census must not be keyed on a local called `post`, because
|
||||
* renaming it would silently turn every posting site into a non-posting one and leave the census
|
||||
* green on a page with no haptics at all.
|
||||
*/
|
||||
function notifierBinding(parsed) {
|
||||
let binding = null
|
||||
const visit = (node) => {
|
||||
if (
|
||||
ts.isFunctionDeclaration(node) &&
|
||||
node.name !== undefined &&
|
||||
node.name.text === PUBLISH_FUNCTION
|
||||
) {
|
||||
const assign = (inner) => {
|
||||
if (
|
||||
ts.isBinaryExpression(inner) &&
|
||||
inner.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
||||
ts.isIdentifier(inner.left)
|
||||
) {
|
||||
binding = inner.left.text
|
||||
}
|
||||
ts.forEachChild(inner, assign)
|
||||
}
|
||||
ts.forEachChild(node, assign)
|
||||
return
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
ts.forEachChild(parsed, visit)
|
||||
return binding
|
||||
}
|
||||
|
||||
/** Every string literal this call is handed, so a site that posts a computed kind reports none. */
|
||||
function literalArguments(call) {
|
||||
return call.arguments.filter((argument) => ts.isStringLiteral(argument)).map((a) => a.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every exported `trigger…` function in a haptics module, and the kind it posts.
|
||||
*
|
||||
* `kind` is null for a function that posts nothing, which is what the native sibling's five are and
|
||||
* what the web sibling's five used to be. Reported as sites rather than as a boolean because a
|
||||
* census whose red names `path:line` is read once and one that names a file is grepped for.
|
||||
*/
|
||||
export function hapticsTriggerSites(source, fileName = 'haptics.ts') {
|
||||
const parsed = parse(source, fileName)
|
||||
const binding = notifierBinding(parsed)
|
||||
const lineOf = (node) => parsed.getLineAndCharacterOfPosition(node.getStart(parsed)).line + 1
|
||||
const sites = []
|
||||
for (const statement of parsed.statements) {
|
||||
if (
|
||||
!ts.isFunctionDeclaration(statement) ||
|
||||
statement.name === undefined ||
|
||||
!statement.name.text.startsWith('trigger') ||
|
||||
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) !==
|
||||
true
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const posted = []
|
||||
if (binding !== null && statement.body !== undefined) {
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
||||
if (node.expression.text === binding) {
|
||||
posted.push(...literalArguments(node))
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
ts.forEachChild(statement.body, visit)
|
||||
}
|
||||
sites.push({
|
||||
name: statement.name.text,
|
||||
line: lineOf(statement),
|
||||
// One kind per call and one call per function: two would be two taps for one gesture.
|
||||
kind: posted.length === 1 ? posted[0] : null
|
||||
})
|
||||
}
|
||||
return sites
|
||||
}
|
||||
|
||||
/** The kinds a module posts, in the order its functions are declared. */
|
||||
export function hapticsPostedKinds(source, fileName = 'haptics.ts') {
|
||||
return hapticsTriggerSites(source, fileName)
|
||||
.map((site) => site.kind)
|
||||
.filter((kind) => kind !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* The names a module imports from the app's haptics, which is how the shell's mapping is held to it.
|
||||
*
|
||||
* `page-haptics.ts` names each function as a named import rather than reaching a namespace, so a
|
||||
* row naming something `haptics.ts` does not export is already a compile error. This is the other
|
||||
* direction, which no type states: a haptic that file grows with no kind of its own would be one
|
||||
* the page can never ask for, and comparing this list against the file's own exports is the only
|
||||
* thing that sees it.
|
||||
*/
|
||||
export function hapticsImportedNames(source, fileName = 'module.ts') {
|
||||
const parsed = parse(source, fileName)
|
||||
const names = []
|
||||
for (const statement of parsed.statements) {
|
||||
if (
|
||||
!ts.isImportDeclaration(statement) ||
|
||||
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
||||
!/(?:\.\.?\/)+platform\/haptics$/.test(statement.moduleSpecifier.text)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const bindings = statement.importClause?.namedBindings
|
||||
if (bindings !== undefined && ts.isNamedImports(bindings)) {
|
||||
// The imported name, not the local one: a renamed import is the same export.
|
||||
names.push(...bindings.elements.map((element) => (element.propertyName ?? element.name).text))
|
||||
}
|
||||
}
|
||||
return [...new Set(names)].sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module in a closure that imports the haptics seam, as the path the closure reports.
|
||||
*
|
||||
* The specifier is read extensionless, because that is how a consumer writes it and how the builder
|
||||
* resolves it: a module importing `../platform/haptics` gets the `.web.ts` on the page and the
|
||||
* native file on a phone, and the census's job is to say which one the closure ended up with.
|
||||
*/
|
||||
export function hapticsSeamImporters(mobileDir, closure) {
|
||||
const specifier = /(?:^|['"])(?:\.\.?\/)+platform\/haptics(?:\.web)?['"]$/
|
||||
return closure.local
|
||||
.filter((file) => file !== HAPTICS_SEAM && file !== HAPTICS_NATIVE)
|
||||
.filter((file) => {
|
||||
let source
|
||||
try {
|
||||
source = readFileSync(join(mobileDir, file), 'utf8')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const parsed = parse(source, file)
|
||||
return parsed.statements.some(
|
||||
(statement) =>
|
||||
(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) &&
|
||||
statement.moduleSpecifier !== undefined &&
|
||||
ts.isStringLiteral(statement.moduleSpecifier) &&
|
||||
specifier.test(`'${statement.moduleSpecifier.text}'`)
|
||||
)
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* What a page's taps reach for a haptic, and the grant every page route needs to get one.
|
||||
*
|
||||
* Inside the shell's WebView `expo-haptics` fakes an iOS haptic by clicking a hidden checkbox it
|
||||
* appends to `document.head`, which is what killed a long press on the worktree list (C1.9). So the
|
||||
* page's seam posts `native.haptics.trigger` instead and the app plays the device's own — and a
|
||||
* route that imports the seam without declaring `haptics` is a page whose taps go quiet, because
|
||||
* grants are resolved once from the route the shell opened.
|
||||
*
|
||||
* The scan has a control rather than an empty list: the same walk over the native sibling finds the
|
||||
* same five functions and no posting site, which is what says it can tell the two apart.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
|
||||
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import {
|
||||
HAPTICS_KINDS_MODULE,
|
||||
HAPTICS_NATIVE,
|
||||
HAPTICS_SEAM,
|
||||
bridgeHapticsKinds,
|
||||
hapticsImportedNames,
|
||||
hapticsPostedKinds,
|
||||
hapticsSeamImporters,
|
||||
hapticsTriggerSites
|
||||
} from './mobile-web-app-haptics-seam.mjs'
|
||||
|
||||
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
|
||||
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
|
||||
|
||||
const read = (file) => readFileSync(join(mobileDir, file), 'utf8')
|
||||
|
||||
/** The route module behind each declared page route, which is what a closure is read from. */
|
||||
const ROUTE_MODULES = new Map([
|
||||
['/h/[hostId]', 'app/h/[hostId]/index.tsx'],
|
||||
['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'],
|
||||
['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'],
|
||||
['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'],
|
||||
['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx']
|
||||
])
|
||||
|
||||
const HAPTICS_GRANT = 'haptics'
|
||||
|
||||
/** The shell's mapping from a notify kind to one of the app's own functions. */
|
||||
const SHELL_MAPPING = 'src/mobile-web-shell/page-haptics.ts'
|
||||
|
||||
describe('the seam reader', () => {
|
||||
it('names the kind each exported trigger posts', () => {
|
||||
expect(
|
||||
hapticsTriggerSites(
|
||||
[
|
||||
'let post = () => false',
|
||||
'export function publishHapticsNotifier(notify) {',
|
||||
' post = notify',
|
||||
'}',
|
||||
"export function triggerSelection() { post('selection') }"
|
||||
].join('\n'),
|
||||
'haptics.web.ts'
|
||||
)
|
||||
).toEqual([{ name: 'triggerSelection', line: 5, kind: 'selection' }])
|
||||
})
|
||||
|
||||
it('reads the binding the publisher assigns rather than a name called post', () => {
|
||||
// Keyed on `post`, renaming the local would turn every posting site into a non-posting one and
|
||||
// leave this census green on a page whose taps buzz for nothing.
|
||||
expect(
|
||||
hapticsPostedKinds(
|
||||
[
|
||||
'let ask = () => false',
|
||||
'export function publishHapticsNotifier(notify) { ask = notify }',
|
||||
"export function triggerError() { ask('error') }"
|
||||
].join('\n'),
|
||||
'haptics.web.ts'
|
||||
)
|
||||
).toEqual(['error'])
|
||||
})
|
||||
|
||||
it('reports a function that posts nothing as a site with no kind', () => {
|
||||
expect(
|
||||
hapticsTriggerSites('export function triggerSelection() {}\n', 'haptics.web.ts')
|
||||
).toEqual([{ name: 'triggerSelection', line: 1, kind: null }])
|
||||
})
|
||||
|
||||
it('reports no kind for a function that posts one it computed, which nothing can pin', () => {
|
||||
expect(
|
||||
hapticsPostedKinds(
|
||||
[
|
||||
'let post = () => false',
|
||||
'export function publishHapticsNotifier(notify) { post = notify }',
|
||||
'export function triggerSelection(kind) { post(kind) }'
|
||||
].join('\n'),
|
||||
'haptics.web.ts'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reports no kind for a function that posts twice, which is two taps for one gesture', () => {
|
||||
expect(
|
||||
hapticsPostedKinds(
|
||||
[
|
||||
'let post = () => false',
|
||||
'export function publishHapticsNotifier(notify) { post = notify }',
|
||||
"export function triggerSelection() { post('selection'); post('success') }"
|
||||
].join('\n'),
|
||||
'haptics.web.ts'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves alone a trigger the module does not export', () => {
|
||||
expect(
|
||||
hapticsTriggerSites(
|
||||
[
|
||||
'let post = () => false',
|
||||
'export function publishHapticsNotifier(notify) { post = notify }',
|
||||
"function triggerLocal() { post('selection') }"
|
||||
].join('\n'),
|
||||
'haptics.web.ts'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores the seam named inside a comment or a string, which text matching cannot', () => {
|
||||
expect(
|
||||
hapticsPostedKinds(
|
||||
[
|
||||
'let post = () => false',
|
||||
'export function publishHapticsNotifier(notify) { post = notify }',
|
||||
"// export function triggerSelection() { post('selection') }",
|
||||
'const hint = "post(\'success\')"'
|
||||
].join('\n'),
|
||||
'haptics.web.ts'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reads the kinds off the tuple that declares them', () => {
|
||||
expect(bridgeHapticsKinds("export const BRIDGE_HAPTICS_KINDS = ['a', 'b'] as const\n")).toEqual(
|
||||
['a', 'b']
|
||||
)
|
||||
// A mention is not a declaration, which is why this is parsed rather than matched.
|
||||
expect(bridgeHapticsKinds("// BRIDGE_HAPTICS_KINDS = ['a']\n")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The two siblings measured against each other, which is what makes "all five post" a number.
|
||||
*
|
||||
* The native file is the control: same five names, same walk, no posting site. Without it an empty
|
||||
* result and a file the scan could not read would report the same thing.
|
||||
*/
|
||||
describe('the two haptics siblings', () => {
|
||||
it('posts every kind the notify admits from the web sibling, and nothing more', () => {
|
||||
const kinds = bridgeHapticsKinds(read(HAPTICS_KINDS_MODULE), HAPTICS_KINDS_MODULE)
|
||||
expect(kinds).toHaveLength(5)
|
||||
const posted = hapticsPostedKinds(read(HAPTICS_SEAM), HAPTICS_SEAM)
|
||||
expect([...posted].sort()).toEqual([...kinds].sort())
|
||||
})
|
||||
|
||||
it('finds five functions in the native sibling and no posting site at all', () => {
|
||||
const sites = hapticsTriggerSites(read(HAPTICS_NATIVE), HAPTICS_NATIVE)
|
||||
expect(sites).toHaveLength(5)
|
||||
expect(sites.filter((site) => site.kind !== null)).toEqual([])
|
||||
})
|
||||
|
||||
it('exports the same five names from both, which is what makes one a substitution', () => {
|
||||
const names = (file) => hapticsTriggerSites(read(file), file).map((site) => site.name)
|
||||
expect(names(HAPTICS_SEAM)).toEqual(names(HAPTICS_NATIVE))
|
||||
})
|
||||
|
||||
/**
|
||||
* The third direction, which no type in the app states.
|
||||
*
|
||||
* The shell's table refuses a kind with no row and a row naming a function that does not exist,
|
||||
* both at compile time. It says nothing about a haptic `haptics.ts` grows with no kind of its own,
|
||||
* which would be one the page can never ask for however many rows the table has.
|
||||
*/
|
||||
it('maps every function the app exports from the shell side, so none is unreachable', () => {
|
||||
const exported = hapticsTriggerSites(read(HAPTICS_NATIVE), HAPTICS_NATIVE).map(
|
||||
(site) => site.name
|
||||
)
|
||||
expect(exported).toHaveLength(5)
|
||||
expect(hapticsImportedNames(read(SHELL_MAPPING), SHELL_MAPPING)).toEqual([...exported].sort())
|
||||
})
|
||||
})
|
||||
|
||||
describe('the imported-name reader', () => {
|
||||
it('names what a module takes from the app haptics', () => {
|
||||
expect(
|
||||
hapticsImportedNames(
|
||||
"import { triggerError, triggerSuccess } from '../platform/haptics'\n",
|
||||
'page-haptics.ts'
|
||||
)
|
||||
).toEqual(['triggerError', 'triggerSuccess'])
|
||||
})
|
||||
|
||||
it('reads the imported name and not the local one, a renamed import being the same export', () => {
|
||||
expect(
|
||||
hapticsImportedNames(
|
||||
"import { triggerError as boom } from '../platform/haptics'\n",
|
||||
'page-haptics.ts'
|
||||
)
|
||||
).toEqual(['triggerError'])
|
||||
})
|
||||
|
||||
it('leaves alone an import of the web sibling or of something else entirely', () => {
|
||||
expect(
|
||||
hapticsImportedNames(
|
||||
[
|
||||
"import { triggerError } from '../platform/haptics.web'",
|
||||
"import { triggerSuccess } from './other-haptics'",
|
||||
"// import { triggerEdgeBump } from '../platform/haptics'"
|
||||
].join('\n'),
|
||||
'page-haptics.ts'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describeClosure(
|
||||
'every page route closure and the haptics seam',
|
||||
() => {
|
||||
it.each([...ROUTE_MODULES])('resolves the seam to the web sibling: %s', async (_route, mod) => {
|
||||
const closure = await mobileWebAppRouteClosure(mod)
|
||||
expect(closure.local).toContain(HAPTICS_SEAM)
|
||||
expect(closure.local).not.toContain(HAPTICS_NATIVE)
|
||||
// The precondition an assertion about a closure needs: the walk read a page, not nothing.
|
||||
expect(closure.local.length).toBeGreaterThan(250)
|
||||
})
|
||||
|
||||
it.each([...ROUTE_MODULES])(
|
||||
'imports the seam from at least one module, so the grant is not idle: %s',
|
||||
async (_route, mod) => {
|
||||
const closure = await mobileWebAppRouteClosure(mod)
|
||||
expect(hapticsSeamImporters(mobileDir, closure).length).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The grant list derived from the closures rather than written by hand.
|
||||
*
|
||||
* Grants are resolved once, from the route the shell opened, and carried for the life of the
|
||||
* session. A route that imports the seam and declares nothing is a page whose taps are silent
|
||||
* with nothing on screen to say why.
|
||||
*
|
||||
* The cost of the answer being every route: `implementedPageRoutes` filters on
|
||||
* `grants.every(implementsGrant)`, so against a shell that does not carry the token no page
|
||||
* route is served at all and the phone renders the native screens. The mechanism is pinned in
|
||||
* `mobile/src/mobile-web-shell/page-route-policy.test.ts`.
|
||||
*/
|
||||
it('declares haptics on exactly the routes whose closure reaches the seam', async () => {
|
||||
const reaching = []
|
||||
for (const [route, mod] of ROUTE_MODULES) {
|
||||
const closure = await mobileWebAppRouteClosure(mod)
|
||||
if (hapticsSeamImporters(mobileDir, closure).length > 0) {
|
||||
reaching.push(route)
|
||||
}
|
||||
}
|
||||
expect(reaching.length).toBeGreaterThan(0)
|
||||
const declared = MOBILE_WEB_PAGE_ROUTES.filter((route) =>
|
||||
route.grants.includes(HAPTICS_GRANT)
|
||||
).map((route) => route.pathname)
|
||||
expect([...declared].sort()).toEqual([...reaching].sort())
|
||||
})
|
||||
|
||||
it('covers every declared page route, so a new one cannot be missed by this file', () => {
|
||||
// The map above is a hand list of route modules; this is what holds it to the declarations.
|
||||
expect([...ROUTE_MODULES.keys()].sort()).toEqual(
|
||||
MOBILE_WEB_PAGE_ROUTES.map((route) => route.pathname).sort()
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* What the notify costs a page to download: one module.
|
||||
*
|
||||
* Measured, not assumed: every page closure grew by exactly `bridge-haptics-notify.ts`, and it
|
||||
* arrives through `page-route-policy.ts` reading the grant token rather than through the seam,
|
||||
* whose own import of the kind type is erased. Its only dependency is `zod`, which the envelope
|
||||
* already put in every closure, so the module total moved by the same one.
|
||||
*
|
||||
* Pinned structurally rather than as a total: an absolute closure count is main's to move, and a
|
||||
* number that drifts for unrelated reasons is one nobody reads.
|
||||
*/
|
||||
it('adds one module to a page closure, and only the two haptics modules are in it', async () => {
|
||||
for (const mod of ROUTE_MODULES.values()) {
|
||||
const closure = await mobileWebAppRouteClosure(mod)
|
||||
expect(closure.local.filter((file) => file.includes('haptics')).sort(), mod).toEqual([
|
||||
HAPTICS_KINDS_MODULE,
|
||||
HAPTICS_SEAM
|
||||
])
|
||||
// The engine of the delta: the grant token is a value the route policy reads, and the
|
||||
// policy is in every page closure. Without this the +1 would have no stated cause.
|
||||
expect(closure.local, mod).toContain('src/mobile-web-shell/page-route-policy.ts')
|
||||
}
|
||||
})
|
||||
},
|
||||
240_000
|
||||
)
|
||||
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Mermaid rendered in the page, in a real browser, under the policy the shell ships.
|
||||
*
|
||||
* The native component seals an untrusted diagram inside a `WebView` whose document embeds the
|
||||
* whole engine as a string. The page has no second content process, so what replaces it is
|
||||
* `import('mermaid')` on demand and mermaid's own `securityLevel: 'strict'` output. That makes
|
||||
* three claims this file measures rather than asserts: that rendering violates no directive and
|
||||
* asks for no JIT, that what the page paints is the diagram the phone already paints, and that a
|
||||
* hostile diagram reaches the document inert.
|
||||
*
|
||||
* The equality oracle is the native `buildHtml` itself, bundled and served as its own document in
|
||||
* the same browser. Two differences survive and are normalised away: the diagram id (mermaid's own
|
||||
* `mermaid-<epoch>` on the native path, the component's `useId` on the page) and the `xmlns:xlink`
|
||||
* declaration the native document's `innerHTML` serialization adds. Everything else — the viewBox,
|
||||
* the `max-width`, the injected `<style>`'s rules — is compared byte for byte.
|
||||
*
|
||||
* Both engines, because the shell is WKWebView on one platform and a Chromium WebView on the
|
||||
* other, and "does mermaid need eval" is answered by the engine rather than by mermaid.
|
||||
*
|
||||
* Recorded from this file's own run, for whoever needs the trade. Rendering one `graph TD` fetches
|
||||
* one chunk of 3,482,965 minified bytes on top of a 283,956-byte entry — the pre-bundled engine,
|
||||
* not in the entry, and not fetched at all by a page with no diagram on it (ruling 28's fence,
|
||||
* held in `mobile-web-app-session-terminal-closure.test.mjs`). One chunk rather than the 103 that
|
||||
* `import('mermaid')` emitted: mermaid splits along its own lazy diagram-type boundaries, all of
|
||||
* which sit inside the generation the phone has already downloaded, so that split moved no bytes
|
||||
* and spent 103 of the 256 manifest assets the shell will load. The native document pays 3,705,846
|
||||
* bytes of engine string instead, in the closure, on every mount.
|
||||
*
|
||||
* The SVG itself: 17,143 bytes on the page against 17,504 in the native document (chromium; webkit
|
||||
* is 8 longer on each side), equal at 15,447 once normalised. The gap is the id string repeated
|
||||
* across 57 selectors, and dropping `xmlns:xlink` is load-bearing rather than cosmetic — the
|
||||
* comparison fails without it.
|
||||
*/
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import * as esbuild from 'esbuild'
|
||||
import { chromium, webkit } from 'playwright-core'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import {
|
||||
createBundleServer,
|
||||
installCspViolationRecorder,
|
||||
installListenerRecorder,
|
||||
readShellCsp
|
||||
} from './mobile-web-app-render-harness.mjs'
|
||||
|
||||
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
|
||||
const diagramDir = join(mobileDir, 'src/components/pr-sidebar')
|
||||
|
||||
/** The design's own fixture, so the byte counts in the docstring above name this diagram. */
|
||||
const FIXTURE =
|
||||
'graph TD\n A[Start] --> B{Choice}\n B -->|yes| C[Ship it]\n B -->|no| D[Fix it]\n D --> A'
|
||||
|
||||
/** A second valid diagram for the source change: a different shape, so a stale SVG is visible. */
|
||||
const SECOND = 'graph LR\n One --> Two\n Two --> Three'
|
||||
|
||||
/**
|
||||
* A script in a label, a `</script>` in a label, an `onerror` attribute and a `javascript:` click.
|
||||
*
|
||||
* The native path escapes `<`, `>` and the line separators because the source is spliced into an
|
||||
* inline `<script>`; on the page it is a JS string argument and that escaping has no analogue, so
|
||||
* the only fence left is mermaid's own strict-mode sanitiser. This is what measures it.
|
||||
*/
|
||||
const HOSTILE =
|
||||
'graph TD\n A["<img src=x onerror=window.__pwned=1><script>window.__pwned=2<\\/script>"] --> B\n' +
|
||||
' B --> C\n click A "javascript:window.__pwned=3"\n' +
|
||||
' C --> D["</script><script>window.__pwned=4</script>"]'
|
||||
|
||||
/** Not a diagram in any grammar mermaid has, so `render` rejects and the component falls back. */
|
||||
const BROKEN = 'graph TD\n A[[[unclosed'
|
||||
|
||||
const ENGINES = [
|
||||
{
|
||||
name: 'chromium',
|
||||
// CI runs this against the runner's Google Chrome rather than paying for a download, the same
|
||||
// override shape as every other render check here.
|
||||
launch: () => {
|
||||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
return chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
|
||||
}
|
||||
},
|
||||
{ name: 'webkit', launch: () => webkit.launch({ headless: true }) }
|
||||
]
|
||||
|
||||
/**
|
||||
* The page under test: the real component, mounted by the real React, with a handle on its props.
|
||||
*
|
||||
* Not a re-implementation of what the component does — the dispose, the fallback and the remount
|
||||
* are the behaviour under test, and a probe that called `mermaid.render` itself would prove
|
||||
* nothing about any of them.
|
||||
*/
|
||||
const PAGE_ENTRY = `
|
||||
import { createElement, useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { MermaidDiagram } from './MermaidDiagram'
|
||||
|
||||
function Harness() {
|
||||
const [state, setState] = useState({ mounted: false, source: '' })
|
||||
useEffect(() => {
|
||||
globalThis.__orcaMermaidSet = setState
|
||||
document.body.setAttribute('data-ready', 'yes')
|
||||
}, [])
|
||||
return state.mounted ? createElement(MermaidDiagram, { source: state.source, base: 15 }) : null
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(createElement(Harness))
|
||||
`
|
||||
|
||||
/**
|
||||
* A stand-in for React, React Native and `react-native-webview`, so the native module can be
|
||||
* bundled for Node to get its HTML.
|
||||
*
|
||||
* `buildHtml` is a pure function of the source and the theme, but it lives beside a component
|
||||
* whose other imports are all native. CommonJS with a Proxy rather than a list of named exports:
|
||||
* what that component reaches for is its own business, and none of it is called here.
|
||||
*/
|
||||
const IMPORT_STUB = `
|
||||
const identity = (value) => value
|
||||
module.exports = new Proxy(
|
||||
{
|
||||
StyleSheet: { create: (styles) => styles, hairlineWidth: 1 },
|
||||
memo: identity,
|
||||
default: identity
|
||||
},
|
||||
{ get: (target, key) => (key in target ? target[key] : identity) }
|
||||
)
|
||||
`
|
||||
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describeMermaid = bundles ? describe : describe.skip
|
||||
|
||||
let scratch = null
|
||||
let server = null
|
||||
let origin = null
|
||||
|
||||
/** The native document's own render of `FIXTURE`, built from `buildHtml` and served as a page. */
|
||||
async function buildNativeDocument(outDir) {
|
||||
const stubPath = join(scratch, 'import-stub.cjs')
|
||||
await writeFile(stubPath, IMPORT_STUB, 'utf8')
|
||||
const nativeHtmlModule = join(scratch, 'native-html.mjs')
|
||||
await esbuild.build({
|
||||
absWorkingDir: mobileDir,
|
||||
stdin: {
|
||||
contents: "export { buildHtml } from './MermaidDiagram'\n",
|
||||
resolveDir: diagramDir,
|
||||
loader: 'ts',
|
||||
sourcefile: 'native-html-entry.ts'
|
||||
},
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
outfile: nativeHtmlModule,
|
||||
target: ['node20'],
|
||||
jsx: 'automatic',
|
||||
logLevel: 'silent',
|
||||
nodePaths: [join(mobileDir, 'node_modules')],
|
||||
// `resolveExtensions` is left at its default here, with no `.web.*`, so `./MermaidDiagram`
|
||||
// resolves to the file the phone builds rather than to the sibling under test.
|
||||
alias: {
|
||||
react: stubPath,
|
||||
'react/jsx-runtime': stubPath,
|
||||
'react-native': stubPath,
|
||||
'react-native-webview': stubPath
|
||||
},
|
||||
define: { __DEV__: 'false', 'process.env.NODE_ENV': '"production"' }
|
||||
})
|
||||
const { buildHtml } = await import(pathToFileURL(nativeHtmlModule).href)
|
||||
await writeFile(join(outDir, 'native.html'), buildHtml(FIXTURE), 'utf8')
|
||||
// The same document for a diagram that throws, because the shared config the page introduced
|
||||
// reaches the phone too and one of its keys changes what mermaid does on that path.
|
||||
await writeFile(join(outDir, 'native-broken.html'), buildHtml(BROKEN), 'utf8')
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!bundles) {
|
||||
return
|
||||
}
|
||||
// Inside mobile/ rather than the system temp dir: the entry resolves the component beside it,
|
||||
// and esbuild resolves a bare specifier from the importer upward.
|
||||
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
|
||||
scratch = await mkdtemp(join(mobileDir, '.tmp', 'mermaid-render-'))
|
||||
const outDir = join(scratch, 'bundle')
|
||||
await mkdir(outDir, { recursive: true })
|
||||
await esbuild.build({
|
||||
absWorkingDir: mobileDir,
|
||||
stdin: {
|
||||
contents: PAGE_ENTRY,
|
||||
resolveDir: diagramDir,
|
||||
loader: 'ts',
|
||||
sourcefile: 'mermaid-check.ts'
|
||||
},
|
||||
bundle: true,
|
||||
// esm with splitting, because `import('mermaid')` has to be a chunk the browser fetches when
|
||||
// the diagram renders. An iife would inline the engine into the entry, which is the one shape
|
||||
// this item exists to avoid.
|
||||
format: 'esm',
|
||||
splitting: true,
|
||||
// Minified, like the bundle the shell serves: the chunk count and the bytes one render fetches
|
||||
// are numbers this file records, and an unminified bundle records neither.
|
||||
minify: true,
|
||||
outdir: outDir,
|
||||
entryNames: 'mermaid-check',
|
||||
chunkNames: 'chunk-[hash]',
|
||||
target: ['es2022'],
|
||||
jsx: 'automatic',
|
||||
logLevel: 'silent',
|
||||
nodePaths: [join(mobileDir, 'node_modules')],
|
||||
alias: { 'react-native': 'react-native-web' },
|
||||
// The web sibling is what the page runs; the native file reaches a WebView that a browser
|
||||
// renders as a line of text.
|
||||
resolveExtensions: ['.web.tsx', '.web.ts', '.web.js', '.tsx', '.ts', '.js'],
|
||||
define: { __DEV__: 'false', 'process.env.NODE_ENV': '"production"' }
|
||||
})
|
||||
await writeFile(
|
||||
join(outDir, 'index.html'),
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div>' +
|
||||
'<script type="module" src="/mermaid-check.js"></script></body></html>'
|
||||
)
|
||||
await buildNativeDocument(outDir)
|
||||
const served = await createBundleServer({ outDir, cspHeader: await readShellCsp() })
|
||||
server = served.server
|
||||
origin = served.origin
|
||||
}, 600_000)
|
||||
|
||||
afterAll(async () => {
|
||||
server?.close()
|
||||
if (scratch) {
|
||||
// This run's directory only: `mobile/.tmp` is a shared ignored root and another suite may hold
|
||||
// one of its own.
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Every `eval` and `new Function` attempted on the page, with the stack that asked for it.
|
||||
*
|
||||
* `script-src 'self'` carries no `'unsafe-eval'`, so a JIT call raises a violation too — but a
|
||||
* library that catches its own `EvalError` and takes a slower path would leave that violation
|
||||
* looking like noise from elsewhere. The stack is what names the caller, and it has to, because
|
||||
* Playwright evaluates every one of this file's own page functions through `eval`: the calls on
|
||||
* this list are mostly the harness's, and only the ones from the bundle's scripts are the page's.
|
||||
*/
|
||||
function installJitRecorder() {
|
||||
globalThis.__orcaJit = []
|
||||
const record = (kind, source) => {
|
||||
// Line 0 is the error's own header and line 1 is this recorder; the rest is whoever asked.
|
||||
const stack = (new Error('jit').stack ?? '').split('\n').slice(2).join(' | ')
|
||||
globalThis.__orcaJit.push({ kind, source: String(source).slice(0, 60), stack })
|
||||
}
|
||||
// oxlint-disable-next-line eslint/no-eval -- SAFETY: the recorder holds the real eval so it can count and forward calls; naming it is this function's whole purpose.
|
||||
const realEval = globalThis.eval
|
||||
// oxlint-disable-next-line eslint/no-eval -- SAFETY: replacing eval with a counting wrapper is the measurement, not a call.
|
||||
globalThis.eval = function (source) {
|
||||
record('eval', source)
|
||||
return realEval.call(globalThis, source)
|
||||
}
|
||||
const RealFunction = globalThis.Function
|
||||
function PatchedFunction(...args) {
|
||||
record('Function', args.map((one) => String(one).slice(0, 40)).join('|'))
|
||||
return RealFunction.apply(this, args)
|
||||
}
|
||||
PatchedFunction.prototype = RealFunction.prototype
|
||||
globalThis.Function = PatchedFunction
|
||||
}
|
||||
|
||||
/**
|
||||
* The JIT calls that came from the bundle rather than from the harness driving it.
|
||||
*
|
||||
* `__orcaJit` being non-empty is the precondition: an attribution filter over a list nothing ever
|
||||
* wrote to answers "none from the page" for a recorder that was never installed.
|
||||
*/
|
||||
async function pageJitCalls(page) {
|
||||
const all = await page.evaluate(() => globalThis.__orcaJit)
|
||||
expect(all.length).toBeGreaterThan(0)
|
||||
return all.filter((one) => /mermaid-check\.js|\/chunk-/.test(one.stack))
|
||||
}
|
||||
|
||||
/** What the component has on the page: its frame, the SVG under it, and every SVG anywhere. */
|
||||
function readDiagram() {
|
||||
const frame = document.querySelector('[data-testid="mermaid-diagram"]')
|
||||
const svg = frame?.querySelector('svg') ?? null
|
||||
return {
|
||||
framed: frame !== null,
|
||||
inFrame: frame ? frame.querySelectorAll('svg').length : -1,
|
||||
// Every SVG in the document, not only the framed one: mermaid renders into a temporary
|
||||
// element of its own, and an orphan left in the body is invisible to a count under the host.
|
||||
inDocument: document.querySelectorAll('svg').length,
|
||||
sourceBox: document.querySelector('[data-testid="mermaid-diagram-source"]') !== null,
|
||||
id: svg?.id ?? null,
|
||||
html: svg?.outerHTML ?? null,
|
||||
text: frame?.textContent ?? null
|
||||
}
|
||||
}
|
||||
|
||||
async function openPage(browser) {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
const consoleErrors = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text())
|
||||
}
|
||||
})
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`))
|
||||
await page.addInitScript(installJitRecorder)
|
||||
await page.addInitScript(installCspViolationRecorder)
|
||||
await page.addInitScript(installListenerRecorder)
|
||||
await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForFunction(() => document.body.dataset.ready === 'yes')
|
||||
return { page, consoleErrors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the harness and waits for the component to settle into a diagram or a fallback.
|
||||
*
|
||||
* `contains` is for a source change, where "an SVG is present" is already true of the diagram being
|
||||
* replaced: naming a label only the new diagram carries is what makes the wait about the new one.
|
||||
*/
|
||||
async function show(page, source, contains = null) {
|
||||
await page.evaluate(
|
||||
(next) => globalThis.__orcaMermaidSet({ mounted: true, source: next }),
|
||||
source
|
||||
)
|
||||
await page.waitForFunction((needle) => {
|
||||
const frame = document.querySelector('[data-testid="mermaid-diagram"]')
|
||||
if (frame === null) {
|
||||
return false
|
||||
}
|
||||
const svg = frame.querySelector('svg')
|
||||
if (needle !== null) {
|
||||
return (svg?.textContent ?? '').includes(needle)
|
||||
}
|
||||
return svg !== null || document.querySelector('[data-testid="mermaid-diagram-source"]') !== null
|
||||
}, contains)
|
||||
}
|
||||
|
||||
async function unmount(page) {
|
||||
await page.evaluate(() => globalThis.__orcaMermaidSet({ mounted: false, source: '' }))
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="mermaid-diagram"]') === null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The two strings, with the only two differences the design measured taken out: the diagram id,
|
||||
* which each host generates its own way, and the `xmlns:xlink` the native serialization adds.
|
||||
*
|
||||
* The id is read off the element rather than matched by a pattern, so a host that changes its id
|
||||
* scheme normalises correctly instead of comparing an unreplaced string.
|
||||
*/
|
||||
function normaliseSvg(html, id) {
|
||||
return html
|
||||
.split(id)
|
||||
.join('ID')
|
||||
.replace(/ xmlns:xlink="[^"]*"/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The native document, loaded in the same browser, with the host it posts to standing in.
|
||||
*
|
||||
* `window.ReactNativeWebView` is what the WebView injects; the document's `post` is a no-op
|
||||
* without it, so the message that drives the component's fallback would be unobservable. Recorded
|
||||
* as a list because the two outcomes are told apart by what it posts: a height, or `error`.
|
||||
*/
|
||||
async function readNativeDocument(browser, file) {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__orcaNativePosts = []
|
||||
globalThis.ReactNativeWebView = {
|
||||
postMessage: (message) => globalThis.__orcaNativePosts.push(String(message))
|
||||
}
|
||||
})
|
||||
await page.goto(`${origin}/${file}`, { waitUntil: 'load' })
|
||||
await page.waitForFunction(() => globalThis.__orcaNativePosts.length > 0, null, {
|
||||
timeout: 120_000
|
||||
})
|
||||
return await page.evaluate(() => {
|
||||
const svg = document.querySelector('#c svg')
|
||||
return {
|
||||
posts: globalThis.__orcaNativePosts,
|
||||
svgs: document.querySelectorAll('svg').length,
|
||||
html: svg?.outerHTML ?? null,
|
||||
id: svg?.id ?? null
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}
|
||||
|
||||
describeMermaid(
|
||||
'mermaid on the page',
|
||||
() => {
|
||||
for (const engine of ENGINES) {
|
||||
describe(engine.name, () => {
|
||||
let browser = null
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await engine.launch()
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
it('paints the diagram the native document paints, with no violation and no JIT', async () => {
|
||||
const { page, consoleErrors } = await openPage(browser)
|
||||
const fetched = []
|
||||
page.on('response', (response) => fetched.push(response.url()))
|
||||
try {
|
||||
await show(page, FIXTURE)
|
||||
const shown = await page.evaluate(readDiagram)
|
||||
expect(shown.inFrame).toBe(1)
|
||||
// The precondition the absences below need: a diagram rendered, and it is mermaid's.
|
||||
expect(shown.html).toContain('aria-roledescription="flowchart-v2"')
|
||||
expect(await pageJitCalls(page)).toEqual([])
|
||||
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
|
||||
expect(consoleErrors).toEqual([])
|
||||
// A CSS identifier, because mermaid writes `#<id>` into the stylesheet it puts inside
|
||||
// the SVG; an id spelled `«r0»` would leave every one of those rules inert.
|
||||
expect(shown.id).toMatch(/^[A-Za-z_][\w-]*$/)
|
||||
// On demand, from here, and in one piece: the engine arrived as exactly one chunk the
|
||||
// render asked for, and nothing was fetched off this origin. The count is the claim —
|
||||
// importing the package rather than the artifact fetched 27 here and emitted 103 in
|
||||
// the app bundle, which is what spends the shell's asset budget.
|
||||
expect(fetched.filter((url) => url.includes('/chunk-'))).toHaveLength(1)
|
||||
expect(fetched.filter((url) => !url.startsWith(origin))).toEqual([])
|
||||
|
||||
const native = await readNativeDocument(browser, 'native.html')
|
||||
expect(normaliseSvg(shown.html, shown.id)).toBe(normaliseSvg(native.html, native.id))
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('leaves the native document reporting a diagram that throws, with nothing drawn', async () => {
|
||||
// The page's shared config reaches the phone as well, and `suppressErrorRendering` is a
|
||||
// key the native path did not have before it. What must not change is that the component
|
||||
// above the WebView still hears about a diagram that throws: `run` rethrows, the
|
||||
// document's own catch posts `error`, and the component swaps in the source box.
|
||||
const broken = await readNativeDocument(browser, 'native-broken.html')
|
||||
expect(broken.posts).toEqual(['error'])
|
||||
// And what the key does change: mermaid draws no error diagram of its own, so the
|
||||
// document is empty behind the fallback rather than showing a diagram for a moment.
|
||||
expect(broken.svgs).toBe(0)
|
||||
|
||||
// The control, the same document for a diagram that parses: a height, not `error`.
|
||||
const rendered = await readNativeDocument(browser, 'native.html')
|
||||
expect(rendered.posts).not.toContain('error')
|
||||
expect(Number(rendered.posts[0])).toBeGreaterThan(0)
|
||||
expect(rendered.svgs).toBe(1)
|
||||
}, 600_000)
|
||||
|
||||
it('leaves one SVG across a source change, an unmount and a remount', async () => {
|
||||
const { page, consoleErrors } = await openPage(browser)
|
||||
const listeners = () => page.evaluate(() => globalThis.__orcaListeners.snapshot())
|
||||
try {
|
||||
const beforeAnyMount = await listeners()
|
||||
await show(page, FIXTURE)
|
||||
const first = await page.evaluate(readDiagram)
|
||||
await unmount(page)
|
||||
// A first mount installs listeners no dispose can take off, and they are not a leak:
|
||||
// mermaid's own `window` `load` (inert under `startOnLoad: false`, and the module's
|
||||
// rather than the mount's) and react-native-web's responder system, which arms itself
|
||||
// on the first `View` the page renders. So the baseline a per-mount leak would move is
|
||||
// the snapshot after one whole cycle, not the one before it — with mermaid's named,
|
||||
// because a cycle that installed nothing would make the comparison below vacuous.
|
||||
const afterEngineLoaded = await listeners()
|
||||
expect(
|
||||
Object.keys(afterEngineLoaded).filter((key) => !(key in beforeAnyMount))
|
||||
).toContain('window load')
|
||||
|
||||
await show(page, FIXTURE)
|
||||
await show(page, SECOND, 'Three')
|
||||
const changed = await page.evaluate(readDiagram)
|
||||
// One in the frame and one in the document: a diagram the first source left behind
|
||||
// would be the second, wherever it hung.
|
||||
expect(changed.inFrame).toBe(1)
|
||||
expect(changed.inDocument).toBe(1)
|
||||
expect(changed.html).not.toBe(first.html)
|
||||
|
||||
await unmount(page)
|
||||
const gone = await page.evaluate(readDiagram)
|
||||
expect(gone.framed).toBe(false)
|
||||
expect(gone.inDocument).toBe(0)
|
||||
// Two mounts and a source change later, the page is listening to exactly what it was
|
||||
// after the first of them. A mount that registered anything of its own would show up
|
||||
// here as the third.
|
||||
expect(await listeners()).toEqual(afterEngineLoaded)
|
||||
|
||||
await show(page, FIXTURE)
|
||||
const again = await page.evaluate(readDiagram)
|
||||
expect(again.inFrame).toBe(1)
|
||||
expect(again.inDocument).toBe(1)
|
||||
expect(normaliseSvg(again.html, again.id)).toBe(normaliseSvg(first.html, first.id))
|
||||
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
|
||||
expect(consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('renders a hostile diagram inert', async () => {
|
||||
const { page } = await openPage(browser)
|
||||
try {
|
||||
await show(page, HOSTILE)
|
||||
const inert = await page.evaluate(() => {
|
||||
const frame = document.querySelector('[data-testid="mermaid-diagram"]')
|
||||
return {
|
||||
rendered: frame?.querySelector('svg') !== null,
|
||||
scripts: frame.querySelectorAll('script').length,
|
||||
inlineHandlers: [...frame.querySelectorAll('*')].filter((element) =>
|
||||
[...element.attributes].some((attribute) => attribute.name.startsWith('on'))
|
||||
).length,
|
||||
javascriptHrefs: [...frame.querySelectorAll('[*|href]')]
|
||||
.map(
|
||||
(element) =>
|
||||
element.getAttribute('href') ?? element.getAttribute('xlink:href') ?? ''
|
||||
)
|
||||
.filter((href) => href.toLowerCase().startsWith('javascript:')).length,
|
||||
pwned: globalThis.__pwned ?? null
|
||||
}
|
||||
})
|
||||
// Rendered rather than refused, which is the whole point: the payload is carried into
|
||||
// the document as data and does nothing there.
|
||||
expect(inert.rendered).toBe(true)
|
||||
expect(inert.scripts).toBe(0)
|
||||
expect(inert.inlineHandlers).toBe(0)
|
||||
expect(inert.javascriptHrefs).toBe(0)
|
||||
expect(inert.pwned).toBeNull()
|
||||
expect(await pageJitCalls(page)).toEqual([])
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('falls back to the source when the diagram throws, and recovers from it', async () => {
|
||||
const { page } = await openPage(browser)
|
||||
try {
|
||||
await show(page, BROKEN)
|
||||
const failed = await page.evaluate(readDiagram)
|
||||
expect(failed.sourceBox).toBe(true)
|
||||
expect(failed.inFrame).toBe(0)
|
||||
expect(failed.text).toContain('unclosed')
|
||||
// The fallback is a state of this component, not a page with a diagram left on it:
|
||||
// mermaid draws its own error diagram unless it is told not to.
|
||||
expect(failed.inDocument).toBe(0)
|
||||
|
||||
// The control: the same component, the same mount, a diagram that parses. Named,
|
||||
// because the fallback it is replacing already satisfies a bare settle.
|
||||
await show(page, FIXTURE, 'Ship it')
|
||||
const recovered = await page.evaluate(readDiagram)
|
||||
expect(recovered.sourceBox).toBe(false)
|
||||
expect(recovered.inFrame).toBe(1)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
})
|
||||
}
|
||||
},
|
||||
3_600_000
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
import { entryStaticClosure, mobileWebAppBuildOptions } from './build-mobile-web-app-bundle.mjs'
|
||||
import { collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs'
|
||||
|
||||
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
|
||||
|
||||
/**
|
||||
* What a browser must download before one page route can paint, and what it may defer.
|
||||
*
|
||||
* `mobileWebAppRouteClosure` answers a different question: it reads `metafile.inputs`, which holds
|
||||
* every module an entry reaches including the ones behind `import()`, so it cannot say "on
|
||||
* demand" about anything (ruling 28). This walks the emitted chunks instead, from the output the
|
||||
* route's own module landed in, and follows only `import-statement` edges — which is exactly where
|
||||
* a dynamic import stops being part of the download.
|
||||
*
|
||||
* Both halves come back, because the interesting claim is always a difference: `staticInputs` is
|
||||
* what the route costs to open, `deferredInputs` is everything else the bundle emitted, and a
|
||||
* module absent from the first is only meaningful while it is present in the second.
|
||||
*/
|
||||
export async function mobileWebAppRouteChunkClosure(routeModule) {
|
||||
const routes = await collectMobileWebAppRoutes(join(mobileDir, 'app'))
|
||||
const { metafile } = await esbuild.build({
|
||||
...mobileWebAppBuildOptions(routes),
|
||||
metafile: true,
|
||||
write: false
|
||||
})
|
||||
const routePath = resolve(mobileDir, routeModule)
|
||||
const owner = Object.entries(metafile.outputs).find(([, output]) =>
|
||||
Object.keys(output.inputs ?? {}).some((input) => resolve(mobileDir, input) === routePath)
|
||||
)
|
||||
if (!owner) {
|
||||
throw new Error(`[mobile-web-app-route-chunk-closure] ${routeModule} reached no output`)
|
||||
}
|
||||
const reached = entryStaticClosure(metafile, owner[0])
|
||||
const inputsOf = (outputs) =>
|
||||
outputs.flatMap((output) => Object.keys(metafile.outputs[output]?.inputs ?? {}))
|
||||
const every = Object.keys(metafile.outputs).filter((output) => output.endsWith('.js'))
|
||||
return {
|
||||
routeChunk: basename(owner[0]),
|
||||
staticChunks: [...reached].map((output) => basename(output)),
|
||||
staticInputs: inputsOf([...reached]),
|
||||
deferredInputs: inputsOf(every.filter((output) => !reached.has(output)))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import { mobileWebAppRouteChunkClosure } from './mobile-web-app-route-chunk-closure.mjs'
|
||||
import {
|
||||
textInputFontSizeOffenders,
|
||||
unresolvedTextInputStyles
|
||||
@@ -16,30 +17,41 @@ import {
|
||||
* the terminal is by far the largest thing in it. Measured here so the trade is a number rather
|
||||
* than a claim, and so that a later change cannot quietly put the engine string back.
|
||||
*
|
||||
* Measured against `origin/main` at ec82173130, which is C7.5 as it landed. The reading is
|
||||
* re-anchored rather than adjusted: the base this note used to name is far enough back that main
|
||||
* has moved 44,296 bytes below its number through changes that are not this lane's.
|
||||
* Re-anchored twice. C7.5b re-measured on its merge of main at 35005fb65c9, because the two sides
|
||||
* of that merge do not add up: it read -40 where main's own mermaid reading below reads +3, and the
|
||||
* merged total came out one above their sum. C7.5c then merged that branch, and the reading moves
|
||||
* again, because ruling 25 changes which files carry the document.
|
||||
*
|
||||
* modules 4320 -> 4322 (+2)
|
||||
* local modules 970 -> 972 (+2)
|
||||
* minified bytes 3,768,122 -> 3,764,937 (-3,185)
|
||||
* modules 4284 -> 4326 (+42)
|
||||
* local modules 934 -> 976 (+42)
|
||||
*
|
||||
* That byte figure is the lane's own, measured at the commit before main was merged in. This head
|
||||
* reads 3,765,180: the 243 between them are the two touch-root predicates and main's own #21687
|
||||
* momentum change, which arrived with e476193bf5 and are not this lane's to claim either way.
|
||||
* Both sides read with `mobileWebAppRouteClosure(SESSION_ROUTE)` and the four postinstall
|
||||
* generators run first, C7.5b's in a scratch worktree detached at 7ceb633df0, where it reproduces
|
||||
* its own 4,284 exactly.
|
||||
*
|
||||
* What moved is which files carry the document, not whether the page carries it. C7.5 already put
|
||||
* the document's own source modules in this closure and started them per mount, and ruling 25 keeps
|
||||
* them there: the page imports ordinary TypeScript and calls it, and nothing generated is in the
|
||||
* reading at all — the bundle the phone loads is built from the same modules and is not imported
|
||||
* here.
|
||||
* The +42 is 43 modules in and one out, and the one out is the whole of it: C7.5b's page imported
|
||||
* `terminal-webview-document-factory.generated.ts`, one emitted file carrying the entire document.
|
||||
* Ruling 25 deletes it, so the page imports the document's 39 source modules directly and reaches,
|
||||
* through them, the three page modules the tap group reads — `terminal-webview-url-tap`,
|
||||
* `terminal-path-tap` and `terminal-file-url-tap` — which the generator used to substitute as
|
||||
* literals. The 43rd is `terminal-text-scales`, the leaf the presets moved to so that the WebView's
|
||||
* own bundle cannot reach `storage/preferences` and the AsyncStorage import behind it.
|
||||
*
|
||||
* The +2 is four modules in and two out. In: `create-terminal-document` holds the start and stop
|
||||
* sequence, `document-frame-registry` holds the frames, `escape-introducers` holds the two control
|
||||
* bytes, and `terminal-text-scales` holds the presets both the document and `storage/preferences`
|
||||
* read — a leaf, because the document is bundled for the WebView and must not reach that module's
|
||||
* AsyncStorage import. Out: `document-constants`, which existed because a generated string cannot
|
||||
* import, and `runtime-constants`, whose one element read is the first line of `startSurfaceSwap`.
|
||||
* Nothing generated is in this reading at all now. The phone's script is built from these same
|
||||
* modules and is not imported here, which is what `SHED` holds.
|
||||
*
|
||||
* One module inside the 4,284 belongs to neither branch: `src/mobile-web-shell/bridge/
|
||||
* bridge-haptics-notify.ts`, which `haptics.web.ts` reaches. C7.10 item E (#21864) and mermaid
|
||||
* (#21871) were each green against a main that lacked the other, so main held 4323 while measuring
|
||||
* 4324, and #21908 re-pinned it there. Which is the point of re-measuring rather than summing: a
|
||||
* merged number arrived at as -40 plus +3 would have read 4283 and been wrong about a module
|
||||
* neither side of the merge moved.
|
||||
*
|
||||
* The byte reading is not re-measured here and stays where it was taken, against main at
|
||||
* ec82173130: 3,768,122 -> 3,766,312 minified (-1,810). `mobileWebAppRouteClosure` reads
|
||||
* `metafile.inputs` and returns no byte total, so a figure produced here would be a different
|
||||
* computation rather than a newer reading of that one. This lane's own byte figure, measured the
|
||||
* way it took it, is 3,764,937 before its merges and 3,765,180 after them.
|
||||
*
|
||||
* The bytes fall because threading the scope deletes a closure: every function names its state as a
|
||||
* parameter, and a parameter minifies to one character where a shared module-level object could not.
|
||||
@@ -50,6 +62,38 @@ import {
|
||||
* xterm was already a static import of the mount before this, so nothing here is xterm arriving: it
|
||||
* and its two addons are 607,945 bytes minified ESM on their own, and they are on both sides of the
|
||||
* reading above.
|
||||
*
|
||||
* Two earlier readings of the same measurement, against the bases this branch sat on before:
|
||||
* -47,255 at 51ae7b1b03 and -55,561 at 0ce0fc99a2. They differ because C7.1's own round-1 fold
|
||||
* deleted `URL_TAP_WEBVIEW_JS` from a module only the page's component brings into this closure,
|
||||
* so the saving lands on the after side and no base can show it.
|
||||
*
|
||||
* Then C7.10 item B put mermaid on the page, and the module list moved again. Its own reading, at
|
||||
* the base it was taken against:
|
||||
*
|
||||
* modules 4320 -> 4323 (+3)
|
||||
* local modules 970 -> 973 (+3)
|
||||
*
|
||||
* Three modules: the configuration both hosts read, the loader, and the pre-bundled engine the
|
||||
* loader imports on demand. The engine's own 66 files and the d3, dagre, katex and cytoscape trees
|
||||
* under them are inside that one artifact rather than in this graph, which is why the count barely
|
||||
* moves. Importing the package here instead read +2,056 and emitted 103 scripts, a package
|
||||
* splitting along its own lazy diagram-type boundaries -- every one of them inside the OTA generation
|
||||
* the phone had already downloaded, so the split moved no bytes and spent 103 of the 256 manifest
|
||||
* assets the shell will load. One artifact costs one script and one module.
|
||||
*
|
||||
* What the generation weighs, because every chunk ships in it whether or not a phone ever fetches
|
||||
* one: the built bundle is 8,016,714 bytes across 112 assets, against the 9 MiB ceiling in
|
||||
* `verify-mobile-web-app-bundle.mjs`. That is 84.9% of it, with 1,420,470 bytes left for the rest
|
||||
* of C7.10 and for C7.7. Before item B the same bundle was 4,539,090 bytes, and the engine is the
|
||||
* difference -- deferring it defers evaluation and a fetch, never the download.
|
||||
*
|
||||
* `mobileWebAppRouteClosure` reads `metafile.inputs`, which holds dynamically imported modules
|
||||
* under `splitting: true` just as it does under `splitting: false`, so it cannot express "on
|
||||
* demand" about anything. Ruling 28: the fence for this route is `entryStaticClosure`, which
|
||||
* follows `import-statement` edges only, and the module list's total is a recorded number rather
|
||||
* than a budget. It moves whenever main adds a module this route reaches, and is re-recorded rather
|
||||
* than argued with.
|
||||
*/
|
||||
|
||||
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
@@ -93,6 +137,20 @@ const XTERM_PACKAGES = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-
|
||||
*/
|
||||
const EXPECTED_OFFENDERS = 0
|
||||
|
||||
/** The deferred engine, as the page reaches it: one artifact, not the package's own file tree. */
|
||||
const MERMAID_PAGE_ENGINE = 'src/components/pr-sidebar/mermaid-page-engine.generated.ts'
|
||||
const MERMAID_PACKAGE = 'node_modules/mermaid/'
|
||||
|
||||
/**
|
||||
* The module list on the merge, recorded at the base in the docstring above, which is where
|
||||
* everything inside it is accounted for: the document's own modules replacing the factory that
|
||||
* carried them, mermaid's three, and the haptics notify module #21908 pins on main.
|
||||
*/
|
||||
const SESSION_ROUTE_MODULES = 4326
|
||||
|
||||
const artifactModules = (inputs) => inputs.filter((input) => input.includes(MERMAID_PAGE_ENGINE))
|
||||
const packageModules = (inputs) => inputs.filter((input) => input.includes(MERMAID_PACKAGE))
|
||||
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describeClosure = bundles ? describe : describe.skip
|
||||
|
||||
@@ -126,6 +184,27 @@ describeClosure(
|
||||
expect(local).not.toContain('src/terminal/terminal-webview-document-script.generated.ts')
|
||||
}, 300_000)
|
||||
|
||||
it('reaches the engine as one deferred module and never as part of the download', async () => {
|
||||
const { modules } = await mobileWebAppRouteClosure(SESSION_ROUTE)
|
||||
// The engine is here, as the one artifact the loader imports.
|
||||
expect(artifactModules(modules)).toHaveLength(1)
|
||||
// And the package's own file tree is not, anywhere: it is inside that artifact. Meaningful
|
||||
// only beside the line above, which is why the two sit together.
|
||||
expect(packageModules(modules)).toEqual([])
|
||||
expect(modules).toHaveLength(SESSION_ROUTE_MODULES)
|
||||
|
||||
const download = await mobileWebAppRouteChunkClosure(SESSION_ROUTE)
|
||||
// The fence: nothing of the engine is reachable from the route's own chunk by an import
|
||||
// statement, so opening the session pays none of it.
|
||||
expect(artifactModules(download.staticInputs)).toEqual([])
|
||||
// The precondition that absence needs. The artifact is in the bundle, in a chunk the route
|
||||
// reaches by a `dynamic-import` edge instead -- a deferred engine, not a dropped one.
|
||||
expect(artifactModules(download.deferredInputs)).toHaveLength(1)
|
||||
// And the walk read a real download rather than one chunk: the route's own chunk is in it.
|
||||
expect(download.staticChunks).toContain(download.routeChunk)
|
||||
expect(download.staticInputs.length).toBeGreaterThan(1000)
|
||||
}, 600_000)
|
||||
|
||||
it('leaves the 16px seam census exactly where C7.2 left it', async () => {
|
||||
const closure = await mobileWebAppRouteClosure(SESSION_ROUTE)
|
||||
// Two preconditions, because zero offenders is what a walk that read nothing also reports:
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
* this closure reaches that package from nowhere at all.
|
||||
*
|
||||
* An empty list is also what a scan that read nothing reports, so the control below no longer
|
||||
* uses the list — it runs the same walk over three native modules that do import the package and
|
||||
* over the three web siblings that replace them.
|
||||
* uses the list — it runs the same walk over the four native modules that do import the package and
|
||||
* over the four web siblings that replace them. The diagram is the fourth: its native component
|
||||
* seals untrusted source in a `WebView` and its sibling renders the same diagram in the document
|
||||
* (C7.10 item B), which is the same substitution the other three are.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
@@ -29,17 +31,19 @@ const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx'
|
||||
/** Nothing: every consumer this closure had now resolves to a web sibling that needs no WebView. */
|
||||
const REMAINING = []
|
||||
|
||||
/** The three answered, whose `.web.tsx` the builder resolves instead of the native file. */
|
||||
/** The four answered, whose `.web.tsx` the builder resolves instead of the native file. */
|
||||
const ANSWERED = [
|
||||
'src/components/MobileRichMarkdownEditor.web.tsx',
|
||||
'src/components/MobileHtmlPreview.web.tsx',
|
||||
'src/components/pr-sidebar/MermaidDiagram.web.tsx',
|
||||
'src/terminal/TerminalWebView.web.tsx'
|
||||
]
|
||||
|
||||
/** The native files behind those three, which do import the package. The scan's own control. */
|
||||
/** The native files behind those four, which do import the package. The scan's own control. */
|
||||
const NATIVE_CONSUMERS = [
|
||||
'src/components/MobileRichMarkdownEditor.tsx',
|
||||
'src/components/MobileHtmlPreview.tsx',
|
||||
'src/components/pr-sidebar/MermaidDiagram.tsx',
|
||||
'src/terminal/TerminalWebView.tsx'
|
||||
]
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const SHELL_HOST = {
|
||||
const UNMATCHED = 'Unmatched Route'
|
||||
const ROUTE_KEY = './h/[hostId]/tasks.tsx'
|
||||
/** Exactly what the route declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */
|
||||
const TASKS_GRANTS = ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
|
||||
const TASKS_GRANTS = ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
|
||||
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describeRender = bundles ? describe : describe.skip
|
||||
|
||||
@@ -12,22 +12,30 @@
|
||||
*
|
||||
* Declared here rather than in src/shared because the builder is the only thing that reads it: the
|
||||
* shape it must satisfy is MobileWebBundleRouteSchema, which the manifest write is checked against.
|
||||
*
|
||||
* `haptics` is on every entry below, and by measurement rather than by habit: the shared worktree
|
||||
* row is in all five closures and calls the seam, so a route without the grant is a page whose taps
|
||||
* stop buzzing. mobile-web-app-haptics-seam.test.mjs derives that list from the closures and fails
|
||||
* on a route that imports the seam and declares nothing.
|
||||
*/
|
||||
export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
// The worktree list. `navigate` because every row opens a session screen that is still native.
|
||||
// `storage` because its pins and its last-visited repo are the app's, not the document's.
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'haptics'] },
|
||||
// Agent session history. `navigate` because a resumed session opens the session screen, which is
|
||||
// native, and because the list above now reaches this one without leaving the page. `storage`
|
||||
// because the host layout above every page route reads the app's own sidebar width.
|
||||
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
|
||||
{
|
||||
pathname: '/h/[hostId]/agent-history/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'haptics']
|
||||
},
|
||||
// Tasks. `navigate` for the session screens its rows open and for the Back that pops the native
|
||||
// stack; `storage` for the shared components it renders; `externalLink` for the provider links
|
||||
// in its items, checks and drawers; `native.clipboard.write` for the two copy actions in its
|
||||
// comment review. Grants are scoped per route, so naming fewer here serves fewer.
|
||||
{
|
||||
pathname: '/h/[hostId]/tasks',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
|
||||
},
|
||||
// The file explorer. `navigate` because its Back pops the native stack. `storage` for the shared
|
||||
// components the host layout renders above it.
|
||||
@@ -54,7 +62,7 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
// the session's — in its own PR.
|
||||
{
|
||||
pathname: '/h/[hostId]/files/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'externalLink']
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
},
|
||||
// The file preview. Same three. `externalLink` is this route's own rather than inherited: a
|
||||
// Markdown preview renders links and `MobileMarkdown` opens them through the platform seam, which
|
||||
@@ -63,6 +71,6 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
// the reasons are not.
|
||||
{
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'externalLink']
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -40,10 +40,22 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* How many scripts the page may be cut into, for a given number of routes. A chunk is emitted per
|
||||
* distinct set of importers rather than per route, so the count is combinatorial in what the
|
||||
* routes share: 8 routes measure 23 chunks, 10 measure 40, 12 measure 47, 14 measure 53, about
|
||||
* three more per route at the top. Four per route with a flat 16 leaves the next few routes room,
|
||||
* so a route added in C2 fails on its own weight and not on a number measured before it existed.
|
||||
* distinct set of importers rather than per route, so this is not a function of the route count
|
||||
* alone — it depends on what the routes in the tree happen to share. Measured on the head that
|
||||
* wrote this, dropping routes from the end of the sorted key list: 8 routes emit 32 scripts, 10
|
||||
* emit 43, 12 emit 61, 14 emit 69. That is between four and nine more per route depending on which
|
||||
* route, so four per route with a flat 16 is a bound rather than a fit.
|
||||
*
|
||||
* Read the headroom before adding a route: 14 routes measure 69 against this ceiling's 72, and the
|
||||
* last two of them cost 8 — exactly the 8 the ceiling grants for two. The fence is at break-even,
|
||||
* so the next route that shares less than its neighbours breaches it. That is the failure it is
|
||||
* for; it names the split, where the asset count alone would name nothing.
|
||||
*
|
||||
* The route count is the only term, deliberately. A deferred engine belongs inside one artifact and
|
||||
* costs one script: C7.10 item B first reached mermaid with `import('mermaid')`, which emitted 103
|
||||
* more because mermaid lazily imports each of its own diagram types, and a second term admitting
|
||||
* those would have raised this fence far enough to admit any split at all. The build test's control
|
||||
* is what holds that line.
|
||||
*
|
||||
* This is the ceiling that catches a split running away; MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES
|
||||
* below is the one that catches it collapsing, and it is the real budget of the two.
|
||||
|
||||
@@ -3,6 +3,7 @@ src/terminal/terminal-webview-engine.generated.ts
|
||||
src/terminal/terminal-webview-engine-css.generated.ts
|
||||
src/terminal/terminal-webview-document-script.generated.ts
|
||||
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
|
||||
src/components/pr-sidebar/mermaid-page-engine.generated.ts
|
||||
.expo/
|
||||
dist/
|
||||
/android/
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"],
|
||||
"ignorePatterns": [
|
||||
"src/terminal/terminal-webview-engine.generated.ts",
|
||||
"src/components/pr-sidebar/mermaid-page-engine.generated.ts"
|
||||
],
|
||||
"rules": {
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"start": "node scripts/start-expo.mjs",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-terminal-document-script.mjs",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-mermaid-page-engine.mjs && node scripts/build-terminal-document-script.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 0480a96d718c997b0a5a54b775e635e2e048e796..85f71b13d3f0f31c3c2140581d1e4051afb21dc3 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as queryString from './base.js';
|
||||
|
||||
export default queryString;
|
||||
+
|
||||
+export * from './base.js';
|
||||
Generated
+6
-3
@@ -12,6 +12,9 @@ patchedDependencies:
|
||||
expo-notifications@55.0.27:
|
||||
hash: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0
|
||||
path: patches/expo-notifications@55.0.27.patch
|
||||
query-string@9.5.1:
|
||||
hash: 8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b
|
||||
path: patches/query-string@9.5.1.patch
|
||||
react-native-webview@13.16.2:
|
||||
hash: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27
|
||||
path: patches/react-native-webview@13.16.2.patch
|
||||
@@ -9966,7 +9969,7 @@ snapshots:
|
||||
escape-string-regexp: 4.0.0
|
||||
fast-deep-equal: 3.1.3
|
||||
nanoid: 3.3.18
|
||||
query-string: 9.5.1
|
||||
query-string: 9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b)
|
||||
react: 19.2.8
|
||||
react-is: 19.2.6
|
||||
use-latest-callback: 0.2.6(react@19.2.8)
|
||||
@@ -12230,7 +12233,7 @@ snapshots:
|
||||
fast-deep-equal: 3.1.3
|
||||
invariant: 2.2.4
|
||||
nanoid: 3.3.18
|
||||
query-string: 9.5.1
|
||||
query-string: 9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b)
|
||||
react: 19.2.8
|
||||
react-fast-compare: 3.2.2
|
||||
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
|
||||
@@ -14283,7 +14286,7 @@ snapshots:
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
query-string@9.5.1:
|
||||
query-string@9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b):
|
||||
dependencies:
|
||||
decode-uri-component: 0.5.0
|
||||
filter-obj: 5.1.0
|
||||
|
||||
@@ -9,5 +9,6 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch
|
||||
query-string@9.5.1: patches/query-string@9.5.1.patch
|
||||
react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch
|
||||
react-native@0.83.10: patches/react-native@0.83.10.patch
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const mobileRoot = path.resolve(import.meta.dirname, '..')
|
||||
const outputPath = path.join(
|
||||
mobileRoot,
|
||||
'src',
|
||||
'components',
|
||||
'pr-sidebar',
|
||||
'mermaid-page-engine.generated.ts'
|
||||
)
|
||||
|
||||
// Why: the page imports mermaid on demand, and `import('mermaid')` from inside the app bundle lands
|
||||
// 103 emitted scripts rather than one -- mermaid lazily imports each of its own diagram types, so
|
||||
// esbuild splits along those boundaries. All 103 sit inside the OTA generation the phone has
|
||||
// already downloaded, so the split buys nothing in transfer and spends 103 of the 256 manifest
|
||||
// assets the shell will load. Bundled here into one artifact, which the page imports on demand
|
||||
// exactly as it imported the package.
|
||||
//
|
||||
// Unlike the sibling that builds the WebView engine, this is not a string: it is the module the
|
||||
// page evaluates, so it is emitted as source and `mermaid-page-engine.ts` is what types it.
|
||||
async function main() {
|
||||
const { version } = require(require.resolve('mermaid/package.json'))
|
||||
const { outputFiles } = await esbuild.build({
|
||||
absWorkingDir: mobileRoot,
|
||||
stdin: {
|
||||
contents: "export { default } from 'mermaid'\n",
|
||||
resolveDir: mobileRoot,
|
||||
loader: 'ts',
|
||||
sourcefile: 'mermaid-page-engine-entry.ts'
|
||||
},
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
// The whole point: one file, so the app bundle emits one deferred chunk for it.
|
||||
splitting: false,
|
||||
minify: true,
|
||||
// The floor the shell's WebViews hold, the same pair the terminal engine is built for.
|
||||
target: ['chrome74', 'safari15'],
|
||||
write: false,
|
||||
charset: 'utf8',
|
||||
legalComments: 'none',
|
||||
logLevel: 'silent',
|
||||
nodePaths: [path.join(mobileRoot, 'node_modules')],
|
||||
define: { 'process.env.NODE_ENV': '"production"' }
|
||||
})
|
||||
const bundle = Buffer.from(outputFiles[0].contents).toString('utf8')
|
||||
const source = [
|
||||
'// Generated by scripts/build-mermaid-page-engine.mjs.',
|
||||
`// Package: mermaid@${version}, bundled as one ESM module for the page.`,
|
||||
'// Do not edit by hand; regenerate via pnpm postinstall.',
|
||||
'// @ts-nocheck -- minified vendor output; mermaid-page-engine.ts is where this is typed.',
|
||||
bundle,
|
||||
''
|
||||
].join('\n')
|
||||
await writeFile(outputPath, source)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -2,6 +2,7 @@ import { memo, useMemo, useState } from 'react'
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
import { MERMAID_DIAGRAM_CONFIG } from './mermaid-diagram-config'
|
||||
import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated'
|
||||
|
||||
export type MermaidDiagramProps = {
|
||||
@@ -79,19 +80,31 @@ function MermaidFallback({ source, base }: MermaidDiagramProps) {
|
||||
}
|
||||
|
||||
// JSON.stringify escapes quotes and control chars but leaves `<`, `>`, `&`, and
|
||||
// the U+2028/U+2029 line separators raw — so a source containing `</script>`
|
||||
// would close this inline <script> and let the rest execute as markup. Diagram
|
||||
// source is untrusted (agent output, PR/chat content), so escape those to \uXXXX;
|
||||
// the literal still parses back to the exact original string inside the WebView.
|
||||
function encodeSourceForScript(source: string): string {
|
||||
return JSON.stringify(source).replace(
|
||||
// the U+2028/U+2029 line separators raw — so a value containing `</script>` would
|
||||
// close the inline <script> this is spliced into and let the rest execute as
|
||||
// markup. These characters only ever appear inside JSON string literals, so
|
||||
// escaping them to \uXXXX is always valid and always parses back to the exact
|
||||
// original text inside the WebView.
|
||||
function encodeJsonForScript(json: string): string {
|
||||
return json.replace(
|
||||
/[<>&\u2028\u2029]/g,
|
||||
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Diagram source is untrusted: agent output, PR and chat content.
|
||||
function encodeSourceForScript(source: string): string {
|
||||
return encodeJsonForScript(JSON.stringify(source))
|
||||
}
|
||||
|
||||
// The config is not untrusted, but it is not a closed set of hex colours either: a
|
||||
// themeCSS or a font stack is free text, and it goes into the same script element.
|
||||
function encodeConfigForScript(): string {
|
||||
return encodeJsonForScript(JSON.stringify(MERMAID_DIAGRAM_CONFIG))
|
||||
}
|
||||
|
||||
// Self-contained HTML: embedded mermaid bundle, render the graph, post the body
|
||||
// height (or "error") back to RN. Theme variables match the dark sidebar palette.
|
||||
// height (or "error") back to RN. The configuration is the one the page runs too.
|
||||
export function buildHtml(source: string): string {
|
||||
const encoded = encodeSourceForScript(source)
|
||||
return `<!DOCTYPE html>
|
||||
@@ -117,19 +130,7 @@ export function buildHtml(source: string): string {
|
||||
}
|
||||
try {
|
||||
document.querySelector('.mermaid').textContent = ${encoded};
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'dark',
|
||||
securityLevel: 'strict',
|
||||
darkMode: true,
|
||||
themeVariables: {
|
||||
background: '${colors.bgRaised}',
|
||||
primaryColor: '${colors.bgPanel}',
|
||||
primaryTextColor: '${colors.textPrimary}',
|
||||
lineColor: '${colors.textSecondary}',
|
||||
textColor: '${colors.textPrimary}'
|
||||
}
|
||||
});
|
||||
mermaid.initialize(${encodeConfigForScript()});
|
||||
mermaid.run({ querySelector: '.mermaid' })
|
||||
.then(function () { reportHeight(); })
|
||||
.catch(function () { post('error'); });
|
||||
|
||||
@@ -1,39 +1,113 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
// The native component's own prop type, so a change to it fails here rather than drifting.
|
||||
import type { MermaidDiagramProps } from './MermaidDiagram'
|
||||
import { MERMAID_DIAGRAM_CONFIG } from './mermaid-diagram-config'
|
||||
import { loadPageMermaid } from './mermaid-page-engine'
|
||||
|
||||
/**
|
||||
* Web sibling: the labelled source box, which is what the native component already falls back to.
|
||||
* Web sibling: the same diagram, drawn by mermaid in this document.
|
||||
*
|
||||
* The native one renders the diagram inside a sandboxed `WebView`, and `react-native-webview` is a
|
||||
* native component with no browser counterpart — importing it runs a codegen lookup that throws,
|
||||
* and the route manifest imports every route, so one such import takes the whole page down rather
|
||||
* than one diagram.
|
||||
* The native component seals the source inside a `WebView` whose document embeds the whole engine
|
||||
* as a string, because `react-native-webview` has no browser counterpart and mermaid has no native
|
||||
* renderer. On the page neither half of that applies: mermaid is a browser library, so it is an
|
||||
* `import()` rather than a 3.7 MB literal, and there is no second content process to sandbox in.
|
||||
*
|
||||
* A real web renderer is reachable — mermaid is a browser library and the engine bundle is already
|
||||
* vendored — but it is a different shape from the native path, not a smaller one: no WebView to
|
||||
* sandbox untrusted source in, so the escaping the native `buildHtml` does for `</script>` and the
|
||||
* line separators would have to be replaced by whatever the DOM path needs. That is its own change
|
||||
* with its own proof, so this series ships the degradation the component already defines and says
|
||||
* so, rather than a second renderer nobody has tested against hostile diagram source.
|
||||
* What replaces the sandbox is mermaid's own `securityLevel: 'strict'`, which runs the serialized
|
||||
* SVG through DOMPurify before handing it back — a `<script>`, an `on*` attribute or a
|
||||
* `javascript:` href in a diagram label reaches this document as nothing at all. That is measured
|
||||
* in `config/scripts/mobile-web-app-mermaid-render.test.mjs`, in both engines, against a hostile
|
||||
* fixture, and so is the byte equality of the result with the native document's own render. The
|
||||
* native path's `</script>` escaping has no analogue here and does not need one: the source is a
|
||||
* JS string argument, not text spliced into an inline `<script>`.
|
||||
*
|
||||
* The import is inside the effect, so a session with no diagram in it evaluates none of the engine
|
||||
* (ruling 28). It reaches the engine through `mermaid-page-engine.ts`, which loads one pre-bundled
|
||||
* artifact rather than the package: importing the package here emitted 103 scripts, all of them
|
||||
* already inside the generation the phone downloaded.
|
||||
*/
|
||||
export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: MermaidDiagramProps) {
|
||||
const hostRef = useRef<View>(null)
|
||||
// The source that failed, rather than a flag: a flag would need clearing from the effect that
|
||||
// renders the next one, and a render the component has already failed is the only thing the
|
||||
// fallback is about.
|
||||
const [failedSource, setFailedSource] = useState<string | null>(null)
|
||||
// mermaid writes `#<id>` into the stylesheet it puts inside the SVG, so this has to be a CSS
|
||||
// identifier. React spells its own `_R_0_`; the strip is for a React that changes that.
|
||||
const suffix = useId().replace(/[^\w-]/g, '')
|
||||
const id = `orca-mermaid-${suffix}`
|
||||
const failed = failedSource === source
|
||||
|
||||
useEffect(() => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: react-native-web renders View as a div and forwards the ref to it; this module only ever runs in that build.
|
||||
const host = hostRef.current as unknown as HTMLElement | null
|
||||
if (!host) {
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
void (async () => {
|
||||
try {
|
||||
const mermaid = await loadPageMermaid()
|
||||
mermaid.initialize(MERMAID_DIAGRAM_CONFIG)
|
||||
const { svg } = await mermaid.render(id, source)
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Already sanitized: `securityLevel: 'strict'` is what makes this string safe to parse,
|
||||
// and mermaid is the only thing that can sanitize its own serialization.
|
||||
host.innerHTML = svg
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setFailedSource(source)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
// React owns this element, not what mermaid put inside it, so nothing else clears the
|
||||
// previous diagram when the source changes.
|
||||
host.replaceChildren()
|
||||
}
|
||||
}, [id, source])
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<MermaidFrame>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.fallbackScroll}
|
||||
testID="mermaid-diagram-source"
|
||||
>
|
||||
<Text style={[styles.fallbackText, { fontSize: base - 1 }]}>{source}</Text>
|
||||
</ScrollView>
|
||||
</MermaidFrame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.frame}>
|
||||
<View style={styles.label}>
|
||||
<Text style={styles.labelText}>mermaid</Text>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.fallbackScroll}>
|
||||
<Text style={[styles.fallbackText, { fontSize: base - 1 }]}>{source}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<MermaidFrame>
|
||||
<View ref={hostRef} style={styles.host} />
|
||||
</MermaidFrame>
|
||||
)
|
||||
})
|
||||
|
||||
// The native component's own fallback styles, so the degradation looks like the state that
|
||||
// component already renders rather than a second design.
|
||||
/** The native component's frame and label, so a diagram and its fallback sit in the same box. */
|
||||
function MermaidFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<View style={styles.frame} testID="mermaid-diagram">
|
||||
<View style={styles.label}>
|
||||
<Text style={styles.labelText}>mermaid</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// The native component's own styles, so the page's diagram sits in the box that component draws.
|
||||
// No rule reaches the SVG: mermaid emits it with `width="100%"` and its own natural `max-width`,
|
||||
// and a rule of ours on the element would be a byte the native document's render does not have.
|
||||
const styles = StyleSheet.create({
|
||||
frame: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
@@ -55,6 +129,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 11,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
host: { padding: spacing.sm },
|
||||
fallbackScroll: { padding: spacing.sm },
|
||||
fallbackText: { color: colors.textPrimary, fontFamily: typography.monoFamily }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
|
||||
/**
|
||||
* The one mermaid configuration both hosts run.
|
||||
*
|
||||
* The native document splices it into the inline script it builds; the page hands the same object
|
||||
* to `mermaid.initialize`. Written down twice these would drift, and the drift would be a diagram
|
||||
* that looks different on the page from the one on the phone.
|
||||
*
|
||||
* `suppressErrorRendering` because a diagram that throws is a source box on both hosts: without it
|
||||
* mermaid draws its own error diagram into a temporary element and then leaves that element in the
|
||||
* document as it rethrows, which on the page is an orphan SVG under nobody's mount.
|
||||
*/
|
||||
export const MERMAID_DIAGRAM_CONFIG = {
|
||||
startOnLoad: false,
|
||||
theme: 'dark',
|
||||
securityLevel: 'strict',
|
||||
darkMode: true,
|
||||
suppressErrorRendering: true,
|
||||
themeVariables: {
|
||||
background: colors.bgRaised,
|
||||
primaryColor: colors.bgPanel,
|
||||
primaryTextColor: colors.textPrimary,
|
||||
lineColor: colors.textSecondary,
|
||||
textColor: colors.textPrimary
|
||||
}
|
||||
} as const
|
||||
@@ -23,6 +23,28 @@ describe('buildHtml source escaping', () => {
|
||||
expect(buildHtml(payload)).toContain('\\u003c/script')
|
||||
})
|
||||
|
||||
it('does not let a config value break out of the inline script either', async () => {
|
||||
// The config is spliced into the same `<script>` as the source and is not a fixed set of hex
|
||||
// colours by nature: a `themeCSS` or a font stack is free text, and `JSON.stringify` leaves
|
||||
// `<` and `>` raw. Mocked rather than edited in place, because what is under test is the
|
||||
// splice and not today's values.
|
||||
vi.resetModules()
|
||||
vi.doMock('./mermaid-diagram-config', () => ({
|
||||
MERMAID_DIAGRAM_CONFIG: { themeCSS: '</script><script>window.evil=1</script>' }
|
||||
}))
|
||||
try {
|
||||
const hostile = await import('./MermaidDiagram')
|
||||
const countClosers = (html: string) => (html.match(/<\/script>/gi) ?? []).length
|
||||
const benign = countClosers(buildHtml('graph TD; A-->B'))
|
||||
const built = hostile.buildHtml('graph TD; A-->B')
|
||||
expect(countClosers(built)).toBe(benign)
|
||||
expect(built).toContain('\\u003c/script')
|
||||
} finally {
|
||||
vi.doUnmock('./mermaid-diagram-config')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('escapes the U+2028/U+2029 line separators that would break the JS literal', () => {
|
||||
const payload = `a${String.fromCharCode(0x2028)}b${String.fromCharCode(0x2029)}c`
|
||||
const html = buildHtml(payload)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Mermaid, MermaidConfig } from 'mermaid'
|
||||
|
||||
/**
|
||||
* The two calls the page makes of mermaid, named rather than cast.
|
||||
*/
|
||||
export type PageMermaid = {
|
||||
initialize: (config: MermaidConfig) => void
|
||||
render: (id: string, text: string) => Promise<{ svg: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* The package's own API satisfies the type above, asserted at compile time.
|
||||
*
|
||||
* The loader's return does not assert it. The artifact is minified vendor output and both members
|
||||
* measure as `any` there (a probe assigning `engine.render` to a `number` compiles), and `any`
|
||||
* satisfies every signature, so returning it as `PageMermaid` checks the two names and nothing
|
||||
* about their shapes. This does: `Mermaid` is precise, so a `PageMermaid` member whose signature
|
||||
* the engine does not really have fails here instead of at a call the page makes. It lives beside
|
||||
* the type it constrains rather than in a test: `mobile/tsconfig.json` excludes `*.test.ts`, so the
|
||||
* app's own typecheck would not cover it there. Tests are typechecked too, by `tsconfig.test.json`
|
||||
* through the tests-typecheck ratchet, but that is a second program with a grandfathered baseline
|
||||
* and a few files held outside it on purpose, and it is not the gate the shipped build rests on.
|
||||
*
|
||||
* What no type can check is that the bundle behaves like the package. The render check is that, in
|
||||
* both engines, against the native document's own bytes.
|
||||
*/
|
||||
const _packageSatisfiesPageMermaid: (engine: Mermaid) => PageMermaid = (engine) => engine
|
||||
|
||||
/**
|
||||
* The page's mermaid, loaded on demand from one pre-bundled artifact.
|
||||
*
|
||||
* `import('mermaid')` from inside the app bundle would emit 103 scripts, because mermaid lazily
|
||||
* imports each of its own diagram types and esbuild splits along those boundaries. Every one of
|
||||
* them ships inside the OTA generation the phone has already downloaded, so the split moves no
|
||||
* bytes over the wire and spends 103 of the 256 manifest assets the shell will load. The artifact
|
||||
* is the same engine in one file, and this import is still the deferred one: a session with no
|
||||
* diagram on it evaluates none of it.
|
||||
*/
|
||||
export async function loadPageMermaid(): Promise<PageMermaid> {
|
||||
const engine = await import('./mermaid-page-engine.generated')
|
||||
return engine.default
|
||||
}
|
||||
@@ -79,6 +79,16 @@ vi.mock('expo-clipboard', () => ({
|
||||
setStringAsync: () => Promise.resolve(true),
|
||||
getStringAsync: () => Promise.resolve('')
|
||||
}))
|
||||
// Same reason, and the screen only hands `playPageHaptic` over: which expo member each kind
|
||||
// reaches is `page-haptics.test.ts`. `Platform.OS` above is pinned to `ios`, so the Android
|
||||
// members are never evaluated and are not listed.
|
||||
vi.mock('expo-haptics', () => ({
|
||||
impactAsync: () => Promise.resolve(),
|
||||
notificationAsync: () => Promise.resolve(),
|
||||
selectionAsync: () => Promise.resolve(),
|
||||
ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' },
|
||||
NotificationFeedbackType: { Error: 'error', Success: 'success' }
|
||||
}))
|
||||
vi.mock('expo-document-picker', () => ({ getDocumentAsync: () => Promise.resolve(null) }))
|
||||
vi.mock('expo-image-picker', () => ({
|
||||
launchImageLibraryAsync: () => Promise.resolve({ canceled: true }),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
isDevelopmentBuild,
|
||||
useMobileWebShellDroppedFrames
|
||||
} from './mobile-web-shell-dev-facts'
|
||||
import { playPageHaptic } from './page-haptics'
|
||||
import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge'
|
||||
import type { MobileWebShellRuntime } from './mobile-web-shell-runtime'
|
||||
import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs'
|
||||
@@ -213,6 +214,10 @@ export function MobileWebShellScreen({
|
||||
console.warn('[web-shell] could not open a URL for the page', { url, error })
|
||||
})
|
||||
},
|
||||
// The app's own haptics, reached through one mapping rather than a second copy of the
|
||||
// `Platform.OS` split. Nothing crosses back and nothing can fail: each function already
|
||||
// swallows its own rejection on the device.
|
||||
onHaptic: playPageHaptic,
|
||||
// The page's own Back goes nowhere: it holds the one history entry the entry wrote, so the only
|
||||
// stack to pop is this one.
|
||||
onNavigateBack: popShellStack,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TerminalBacklogEnd, TerminalBacklogTimers } from './bridge-termina
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { BridgeRefusal } from './bridge/bridge-caps'
|
||||
import type { BridgeInitHost, BridgeInitRoute } from './bridge/bridge-envelope'
|
||||
import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify'
|
||||
import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
|
||||
import type { BridgeNativeVerb } from './bridge/bridge-native-verbs'
|
||||
import type { BridgeNotifyRefusal } from './bridge/bridge-notify-grants'
|
||||
@@ -134,6 +135,16 @@ export type BridgeHostOptions = {
|
||||
* failed is invisible on both sides unless the caller says so.
|
||||
*/
|
||||
onExternalLink: (url: string) => void
|
||||
/**
|
||||
* Plays one haptic on this device. Required for the reason `onExternalLink` is: the `haptics`
|
||||
* grant is issued on the strength of this existing.
|
||||
*
|
||||
* Injected rather than called here, as every other device-local notify is: a static import of the
|
||||
* app's haptics would put `react-native` and `expo-haptics` in this module's graph, and the host
|
||||
* is the protocol's half of the bridge on either. It must not throw — this runs on the native
|
||||
* frame handler — and it owes the page nothing, which is why a notify rather than a verb.
|
||||
*/
|
||||
onHaptic: (kind: BridgeHapticsKind) => void
|
||||
/**
|
||||
* Pops the native stack this page was pushed onto. Required for the reason `onNavigate` is: the
|
||||
* `navigate` grant carries this verb too, and a page told it may hand its Back button over and
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('init and state', () => {
|
||||
'storage',
|
||||
'externalLink',
|
||||
'screencastBinary',
|
||||
'haptics',
|
||||
'native.clipboard.write',
|
||||
'native.clipboard.read',
|
||||
'native.media.pick',
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
BRIDGE_FAULT_GRANT,
|
||||
BRIDGE_NAVIGATE_BACK_NOTIFY
|
||||
} from './bridge/bridge-envelope'
|
||||
import {
|
||||
BRIDGE_HAPTICS_GRANT,
|
||||
BRIDGE_HAPTICS_KINDS,
|
||||
BRIDGE_HAPTICS_NOTIFY
|
||||
} from './bridge/bridge-haptics-notify'
|
||||
import { BRIDGE_NATIVE_GRANTS } from './bridge/bridge-init-frame'
|
||||
import { MOBILE_WEB_SHELL_GRANTS } from './page-route-policy'
|
||||
|
||||
describe('notifications, refusals and the fence', () => {
|
||||
it('forwards foreground with the arity the page used, and the viewport whole', () => {
|
||||
@@ -335,3 +341,75 @@ describe('externalLink', () => {
|
||||
expect(init.type === 'init' && init.grants.native).toContain(BRIDGE_EXTERNAL_LINK_GRANT)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The notify that reaches hardware.
|
||||
*
|
||||
* Nothing crosses back, which is the reason it is a notify: a reply would spend a slot in the same
|
||||
* 64-deep in-flight window a forwarded request does, and the file explorer plays one per row tap.
|
||||
* So the oracle is what the shell was asked to play, and the refusals are the only report there is.
|
||||
*/
|
||||
describe('haptics', () => {
|
||||
const play = (kind: string) => clientFrame({ type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind })
|
||||
|
||||
it('plays each kind on the device and asks the client for nothing', () => {
|
||||
const bridge = harness()
|
||||
bridge.host.receive(clientFrame({ type: 'ready' }))
|
||||
for (const kind of BRIDGE_HAPTICS_KINDS) {
|
||||
bridge.host.receive(play(kind))
|
||||
}
|
||||
expect(bridge.haptics).toEqual([...BRIDGE_HAPTICS_KINDS])
|
||||
expect(bridge.client.requests).toHaveLength(0)
|
||||
expect(bridge.client.foregroundCalls).toEqual([])
|
||||
expect(bridge.diagnostics).toEqual([])
|
||||
})
|
||||
|
||||
it('plays one per frame, so a twelve-row scroll is twelve taps and not one', () => {
|
||||
const bridge = harness()
|
||||
bridge.host.receive(clientFrame({ type: 'ready' }))
|
||||
for (let row = 0; row < 12; row += 1) {
|
||||
bridge.host.receive(play('selection'))
|
||||
}
|
||||
expect(bridge.haptics).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('plays nothing for a route that was granted no haptics', () => {
|
||||
// Granted everything else this shell implements, so the refusal is this row and not an empty list.
|
||||
const bridge = harness({
|
||||
routeGrants: MOBILE_WEB_SHELL_GRANTS.filter((grant) => grant !== BRIDGE_HAPTICS_GRANT)
|
||||
})
|
||||
bridge.host.receive(clientFrame({ type: 'ready' }))
|
||||
bridge.host.receive(play('selection'))
|
||||
expect(bridge.haptics).toEqual([])
|
||||
expect(bridge.diagnostics).toEqual([
|
||||
{ kind: 'notify-refused', name: BRIDGE_HAPTICS_NOTIFY, why: 'ungranted' }
|
||||
])
|
||||
})
|
||||
|
||||
it('plays nothing for a page that has not asked for a session', () => {
|
||||
const bridge = harness()
|
||||
bridge.host.receive(play('selection'))
|
||||
expect(bridge.haptics).toEqual([])
|
||||
expect(bridge.diagnostics).toEqual([
|
||||
{ kind: 'notify-refused', name: BRIDGE_HAPTICS_NOTIFY, why: 'before-ready' }
|
||||
])
|
||||
})
|
||||
|
||||
it('plays nothing for a kind this app has no function for', () => {
|
||||
const bridge = harness()
|
||||
bridge.host.receive(clientFrame({ type: 'ready' }))
|
||||
bridge.host.receive(play('heavyImpact'))
|
||||
expect(bridge.haptics).toEqual([])
|
||||
// Dropped by the envelope rather than by the grant check: the kinds are a closed list, so a
|
||||
// shell older than a kind refuses the whole frame instead of playing something else.
|
||||
expect(bridge.diagnostics).toEqual([{ kind: 'refused', refusal: 'unrecognised-message' }])
|
||||
})
|
||||
|
||||
it('is advertised under the token a route can declare, not under the notify name', () => {
|
||||
const bridge = harness()
|
||||
bridge.host.receive(clientFrame({ type: 'ready' }))
|
||||
const init = bridge.last()
|
||||
expect(init.type === 'init' && init.grants.native).toContain(BRIDGE_HAPTICS_GRANT)
|
||||
expect(init.type === 'init' && init.grants.native).not.toContain(BRIDGE_HAPTICS_NOTIFY)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from './bridge-host-test-fakes'
|
||||
import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from './bridge-host'
|
||||
import type { BridgeNavigateBackOutcome } from './bridge-host-contract'
|
||||
import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify'
|
||||
import { MOBILE_WEB_SHELL_GRANTS } from './page-route-policy'
|
||||
import {
|
||||
BRIDGE_NATIVE_VERBS,
|
||||
@@ -35,6 +36,8 @@ export type Harness = {
|
||||
navigations: string[]
|
||||
/** Every URL the page asked the shell to open outside the app, in order. */
|
||||
externalLinks: string[]
|
||||
/** Every haptic the page asked the shell to play, in order. */
|
||||
haptics: BridgeHapticsKind[]
|
||||
/** Every text the page wrote to the pasteboard through a native verb, in order. */
|
||||
clipboardWrites: string[]
|
||||
/** One entry per `navigate-back` the host answered, in order, with what the shell did. */
|
||||
@@ -85,6 +88,7 @@ export function harness(
|
||||
const diagnostics: BridgeHostDiagnostic[] = []
|
||||
const navigations: string[] = []
|
||||
const externalLinks: string[] = []
|
||||
const haptics: BridgeHapticsKind[] = []
|
||||
const clipboardWrites: string[] = []
|
||||
const backPops: BridgeNavigateBackOutcome[] = []
|
||||
const storageWrites: { key: string; value: string | null }[] = []
|
||||
@@ -113,6 +117,7 @@ export function harness(
|
||||
onRouteRefused: (issue) => routeRefusals.push(issue),
|
||||
onNavigate: options.onNavigate ?? ((href) => navigations.push(href)),
|
||||
onExternalLink: (url) => externalLinks.push(url),
|
||||
onHaptic: (kind) => haptics.push(kind),
|
||||
serveNativeVerb: (verb, params) => {
|
||||
if (options.serveNativeVerb !== undefined) {
|
||||
return options.serveNativeVerb(verb, params)
|
||||
@@ -159,6 +164,7 @@ export function harness(
|
||||
droppedBinaryFrames,
|
||||
navigations,
|
||||
externalLinks,
|
||||
haptics,
|
||||
clipboardWrites,
|
||||
backPops,
|
||||
storageWrites,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './bridge/bridge-envelope'
|
||||
import { captureBridgeError } from './bridge/bridge-error-capture'
|
||||
import { createBridgeInitFrame } from './bridge/bridge-init-frame'
|
||||
import { BRIDGE_HAPTICS_NOTIFY } from './bridge/bridge-haptics-notify'
|
||||
import { bridgeNotifyRefusal } from './bridge/bridge-notify-grants'
|
||||
import { splitBridgeReply } from './bridge/bridge-reply-chunking'
|
||||
import { isPageStorageKeyForHost } from './page-storage-keys'
|
||||
@@ -257,6 +258,12 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
options.onStorageWrite(message.key, message.value)
|
||||
return
|
||||
}
|
||||
if (message.name === BRIDGE_HAPTICS_NOTIFY) {
|
||||
// Local, and the only notify the shell answers with hardware. Nothing crosses back, which
|
||||
// is the whole reason this is a notify: a reply would spend an in-flight slot per row tap.
|
||||
options.onHaptic(message.kind)
|
||||
return
|
||||
}
|
||||
client.updateTerminalSubscriptionViewport(message.terminal, {
|
||||
cols: message.cols,
|
||||
rows: message.rows
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
BRIDGE_NAVIGATE_BACK_NOTIFY,
|
||||
BRIDGE_PROTOCOL_VERSION
|
||||
} from './bridge-envelope'
|
||||
import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from './bridge-caps'
|
||||
import {
|
||||
BRIDGE_HAPTICS_GRANT,
|
||||
BRIDGE_HAPTICS_KINDS,
|
||||
BRIDGE_HAPTICS_NOTIFY
|
||||
} from './bridge-haptics-notify'
|
||||
import { GRANTS, INIT, createPageClient } from './bridge-page-client-test-harness'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -207,3 +213,90 @@ describe('externalLink', () => {
|
||||
expect(page.client.notifyExternalLink('https://example.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The haptic the page asks for and hears nothing back about.
|
||||
*
|
||||
* Gated at the call site as well as at the frame, for the reason every gated notify is: `notify` is
|
||||
* a closed list, so a shell that granted no haptics refuses the whole frame, and a caller told the
|
||||
* frame left would be told a lie. Unlike `externalLink` nobody reads the answer — a tap that did
|
||||
* not buzz is every tap on every phone before this page existed — so it is returned and not logged.
|
||||
*/
|
||||
describe('bridge client haptics', () => {
|
||||
const granted = (page: ReturnType<typeof createPageClient>): void => {
|
||||
page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_HAPTICS_GRANT] } })
|
||||
}
|
||||
|
||||
it('posts one frame per kind, carrying the kind it was asked for', () => {
|
||||
const page = createPageClient()
|
||||
granted(page)
|
||||
for (const kind of BRIDGE_HAPTICS_KINDS) {
|
||||
expect(page.client.notifyHaptics(kind), kind).toBe(true)
|
||||
}
|
||||
expect(page.frames().slice(1)).toEqual(
|
||||
BRIDGE_HAPTICS_KINDS.map((kind) => ({
|
||||
v: BRIDGE_PROTOCOL_VERSION,
|
||||
type: 'notify',
|
||||
name: BRIDGE_HAPTICS_NOTIFY,
|
||||
kind
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
it('stays quiet against a shell that granted nothing, because the frame would be refused whole', () => {
|
||||
const page = createPageClient()
|
||||
page.start()
|
||||
expect(page.client.notifyHaptics('selection')).toBe(false)
|
||||
expect(page.sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stays quiet against a shell that granted the notify name instead of the token', () => {
|
||||
const page = createPageClient()
|
||||
page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_HAPTICS_NOTIFY] } })
|
||||
expect(page.client.notifyHaptics('selection')).toBe(false)
|
||||
expect(page.sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('answers false before a session and after close rather than throwing inside a tap handler', () => {
|
||||
const early = createPageClient()
|
||||
expect(early.client.notifyHaptics('selection')).toBe(false)
|
||||
const page = createPageClient()
|
||||
granted(page)
|
||||
page.client.close()
|
||||
expect(page.client.notifyHaptics('selection')).toBe(false)
|
||||
expect(page.frames().at(-1)).toEqual({ v: BRIDGE_PROTOCOL_VERSION, type: 'close' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* What a haptic costs on the wire, measured off the frame the client posted rather than a written
|
||||
* copy of its shape: the two drift, and the one that drifts is the budget.
|
||||
*
|
||||
* The worst kind is the longest name, and a scrolling list is the worst case for the count: the
|
||||
* file explorer plays `selection` once per row, so twelve rows is the number to think about.
|
||||
*/
|
||||
describe('the bytes a haptic spends', () => {
|
||||
it('costs well under a thousandth of the frame cap, whichever kind it is', () => {
|
||||
const page = createPageClient()
|
||||
page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_HAPTICS_GRANT] } })
|
||||
const bytes = BRIDGE_HAPTICS_KINDS.map((kind) => {
|
||||
page.client.notifyHaptics(kind)
|
||||
return utf8ByteLength(page.sent.at(-1) ?? '')
|
||||
})
|
||||
// One per kind, widest first: `mediumImpact` is the longest name and `error` the shortest.
|
||||
expect(bytes).toEqual([77, 74, 72, 70, 73])
|
||||
expect(Math.max(...bytes) / BRIDGE_MAX_MESSAGE_BYTES).toBeLessThan(0.0002)
|
||||
})
|
||||
|
||||
it('costs a twelve-row scroll under a kilobyte, one frame per row', () => {
|
||||
const page = createPageClient()
|
||||
page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_HAPTICS_GRANT] } })
|
||||
const before = page.sent.length
|
||||
for (let row = 0; row < 12; row += 1) {
|
||||
page.client.notifyHaptics('selection')
|
||||
}
|
||||
const scroll = page.sent.slice(before)
|
||||
expect(scroll).toHaveLength(12)
|
||||
expect(scroll.reduce((total, json) => total + utf8ByteLength(json), 0)).toBe(888)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,22 +7,27 @@ import {
|
||||
type BridgeClientMessage
|
||||
} from './bridge-envelope'
|
||||
import { readBridgeExternalLinkUrl } from './bridge-caps'
|
||||
import {
|
||||
BRIDGE_HAPTICS_GRANT,
|
||||
BRIDGE_HAPTICS_NOTIFY,
|
||||
type BridgeHapticsKind
|
||||
} from './bridge-haptics-notify'
|
||||
import { captureBridgeError } from './bridge-error-capture'
|
||||
|
||||
/**
|
||||
* Everything the page posts and hears nothing back about.
|
||||
*
|
||||
* Five of the six post through one guard, but only two reach its throw, and it is not the guard
|
||||
* Six of the seven post through one guard, but only two reach its throw, and it is not the guard
|
||||
* `sendRequest` uses. A call before `init` is a mount-order bug and throws; a call after `close` is
|
||||
* an unmounting screen posting one more nudge on its way out, which the native clients answer
|
||||
* inertly rather than by throwing into a teardown path nobody wrote a catch for. Nothing here
|
||||
* returns a promise, so nothing here can be awaited into a rejection either.
|
||||
*
|
||||
* Only the two ungated notifies reach that throw. A grant is read off the session, so before `init`
|
||||
* there is no grant either and `navigate`, `navigate-back`, `externalLink` and `storage` answer
|
||||
* false without asking: that is the same false they answer a shell that withheld the grant, and
|
||||
* every caller already handles it — `useRouteHandoff` pushes or goes back inside the page instead,
|
||||
* where a throw would take down a tap handler nobody wrapped.
|
||||
* there is no grant either and `navigate`, `navigate-back`, `externalLink`, `storage` and the
|
||||
* haptic answer false without asking: that is the same false they answer a shell that withheld the
|
||||
* grant, and every caller already handles it — `useRouteHandoff` pushes or goes back inside the
|
||||
* page instead, where a throw would take down a tap handler nobody wrapped.
|
||||
*
|
||||
* `notifyPageFault` reads the session instead of requiring it for a different reason: its one caller
|
||||
* is an error boundary, and a report that threw would replace the page's last word with an error
|
||||
@@ -48,6 +53,7 @@ export type BridgeClientNotifications = {
|
||||
notifyNavigateBack: () => boolean
|
||||
notifyExternalLink: (url: string) => boolean
|
||||
notifyStorageWrite: (key: string, value: string | null) => boolean
|
||||
notifyHaptics: (kind: BridgeHapticsKind) => boolean
|
||||
notifyPageFault: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
@@ -112,6 +118,11 @@ export function createBridgeClientNotifications(
|
||||
notifyStorageWrite: (key, value) =>
|
||||
deps.hasGrant('storage') &&
|
||||
post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: 'storage', key, value }),
|
||||
// The answer is returned and never logged: a warning per refused frame would be one per row of
|
||||
// a scrolling list, and a tap that did not buzz is every tap on every phone before this page.
|
||||
notifyHaptics: (kind) =>
|
||||
deps.hasGrant(BRIDGE_HAPTICS_GRANT) &&
|
||||
post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind }),
|
||||
notifyPageFault: (error) => {
|
||||
if (deps.isClosed() || !deps.hasGrant(BRIDGE_FAULT_GRANT)) {
|
||||
return false
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
BRIDGE_ROUTE_HREF_PATTERN,
|
||||
BRIDGE_ROUTE_PATHNAME_PATTERN
|
||||
} from './bridge-caps'
|
||||
import { BRIDGE_HAPTICS_KINDS, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify'
|
||||
import {
|
||||
BRIDGE_BINARY_FORMATS,
|
||||
BRIDGE_CONNECTION_STATES,
|
||||
@@ -130,6 +131,12 @@ describe('client messages', () => {
|
||||
}
|
||||
],
|
||||
['a navigate-back notify', { type: 'notify', name: BRIDGE_NAVIGATE_BACK_NOTIFY }],
|
||||
// One per kind, spread from the list itself: a kind added to the tuple and left out of the
|
||||
// schema's enum would otherwise be accepted here by a case nobody wrote.
|
||||
...BRIDGE_HAPTICS_KINDS.map(
|
||||
(kind) =>
|
||||
[`a ${kind} haptics notify`, { type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind }] as const
|
||||
),
|
||||
['close', { type: 'close' }]
|
||||
] as const
|
||||
|
||||
@@ -199,6 +206,11 @@ describe('client messages', () => {
|
||||
'a page fault whose error is not a capture',
|
||||
client({ type: 'notify', name: BRIDGE_FAULT_GRANT, error: 'the route threw' })
|
||||
],
|
||||
[
|
||||
'a haptic this app has no function for',
|
||||
client({ type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind: 'heavyImpact' })
|
||||
],
|
||||
['a haptics notify naming no kind', client({ type: 'notify', name: BRIDGE_HAPTICS_NOTIFY })],
|
||||
['a bare array', []],
|
||||
['a bare string', 'ready']
|
||||
] as const
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { isRpcResponse } from '../../transport/rpc-response-shape'
|
||||
import type { RpcResponse } from '../../transport/types'
|
||||
import { BridgeErrorCaptureSchema } from './bridge-error-capture'
|
||||
import { BRIDGE_HAPTICS_NOTIFY_FIELDS } from './bridge-haptics-notify'
|
||||
import {
|
||||
isPageStorageKey,
|
||||
PAGE_STORAGE_MAX_ENTRIES,
|
||||
@@ -316,7 +317,9 @@ const BridgeClientMessageSchema = z.discriminatedUnion('type', [
|
||||
/** The capture an `error` frame already carries, so both directions share one bound and one
|
||||
* reader. Nothing is owed back: the page is telling the shell, not asking it. */
|
||||
error: BridgeErrorCaptureSchema
|
||||
})
|
||||
}),
|
||||
// Behind the `haptics` grant, and the fields are its own module's for the reason stated there.
|
||||
z.object({ v: versionSchema, ...BRIDGE_HAPTICS_NOTIFY_FIELDS })
|
||||
]),
|
||||
z.object({ v: versionSchema, type: z.literal('close') })
|
||||
])
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* The one haptic the page asks the shell for, as a `notify` rather than a verb.
|
||||
*
|
||||
* Fire-and-forget on the request/reply table would be wrong twice over: a reply costs a slot in the
|
||||
* same 64-deep in-flight window a forwarded request spends, and there are 90 call sites in this
|
||||
* app, some of them one per row of a scrolling list. Nothing is owed back — a haptic the shell did
|
||||
* not play is a tap that felt like every tap on every phone before this page existed.
|
||||
*
|
||||
* Split out of `bridge-envelope.ts` rather than added to it, as `bridge-event-envelope-bytes.ts`
|
||||
* was: that file is the protocol's schemas and it is at its line cap. The arm below is its fields
|
||||
* without `v`, because the envelope owns the version literal and reading it back from here would be
|
||||
* an import cycle.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The grant, a single token rather than the notify's own name.
|
||||
*
|
||||
* The notify table's grants are tokens — `navigate`, `storage` — because a notify is not a verb:
|
||||
* `MOBILE_WEB_SHELL_GRANTS` spreads the dotted names from the verb table alone. One token is also
|
||||
* what the capability is: an app either plays haptics or it does not.
|
||||
*/
|
||||
export const BRIDGE_HAPTICS_GRANT = 'haptics'
|
||||
|
||||
/** The notify name. Dotted like a verb because it names a device the shell owns, not a screen. */
|
||||
export const BRIDGE_HAPTICS_NOTIFY = 'native.haptics.trigger'
|
||||
|
||||
/**
|
||||
* Exactly the five haptics `src/platform/haptics.ts` has, and nothing the page can invent.
|
||||
*
|
||||
* A closed list, so a kind outside it takes the whole frame down as `unrecognised-message` on an
|
||||
* older shell; adding one is a compatibility change rather than an additive field. The shell's
|
||||
* handler is keyed on this tuple, so a kind here with no function behind it does not compile.
|
||||
*/
|
||||
export const BRIDGE_HAPTICS_KINDS = [
|
||||
'mediumImpact',
|
||||
'selection',
|
||||
'success',
|
||||
'error',
|
||||
'edgeBump'
|
||||
] as const
|
||||
|
||||
export type BridgeHapticsKind = (typeof BRIDGE_HAPTICS_KINDS)[number]
|
||||
|
||||
/** Spread into the envelope's notify union beside `v`, which the envelope adds. */
|
||||
export const BRIDGE_HAPTICS_NOTIFY_FIELDS = {
|
||||
type: z.literal('notify'),
|
||||
name: z.literal(BRIDGE_HAPTICS_NOTIFY),
|
||||
kind: z.enum(BRIDGE_HAPTICS_KINDS)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY } from './bridge-envelope'
|
||||
import { bridgeNotifyRefusal } from './bridge-notify-grants'
|
||||
import {
|
||||
BRIDGE_EXTERNAL_LINK_GRANT,
|
||||
BRIDGE_FAULT_GRANT,
|
||||
BRIDGE_NAVIGATE_BACK_NOTIFY
|
||||
} from './bridge-envelope'
|
||||
import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify'
|
||||
import { bridgeNotifyRefusal, type BridgeNotifyName } from './bridge-notify-grants'
|
||||
|
||||
const GRANTED = [BRIDGE_FAULT_GRANT]
|
||||
|
||||
@@ -97,3 +102,81 @@ describe('the grant table', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Haptics, the first notify added since the protocol's own, and the second whose name is not its
|
||||
* grant: `native.haptics.trigger` rides the single token `haptics`.
|
||||
*
|
||||
* A token because a notify is not a verb: every grant in that table is one, and the dotted names
|
||||
* `MOBILE_WEB_SHELL_GRANTS` carries are spread from the verb table. A route declaring the notify's
|
||||
* own name would be declaring something no shell advertises, which the case below pins.
|
||||
*/
|
||||
describe('the haptics notify', () => {
|
||||
it('is refused on a route that was granted no haptics', () => {
|
||||
expect(bridgeNotifyRefusal({ name: BRIDGE_HAPTICS_NOTIFY, initSent: true, granted: [] })).toBe(
|
||||
'ungranted'
|
||||
)
|
||||
// Granted everything else this shell has, so the refusal is the haptics row and not an
|
||||
// empty list.
|
||||
expect(
|
||||
bridgeNotifyRefusal({
|
||||
name: BRIDGE_HAPTICS_NOTIFY,
|
||||
initSent: true,
|
||||
granted: ['navigate', 'storage', BRIDGE_EXTERNAL_LINK_GRANT, BRIDGE_FAULT_GRANT]
|
||||
})
|
||||
).toBe('ungranted')
|
||||
})
|
||||
|
||||
it('is served on a route that was granted the token', () => {
|
||||
expect(
|
||||
bridgeNotifyRefusal({
|
||||
name: BRIDGE_HAPTICS_NOTIFY,
|
||||
initSent: true,
|
||||
granted: [BRIDGE_HAPTICS_GRANT]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('is not served against a grant list that names the notify instead of the token', () => {
|
||||
expect(
|
||||
bridgeNotifyRefusal({
|
||||
name: BRIDGE_HAPTICS_NOTIFY,
|
||||
initSent: true,
|
||||
granted: [BRIDGE_HAPTICS_NOTIFY]
|
||||
})
|
||||
).toBe('ungranted')
|
||||
})
|
||||
|
||||
it('is refused before a grant is read at all from a page with no session', () => {
|
||||
expect(
|
||||
bridgeNotifyRefusal({
|
||||
name: BRIDGE_HAPTICS_NOTIFY,
|
||||
initSent: false,
|
||||
granted: [BRIDGE_HAPTICS_GRANT]
|
||||
})
|
||||
).toBe('before-ready')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The totality shown rather than described.
|
||||
*
|
||||
* The docstring above says a name with no row is a compile error; this is the error. Every row the
|
||||
* table has, less the haptics one, against the same `Record` over the union — checked by
|
||||
* `tsconfig.test.json`, so the day the omission stops being an error the unused directive is.
|
||||
*/
|
||||
describe('a grant table missing a row', () => {
|
||||
it('does not typecheck', () => {
|
||||
// @ts-expect-error TS2741: no row for the haptics notify, the hole the Record closes.
|
||||
const incomplete: Readonly<Record<BridgeNotifyName, string | null>> = {
|
||||
foreground: null,
|
||||
terminalViewport: null,
|
||||
navigate: 'navigate',
|
||||
[BRIDGE_NAVIGATE_BACK_NOTIFY]: 'navigate',
|
||||
storage: 'storage',
|
||||
[BRIDGE_EXTERNAL_LINK_GRANT]: BRIDGE_EXTERNAL_LINK_GRANT,
|
||||
[BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT
|
||||
}
|
||||
expect(Object.keys(incomplete)).toHaveLength(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
BRIDGE_NAVIGATE_BACK_NOTIFY,
|
||||
type BridgeClientMessage
|
||||
} from './bridge-envelope'
|
||||
import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify'
|
||||
|
||||
/** Every `notify` name the envelope accepts, so the table below cannot be asked about another. */
|
||||
type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>['name']
|
||||
export type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>['name']
|
||||
|
||||
/**
|
||||
* Which grant each `notify` name rides, and `null` for the ones that ride none.
|
||||
@@ -20,8 +21,12 @@ type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>['name']
|
||||
* new enters `MOBILE_WEB_SHELL_GRANTS`. Keyed on the notify name alone it would be refused by every
|
||||
* shell that exists.
|
||||
*
|
||||
* `foreground` and `terminalViewport` are the protocol's own and ride no grant. The other four are
|
||||
* `foreground` and `terminalViewport` are the protocol's own and ride no grant. The other five are
|
||||
* inert while every page is offered all of them, and load-bearing the moment a grant is per-route.
|
||||
*
|
||||
* Haptics is the second whose name is not its grant, and for a different reason from
|
||||
* `navigate-back`: every grant in this table is a token because a notify is not a verb, and the
|
||||
* dotted names in `MOBILE_WEB_SHELL_GRANTS` come from the verb table alone.
|
||||
*/
|
||||
const BRIDGE_NOTIFY_GRANTS: Readonly<Record<BridgeNotifyName, string | null>> = {
|
||||
foreground: null,
|
||||
@@ -30,7 +35,8 @@ const BRIDGE_NOTIFY_GRANTS: Readonly<Record<BridgeNotifyName, string | null>> =
|
||||
[BRIDGE_NAVIGATE_BACK_NOTIFY]: 'navigate',
|
||||
storage: 'storage',
|
||||
[BRIDGE_EXTERNAL_LINK_GRANT]: BRIDGE_EXTERNAL_LINK_GRANT,
|
||||
[BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT
|
||||
[BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT,
|
||||
[BRIDGE_HAPTICS_NOTIFY]: BRIDGE_HAPTICS_GRANT
|
||||
}
|
||||
|
||||
export type BridgeNotifyRefusal = 'before-ready' | 'ungranted'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from '../bridge-host'
|
||||
import type { BridgeNavigateBackOutcome } from '../bridge-host-contract'
|
||||
import type { BridgeHapticsKind } from './bridge-haptics-notify'
|
||||
import type { BridgeNativeVerb } from './bridge-native-verbs'
|
||||
import { MOBILE_WEB_SHELL_GRANTS } from '../page-route-policy'
|
||||
import { createFakeRpcClient, type FakeRpcClient } from '../bridge-host-test-fakes'
|
||||
@@ -44,6 +45,8 @@ export type BridgePortPair<TRpc extends RpcClient = FakeRpcClient> = {
|
||||
navigations: string[]
|
||||
/** Every URL the page asked the shell to open outside the app, in order. */
|
||||
externalLinks: string[]
|
||||
/** Every haptic the page asked the shell to play, in order. */
|
||||
haptics: BridgeHapticsKind[]
|
||||
/** One entry per stack pop the page asked for, with what the shell did about it. */
|
||||
backPops: BridgeNavigateBackOutcome[]
|
||||
/** Every allowlisted key the page wrote through the shell, in order. */
|
||||
@@ -177,6 +180,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
|
||||
const hostDiagnostics: BridgeHostDiagnostic[] = []
|
||||
const navigations: string[] = []
|
||||
const externalLinks: string[] = []
|
||||
const haptics: BridgeHapticsKind[] = []
|
||||
const backPops: BridgeNavigateBackOutcome[] = []
|
||||
const storageWrites: { key: string; value: string | null }[] = []
|
||||
const pageFaults: BridgeErrorCapture[] = []
|
||||
@@ -202,6 +206,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
|
||||
sessionEstablished: options.sessionEstablished ?? false,
|
||||
onNavigate: (href) => navigations.push(href),
|
||||
onExternalLink: (url) => externalLinks.push(url),
|
||||
onHaptic: (kind) => haptics.push(kind),
|
||||
// The pair has no device: what a test reads here is that the host answered without forwarding.
|
||||
// Each verb gets a shape its own row declares, so a case that calls one it did not configure
|
||||
// reads an answer rather than `native_verb_result`, which is a shell bug's code.
|
||||
@@ -248,6 +253,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
|
||||
hostDiagnostics,
|
||||
navigations,
|
||||
externalLinks,
|
||||
haptics,
|
||||
backPops,
|
||||
storageWrites,
|
||||
pageFaults,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
BridgeSendFailedError,
|
||||
BridgeShellReplacedError
|
||||
} from './bridge-client-errors'
|
||||
import type { BridgeHapticsKind } from './bridge-haptics-notify'
|
||||
import { createBridgeInboundFrameReader } from './bridge-client-inbound-frames'
|
||||
import { createBridgeClientNotifications } from './bridge-client-notifications'
|
||||
import { BridgeClientRequests } from './bridge-client-requests'
|
||||
@@ -82,6 +83,11 @@ export type BridgeRpcClient = RpcClient & {
|
||||
callNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise<RpcSuccess>
|
||||
/** Writes one allowlisted key into the app's store. False when the shell granted no `storage`. */
|
||||
notifyStorageWrite: (key: string, value: string | null) => boolean
|
||||
/**
|
||||
* Asks the shell to play one haptic. False when the shell granted no `haptics`, which no caller
|
||||
* has to do anything about: a tap that did not buzz is what the page did before this existed.
|
||||
*/
|
||||
notifyHaptics: (kind: BridgeHapticsKind) => boolean
|
||||
/**
|
||||
* Tells the shell this page cannot render what it was opened for. Never throws and never rejects:
|
||||
* the one caller is an error boundary, and a report that threw would be the second failure.
|
||||
@@ -345,6 +351,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
|
||||
})
|
||||
},
|
||||
notifyStorageWrite: notifications.notifyStorageWrite,
|
||||
notifyHaptics: notifications.notifyHaptics,
|
||||
notifyPageFault: notifications.notifyPageFault,
|
||||
close,
|
||||
onReady: (listener) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
|
||||
/**
|
||||
* The hybrid shell flag is the whole of what keeps this feature dark, so who touches it is a
|
||||
@@ -34,16 +35,9 @@ const TREES = { src: 200, app: 10, modules: 1 }
|
||||
const SHELL_VIEW = 'modules/orca-mobile-web-shell/src/index.ts'
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
const found: string[] = []
|
||||
for (const entry of readdirSync(join(MOBILE_ROOT, directory), { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...sourceFiles(path))
|
||||
} else if (/\.tsx?$/.test(entry.name) && !entry.name.includes('.test.')) {
|
||||
found.push(path)
|
||||
}
|
||||
}
|
||||
return found
|
||||
return censusSourceFiles(join(MOBILE_ROOT, directory))
|
||||
.map((path) => relative(MOBILE_ROOT, path))
|
||||
.filter((path) => /\.tsx?$/.test(path) && !path.includes('.test.'))
|
||||
}
|
||||
|
||||
const SOURCES = Object.keys(TREES)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The page's haptic is the app's haptic.
|
||||
*
|
||||
* Asserted against `expo-haptics` rather than against `platform/haptics`: a test that mocked the
|
||||
* app's own module would pin this file's table and prove nothing about the thing a hand feels, and
|
||||
* the whole reason haptics ride one mapping is that the `Platform.OS` split and the Android
|
||||
* `HapticFeedbackConstants` must not be written twice.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BRIDGE_HAPTICS_KINDS, type BridgeHapticsKind } from './bridge/bridge-haptics-notify'
|
||||
|
||||
/** Annotated rather than asserted: the platform is a two-value union and the log starts empty. */
|
||||
type MockDevice = { platform: { OS: 'ios' | 'android' }; calls: string[] }
|
||||
|
||||
// Hoisted, because `vi.mock` is: a factory closing over an ordinary `const` reads it before its
|
||||
// initializer has run. The device call each haptic makes is the only thing recorded.
|
||||
const device = vi.hoisted((): MockDevice => ({ platform: { OS: 'ios' }, calls: [] }))
|
||||
const { calls, platform } = device
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: device.platform }))
|
||||
|
||||
vi.mock('expo-haptics', () => ({
|
||||
impactAsync: (style: string) => {
|
||||
device.calls.push(`impact:${style}`)
|
||||
return Promise.resolve()
|
||||
},
|
||||
selectionAsync: () => {
|
||||
device.calls.push('selection')
|
||||
return Promise.resolve()
|
||||
},
|
||||
notificationAsync: (type: string) => {
|
||||
device.calls.push(`notification:${type}`)
|
||||
return Promise.resolve()
|
||||
},
|
||||
performAndroidHapticsAsync: (constant: string) => {
|
||||
device.calls.push(`android:${constant}`)
|
||||
return Promise.resolve()
|
||||
},
|
||||
ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' },
|
||||
NotificationFeedbackType: { Success: 'success', Error: 'error' },
|
||||
AndroidHaptics: {
|
||||
Long_Press: 'long-press',
|
||||
Gesture_Start: 'gesture-start',
|
||||
Confirm: 'confirm',
|
||||
Reject: 'reject',
|
||||
Clock_Tick: 'clock-tick'
|
||||
}
|
||||
}))
|
||||
|
||||
import { playPageHaptic } from './page-haptics'
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0
|
||||
platform.OS = 'ios'
|
||||
})
|
||||
|
||||
describe('the haptic a page asked for, on iOS', () => {
|
||||
it.each([
|
||||
['mediumImpact', 'impact:medium'],
|
||||
['selection', 'selection'],
|
||||
['success', 'notification:success'],
|
||||
['error', 'notification:error'],
|
||||
['edgeBump', 'impact:light']
|
||||
] as const)('plays %s as %s', (kind, expected) => {
|
||||
playPageHaptic(kind)
|
||||
expect(calls).toEqual([expected])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The other platform, unchanged: `performAndroidHapticsAsync` reaches
|
||||
* `HapticFeedbackConstants`, which works with no `VIBRATE` permission and is why the split exists.
|
||||
*/
|
||||
describe('the same haptic on Android', () => {
|
||||
it.each([
|
||||
['mediumImpact', 'android:long-press'],
|
||||
['selection', 'android:gesture-start'],
|
||||
['success', 'android:confirm'],
|
||||
['error', 'android:reject'],
|
||||
['edgeBump', 'android:clock-tick']
|
||||
] as const)('plays %s as %s', (kind, expected) => {
|
||||
platform.OS = 'android'
|
||||
playPageHaptic(kind)
|
||||
expect(calls).toEqual([expected])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the kinds and the functions behind them', () => {
|
||||
it('spends exactly one call per notify, which is what a per-row tap can afford', () => {
|
||||
for (const kind of BRIDGE_HAPTICS_KINDS) {
|
||||
playPageHaptic(kind)
|
||||
}
|
||||
expect(calls).toHaveLength(BRIDGE_HAPTICS_KINDS.length)
|
||||
})
|
||||
|
||||
/**
|
||||
* Every kind reaches a different device call, which is what says the table has no duplicate row.
|
||||
*
|
||||
* A table mapping two kinds to one function would pass every case above — each still plays
|
||||
* something — and would mean a Save that felt like a failure. The third direction, a haptic
|
||||
* `haptics.ts` grows with no kind of its own, is the census's:
|
||||
* `config/scripts/mobile-web-app-haptics-seam.test.mjs` reads both files' names.
|
||||
*/
|
||||
it('plays a different device call for every kind, so no two share a row', () => {
|
||||
for (const kind of BRIDGE_HAPTICS_KINDS) {
|
||||
playPageHaptic(kind)
|
||||
}
|
||||
expect(new Set(calls).size).toBe(BRIDGE_HAPTICS_KINDS.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the kind union', () => {
|
||||
it('is the five the app has and nothing else', () => {
|
||||
const kinds: readonly BridgeHapticsKind[] = BRIDGE_HAPTICS_KINDS
|
||||
expect([...kinds]).toEqual(['mediumImpact', 'selection', 'success', 'error', 'edgeBump'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
triggerEdgeBump,
|
||||
triggerError,
|
||||
triggerMediumImpact,
|
||||
triggerSelection,
|
||||
triggerSuccess
|
||||
} from '../platform/haptics'
|
||||
import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify'
|
||||
|
||||
/**
|
||||
* A haptic the page asked for, played by the same functions a native screen plays.
|
||||
*
|
||||
* The native file's own bodies and nothing beside them: the `Platform.OS` split, the Android
|
||||
* `HapticFeedbackConstants` and the iOS styles all stay where they are, so a phone feels the same
|
||||
* tap whether the screen came from the bundle or from the app. A second mapping would be the one
|
||||
* that drifted.
|
||||
*
|
||||
* Total in both the directions a type can state. Keyed on the kind union, a kind with no row does
|
||||
* not compile; named as imports rather than reached through a namespace, a row naming a function
|
||||
* `haptics.ts` does not export does not compile either. The third direction — a haptic that file
|
||||
* grows with no kind of its own, which the page could never ask for — is the census's, in
|
||||
* `config/scripts/mobile-web-app-haptics-seam.test.mjs`, which reads both files' names.
|
||||
*/
|
||||
const HAPTIC_BY_KIND: Readonly<Record<BridgeHapticsKind, () => void>> = {
|
||||
mediumImpact: triggerMediumImpact,
|
||||
selection: triggerSelection,
|
||||
success: triggerSuccess,
|
||||
error: triggerError,
|
||||
edgeBump: triggerEdgeBump
|
||||
}
|
||||
|
||||
/** Nothing is owed back: every function above is already `void …catch(() => {})` on the device. */
|
||||
export function playPageHaptic(kind: BridgeHapticsKind): void {
|
||||
HAPTIC_BY_KIND[kind]()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MOBILE_WEB_SHELL_GRANTS,
|
||||
grantsForRoute
|
||||
} from './page-route-policy'
|
||||
import { BRIDGE_HAPTICS_GRANT } from './bridge/bridge-haptics-notify'
|
||||
import {
|
||||
BRIDGE_NATIVE_METHOD_PREFIX,
|
||||
BRIDGE_NATIVE_VERB_NAMES,
|
||||
@@ -81,6 +82,7 @@ describe('the grants this app implements', () => {
|
||||
'storage',
|
||||
'externalLink',
|
||||
'screencastBinary',
|
||||
'haptics',
|
||||
'native.clipboard.write',
|
||||
'native.clipboard.read',
|
||||
'native.media.pick',
|
||||
@@ -222,3 +224,41 @@ describe('a grant name this build has never heard of', () => {
|
||||
).toEqual(['navigate', 'native.clipboard.write'])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* What a token on every page route costs against a shell that does not carry it.
|
||||
*
|
||||
* `implementedPageRoutes` filters on `grants.every(implementsGrant)`, so one grant this build lacks
|
||||
* takes the whole route native rather than degrading the feature that needed it. `haptics` is
|
||||
* declared by all five page routes, which makes the whole set conditional on a shell carrying the
|
||||
* token; the route list itself is pinned in `config/scripts/mobile-web-app-haptics-seam.test.mjs`,
|
||||
* and this is the mechanism behind it.
|
||||
*/
|
||||
describe('a page route that needs the haptics token', () => {
|
||||
const route = {
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate', 'storage', BRIDGE_HAPTICS_GRANT]
|
||||
}
|
||||
|
||||
it('is served by this shell, which implements the token', () => {
|
||||
expect(implementedPageRoutes([route])).toEqual(['/h/[hostId]'])
|
||||
})
|
||||
|
||||
it('renders natively against a shell whose grant list does not carry it', () => {
|
||||
// An older shell's view of the same declaration: a grant it does not implement, whatever it is
|
||||
// spelled. Nothing degrades — the route goes native whole, pins and sidebar and all.
|
||||
const older = {
|
||||
...route,
|
||||
grants: route.grants.map((grant) =>
|
||||
grant === BRIDGE_HAPTICS_GRANT ? 'hapticsUnderAnotherName' : grant
|
||||
)
|
||||
}
|
||||
expect(implementedPageRoutes([older])).toEqual([])
|
||||
// The control, so the empty list above is the token and not the other two grants.
|
||||
expect(
|
||||
implementedPageRoutes([
|
||||
{ ...route, grants: route.grants.filter((grant) => grant !== BRIDGE_HAPTICS_GRANT) }
|
||||
])
|
||||
).toEqual(['/h/[hostId]'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MobileWebBundleManifestRead } from '../transport/mobile-web-bundle-reply-schemas'
|
||||
import { BRIDGE_HAPTICS_GRANT } from './bridge/bridge-haptics-notify'
|
||||
import { BRIDGE_NATIVE_VERB_NAMES } from './bridge/bridge-native-verbs'
|
||||
import { BRIDGE_SCREENCAST_BINARY_GRANT } from './bridge/bridge-screencast-grant'
|
||||
|
||||
@@ -20,6 +21,10 @@ export const MOBILE_WEB_SHELL_GRANTS = [
|
||||
// The screencast's binary frames, encoded into `event.binary` for a page that subscribed with
|
||||
// `wantsBinary`. Named where the rule that reads it lives, so the two cannot drift.
|
||||
BRIDGE_SCREENCAST_BINARY_GRANT,
|
||||
// The device's own feedback, played by the app's functions on the page's behalf. A token rather
|
||||
// than the notify's dotted name, because a notify is not a verb: the dotted names below are the
|
||||
// verb table's, spread from it.
|
||||
BRIDGE_HAPTICS_GRANT,
|
||||
// Spread rather than restated: the verb table is keyed on this same tuple, so a verb cannot be
|
||||
// advertised without a row and a row cannot exist without being advertised.
|
||||
...BRIDGE_NATIVE_VERB_NAMES
|
||||
@@ -53,7 +58,13 @@ export function matchesRoutePattern(pathname: string, pattern: string): boolean
|
||||
})
|
||||
}
|
||||
|
||||
/** The patterns this shell will render from the page: listed, and needing nothing it lacks. */
|
||||
/**
|
||||
* The patterns this shell will render from the page: listed, and needing nothing it lacks.
|
||||
*
|
||||
* `every` and not `some`: one grant this build lacks takes the whole route native, so a token every
|
||||
* page route declares couples the whole set to a shell that carries it — `haptics` is the first,
|
||||
* and against a shell without it no page route is served at all.
|
||||
*/
|
||||
export function implementedPageRoutes(routes: readonly MobileWebPageRoute[] | undefined): string[] {
|
||||
return (routes ?? [])
|
||||
.filter((route) => route.grants.every(implementsGrant))
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import type { OrcaMobileWebShellViewHandle } from '../../modules/orca-mobile-web-shell/src'
|
||||
import { BRIDGE_NATIVE_VERB_NAMES } from './bridge/bridge-native-verbs'
|
||||
import { BRIDGE_HAPTICS_GRANT, type BridgeHapticsKind } from './bridge/bridge-haptics-notify'
|
||||
import { BRIDGE_SCREENCAST_BINARY_GRANT } from './bridge/bridge-screencast-grant'
|
||||
import {
|
||||
BRIDGE_FAULT_GRANT,
|
||||
@@ -44,6 +45,7 @@ type Probe = {
|
||||
view: MobileWebShellBridgeView | null
|
||||
navigations: string[]
|
||||
externalLinks: string[]
|
||||
haptics: BridgeHapticsKind[]
|
||||
backPops: number
|
||||
storageWrites: { key: string; value: string | null }[]
|
||||
/** The running total after each dropped screencast frame, as the screen receives it. */
|
||||
@@ -130,11 +132,13 @@ function Harness(props: {
|
||||
'navigate',
|
||||
'storage',
|
||||
'externalLink',
|
||||
BRIDGE_HAPTICS_GRANT,
|
||||
BRIDGE_SCREENCAST_BINARY_GRANT,
|
||||
...BRIDGE_NATIVE_VERB_NAMES
|
||||
],
|
||||
onNavigate: (href) => props.probe.navigations.push(href),
|
||||
onExternalLink: (url) => props.probe.externalLinks.push(url),
|
||||
onHaptic: (kind) => props.probe.haptics.push(kind),
|
||||
serveNativeVerb: () => Promise.resolve({ value: 'pasteboard' }),
|
||||
onNavigateBack: () => {
|
||||
props.probe.backPops += 1
|
||||
@@ -196,6 +200,7 @@ async function mount(session: MobileWebShellSessionState): Promise<Mounted> {
|
||||
view: null,
|
||||
navigations: [],
|
||||
externalLinks: [],
|
||||
haptics: [],
|
||||
backPops: 0,
|
||||
droppedBinaryFrames: [],
|
||||
storageWrites: []
|
||||
@@ -539,6 +544,7 @@ describe('the callbacks a render passes', () => {
|
||||
view: null,
|
||||
navigations: [],
|
||||
externalLinks: [],
|
||||
haptics: [],
|
||||
backPops: 0,
|
||||
droppedBinaryFrames: [],
|
||||
storageWrites: []
|
||||
@@ -600,6 +606,7 @@ describe('client changes', () => {
|
||||
view: null,
|
||||
navigations: [],
|
||||
externalLinks: [],
|
||||
haptics: [],
|
||||
backPops: 0,
|
||||
droppedBinaryFrames: [],
|
||||
storageWrites: []
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import { createBridgeDiagnosticReporter } from './bridge-diagnostic-log'
|
||||
import type { BridgeInitRoute } from './bridge/bridge-envelope'
|
||||
import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify'
|
||||
import { createBridgeHost, type BridgeHost } from './bridge-host'
|
||||
import type { BridgeNavigateBackOutcome } from './bridge-host-contract'
|
||||
import type { BridgeNativeVerb } from './bridge/bridge-native-verbs'
|
||||
@@ -67,6 +68,8 @@ export function useMobileWebShellBridge(args: {
|
||||
onNavigate: (href: string) => void
|
||||
/** Opens a URL outside the app, on the page's behalf. */
|
||||
onExternalLink: (url: string) => void
|
||||
/** Plays one haptic on this device, on the page's behalf. */
|
||||
onHaptic: (kind: BridgeHapticsKind) => void
|
||||
/** Serves one `native.` verb on this device, for a page that was granted it. */
|
||||
serveNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise<unknown>
|
||||
/** Pops the stack this page was pushed onto, and says so when it did not. */
|
||||
@@ -108,6 +111,7 @@ export function useMobileWebShellBridge(args: {
|
||||
// fresh closure every render must not tear one down and settle its pendings.
|
||||
const navigateRef = useRef(args.onNavigate)
|
||||
const externalLinkRef = useRef(args.onExternalLink)
|
||||
const hapticRef = useRef(args.onHaptic)
|
||||
const nativeVerbRef = useRef(args.serveNativeVerb)
|
||||
const navigateBackRef = useRef(args.onNavigateBack)
|
||||
const storageWriteRef = useRef(args.onStorageWrite)
|
||||
@@ -124,6 +128,7 @@ export function useMobileWebShellBridge(args: {
|
||||
routeGrantsRef.current = args.routeGrants
|
||||
navigateRef.current = args.onNavigate
|
||||
externalLinkRef.current = args.onExternalLink
|
||||
hapticRef.current = args.onHaptic
|
||||
nativeVerbRef.current = args.serveNativeVerb
|
||||
navigateBackRef.current = args.onNavigateBack
|
||||
storageWriteRef.current = args.onStorageWrite
|
||||
@@ -135,6 +140,7 @@ export function useMobileWebShellBridge(args: {
|
||||
}, [
|
||||
args.onBinaryFramesDropped,
|
||||
args.onExternalLink,
|
||||
args.onHaptic,
|
||||
args.serveNativeVerb,
|
||||
args.onNavigate,
|
||||
args.onNavigateBack,
|
||||
@@ -183,6 +189,9 @@ export function useMobileWebShellBridge(args: {
|
||||
onExternalLink: (url) => {
|
||||
externalLinkRef.current(url)
|
||||
},
|
||||
onHaptic: (kind) => {
|
||||
hapticRef.current(kind)
|
||||
},
|
||||
serveNativeVerb: (verb, params) => nativeVerbRef.current(verb, params),
|
||||
host: snapshot.host,
|
||||
readStorage: () => readStorageRef.current(),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* expo-router and React Navigation serialise route params through `import * as queryString from
|
||||
* 'query-string'`. The lockfile overrides `query-string` to 9.x for a `decode-uri-component`
|
||||
* advisory, and 9.x's entry has a default export only, so without `patches/query-string@9.5.1.patch`
|
||||
* every push carrying a param outside the path pattern throws `queryString.stringify is not a
|
||||
* function` and every href with a query throws on `parse`. Resolved from expo-router's own location,
|
||||
* the way Metro and the web bundler resolve it for that consumer, and imported by a plain Node
|
||||
* child: vitest's default-export interop would paper over the missing names in-process.
|
||||
*/
|
||||
describe('query-string, as expo-router resolves it', () => {
|
||||
it('exposes the named API the namespace import needs', () => {
|
||||
const requireFromHere = createRequire(import.meta.url)
|
||||
const requireFromExpoRouter = createRequire(requireFromHere.resolve('expo-router/package.json'))
|
||||
const entry = pathToFileURL(requireFromExpoRouter.resolve('query-string')).href
|
||||
const script = `const ns = await import(${JSON.stringify(entry)}); process.stdout.write(JSON.stringify({ names: Object.keys(ns).sort(), stringified: typeof ns.stringify === 'function' ? ns.stringify({ from: 'worktrees' }) : null }))`
|
||||
const output = execFileSync(process.execPath, ['--input-type=module', '-e', script], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
const { names, stringified } = JSON.parse(output)
|
||||
expect(names).toEqual(expect.arrayContaining(['parse', 'stringify']))
|
||||
expect(stringified).toBe('from=worktrees')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The web form: the page has no haptics of its own, so the shell is asked for one.
|
||||
*
|
||||
* Every case asserts the kind as well as the count. A seam that posted something for all five
|
||||
* names would pass a test that only counted, and the five kinds are the whole content of the frame.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BRIDGE_HAPTICS_KINDS } from '../mobile-web-shell/bridge/bridge-haptics-notify'
|
||||
import {
|
||||
publishHapticsNotifier,
|
||||
triggerEdgeBump,
|
||||
triggerError,
|
||||
triggerMediumImpact,
|
||||
triggerSelection,
|
||||
triggerSuccess
|
||||
} from './haptics.web'
|
||||
|
||||
const asked: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
asked.length = 0
|
||||
publishHapticsNotifier((kind) => {
|
||||
asked.push(kind)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('the haptic each page function asks the shell for', () => {
|
||||
// Kind first, because the title reads the first argument: `%#` consumes none, so with the
|
||||
// function in front `%s` printed its whole body as the name of the case.
|
||||
it.each([
|
||||
['mediumImpact', triggerMediumImpact],
|
||||
['selection', triggerSelection],
|
||||
['success', triggerSuccess],
|
||||
['error', triggerError],
|
||||
['edgeBump', triggerEdgeBump]
|
||||
] as const)('posts exactly one notify, carrying %s', (kind, trigger) => {
|
||||
trigger()
|
||||
expect(asked).toEqual([kind])
|
||||
})
|
||||
|
||||
it('covers every kind the notify accepts, so no name is left on a no-op', () => {
|
||||
// The two halves measured against each other: the five functions the app's screens call, and
|
||||
// the five kinds the frame admits. A function missing here is a dead tap on the page.
|
||||
for (const trigger of [
|
||||
triggerMediumImpact,
|
||||
triggerSelection,
|
||||
triggerSuccess,
|
||||
triggerError,
|
||||
triggerEdgeBump
|
||||
]) {
|
||||
trigger()
|
||||
}
|
||||
expect([...asked].sort()).toEqual([...BRIDGE_HAPTICS_KINDS].sort())
|
||||
})
|
||||
|
||||
it('posts one frame per call, because a scrolling list calls once per row', () => {
|
||||
for (let row = 0; row < 12; row += 1) {
|
||||
triggerSelection()
|
||||
}
|
||||
expect(asked).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a shell that will not play it', () => {
|
||||
it('says nothing, because nobody reads the answer and every row tap would say it again', () => {
|
||||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
publishHapticsNotifier(() => false)
|
||||
expect(() => triggerSelection()).not.toThrow()
|
||||
expect(warned).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks nothing at all in a document that published no notifier', async () => {
|
||||
// A fresh module, because the notifier is module state and every case above has published one.
|
||||
vi.resetModules()
|
||||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const fresh: typeof import('./haptics.web') = await import('./haptics.web')
|
||||
expect(() => fresh.triggerError()).not.toThrow()
|
||||
expect(asked).toEqual([])
|
||||
expect(warned).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,52 @@
|
||||
import type { BridgeHapticsKind } from '../mobile-web-shell/bridge/bridge-haptics-notify'
|
||||
|
||||
/**
|
||||
* Haptics inside the shell's page: nothing at all.
|
||||
* Haptics inside the shell's page: the device's own, played by the app on the page's behalf.
|
||||
*
|
||||
* expo-haptics has a web build, and that is the problem rather than the solution. With no
|
||||
* Not `expo-haptics`. It has a web build, and that is the problem rather than the solution: with no
|
||||
* `navigator.vibrate` — iOS Safari, which is the WebView the page runs in — it fakes a haptic by
|
||||
* appending a hidden `<label><input type="checkbox" switch>` to `document.head`, clicking it, and
|
||||
* removing it again, once per call. C1.9 traced a long press that never fired on the worktree list
|
||||
* to exactly that stray click, and the file explorer calls `triggerSelection` on every row tap.
|
||||
*
|
||||
* So the page has no haptics. A phone holding the page is a phone whose native app is right there
|
||||
* with the real ones, and a missing tap feedback is worth less than a tap that does not register.
|
||||
* So the page asks the shell instead, over the `native.haptics.trigger` notify: one frame of 70 to
|
||||
* 77 bytes, no reply, and the app's own `Platform.OS` split on the other side. A notify rather than
|
||||
* a verb because nothing is owed back — a reply would spend a slot in the same in-flight window a
|
||||
* forwarded request does, and there are 90 call sites in this app (rulings-ota-c7.md ruling 30).
|
||||
*
|
||||
* Published by the entry rather than read from context, because the callers are plain functions in
|
||||
* render trees the provider does not wrap — the same reason `publishExternalLinkOpener` exists. A
|
||||
* document that published none, or a shell that granted no `haptics`, plays nothing and says
|
||||
* nothing: a warning here would be one per row of a scrolling list, and nobody reads the answer.
|
||||
*
|
||||
* Same five names as the native file, because that is what makes this a substitution: an export
|
||||
* added there and missing here is a build error in the bundle, not a silent no-op.
|
||||
*/
|
||||
export function triggerMediumImpact(): void {}
|
||||
type HapticsNotifier = (kind: BridgeHapticsKind) => boolean
|
||||
|
||||
export function triggerSelection(): void {}
|
||||
let post: HapticsNotifier = () => false
|
||||
|
||||
export function triggerSuccess(): void {}
|
||||
/** Called once by the entry, with the page client's own notify. */
|
||||
export function publishHapticsNotifier(notify: HapticsNotifier): void {
|
||||
post = notify
|
||||
}
|
||||
|
||||
export function triggerError(): void {}
|
||||
export function triggerMediumImpact(): void {
|
||||
post('mediumImpact')
|
||||
}
|
||||
|
||||
export function triggerEdgeBump(): void {}
|
||||
export function triggerSelection(): void {
|
||||
post('selection')
|
||||
}
|
||||
|
||||
export function triggerSuccess(): void {
|
||||
post('success')
|
||||
}
|
||||
|
||||
export function triggerError(): void {
|
||||
post('error')
|
||||
}
|
||||
|
||||
export function triggerEdgeBump(): void {
|
||||
post('edgeBump')
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from './test-support/census-source-files'
|
||||
|
||||
const mobileDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const scanned = ['src', 'app']
|
||||
@@ -29,16 +30,6 @@ const MAPPER_HOOKS = new Map([
|
||||
['useAnimatedReaction', { updaters: [0, 1], dependencies: 2 }]
|
||||
])
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return sourceExtensions.has(extname(entry.name)) ? [path] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether this `X.value` is being written rather than read. A write is an output, not an input. */
|
||||
function isWriteTarget(node: ts.PropertyAccessExpression): boolean {
|
||||
const parent = node.parent
|
||||
@@ -200,11 +191,13 @@ describe('reanimated mapper hooks in the web bundle', () => {
|
||||
it('are all given a dependency array, because esbuild writes no worklet closure', () => {
|
||||
const found: string[] = []
|
||||
const missing = scanned.flatMap((directory) =>
|
||||
sourceFiles(join(mobileDirectory, directory)).flatMap((path) =>
|
||||
path.endsWith('.test.ts') || path.endsWith('.test.tsx')
|
||||
? []
|
||||
: callsMissingDependencies(path, readFileSync(path, 'utf8'), found)
|
||||
)
|
||||
censusSourceFiles(join(mobileDirectory, directory))
|
||||
.filter((path) => sourceExtensions.has(extname(path)))
|
||||
.flatMap((path) =>
|
||||
path.endsWith('.test.ts') || path.endsWith('.test.tsx')
|
||||
? []
|
||||
: callsMissingDependencies(path, readFileSync(path, 'utf8'), found)
|
||||
)
|
||||
)
|
||||
// The precondition the empty list above rests on. Binding resolution means a broken resolver
|
||||
// reports nothing at all, which would read exactly like a clean tree.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from './test-support/census-source-files'
|
||||
|
||||
// Why: src/shared/rpc-contract/*-params.ts hold the host's zod schemas. Bundling one
|
||||
// into the app would let client code call parse(), and requiredString is
|
||||
@@ -13,16 +14,6 @@ const contractRoot = resolve(mobileRoot, '..', 'src', 'shared', 'rpc-contract')
|
||||
const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory))
|
||||
const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
function targetsContract(path: string, specifier: string): boolean {
|
||||
if (!specifier.startsWith('.')) {
|
||||
return false
|
||||
@@ -131,7 +122,7 @@ describe('RPC params contract boundary', () => {
|
||||
|
||||
it('keeps every mobile import of the params contract type-only', () => {
|
||||
const offenders = scannedRoots
|
||||
.flatMap(sourceFiles)
|
||||
.flatMap(censusSourceFiles)
|
||||
.filter((path) => sourceExtensions.has(extname(path)))
|
||||
.flatMap((path) =>
|
||||
contractValueImports(path, readFileSync(path, 'utf8')).map(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles, isGeneratedSource } from './census-source-files'
|
||||
|
||||
const mobileRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
let scratch = ''
|
||||
|
||||
beforeEach(() => {
|
||||
scratch = mkdtempSync(join(tmpdir(), 'orca-census-source-files-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function plant(relativePath: string, source: string): void {
|
||||
const absolute = join(scratch, relativePath)
|
||||
mkdirSync(join(absolute, '..'), { recursive: true })
|
||||
writeFileSync(absolute, source, 'utf8')
|
||||
}
|
||||
|
||||
describe('the source files a census reads', () => {
|
||||
it('leaves out build output that would be read as a violating import', () => {
|
||||
// The shape that started this: a generated file whose text holds exactly what a census is
|
||||
// looking for. Every one of them is minified vendor output, so the match is a token in
|
||||
// somebody else's code and the census has no line to offer anybody.
|
||||
const violating = "import { requiredString } from '../../src/shared/rpc-contract/params'\n"
|
||||
plant('src/components/engine.generated.ts', violating)
|
||||
plant('src/components/Diagram.tsx', violating)
|
||||
|
||||
const walked = censusSourceFiles(join(scratch, 'src')).map((path) => relative(scratch, path))
|
||||
expect(walked).toEqual([join('src', 'components', 'Diagram.tsx')])
|
||||
// Both halves: the generated file is gone, and the file beside it carrying the same text is
|
||||
// not — a walk that returned nothing at all would satisfy the first line on its own.
|
||||
expect(readFileSync(join(scratch, 'src/components/engine.generated.ts'), 'utf8')).toBe(
|
||||
violating
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves out node_modules, and keeps everything else', () => {
|
||||
plant('src/a.ts', '')
|
||||
plant('src/node_modules/dep/index.ts', '')
|
||||
plant('src/deep/b.tsx', '')
|
||||
plant('src/notes.md', '')
|
||||
expect(
|
||||
censusSourceFiles(join(scratch, 'src'))
|
||||
.map((path) => relative(scratch, path))
|
||||
.sort()
|
||||
).toEqual([join('src', 'a.ts'), join('src', 'deep', 'b.tsx'), join('src', 'notes.md')].sort())
|
||||
})
|
||||
|
||||
it('names build output by the suffix the generators write, and nothing else', () => {
|
||||
expect(isGeneratedSource('mermaid-page-engine.generated.ts')).toBe(true)
|
||||
expect(isGeneratedSource('route-manifest.generated.tsx')).toBe(true)
|
||||
expect(isGeneratedSource('generated.ts')).toBe(false)
|
||||
expect(isGeneratedSource('rpc-client.ts')).toBe(false)
|
||||
expect(isGeneratedSource('generated-goldens.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('covers every artifact the tree generates, read from the ignore file that lists them', () => {
|
||||
// The list is `mobile/.gitignore`, because that is what the generators and the build agree on.
|
||||
// A seventh artifact landing under a name this predicate does not match would put a multi-megabyte
|
||||
// vendor bundle back into every census, which is the failure this module exists for.
|
||||
const ignored = readFileSync(join(mobileRoot, '.gitignore'), 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.endsWith('.generated.ts'))
|
||||
expect(ignored.length).toBeGreaterThanOrEqual(5)
|
||||
for (const entry of ignored) {
|
||||
expect(isGeneratedSource(entry), entry).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('is the only walk of its kind left in the tree', () => {
|
||||
// The line every census used to hold a copy of. One spelling, so a tenth census cannot quietly
|
||||
// reintroduce the cost by pasting the walk rather than importing it.
|
||||
const copies: string[] = []
|
||||
const walk = (directory: string): void => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules') {
|
||||
walk(path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!/\.tsx?$/.test(entry.name) || isGeneratedSource(entry.name)) {
|
||||
continue
|
||||
}
|
||||
if (readFileSync(path, 'utf8').includes("entry.name === 'node_modules' ? []")) {
|
||||
copies.push(relative(mobileRoot, path))
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(join(mobileRoot, 'src'))
|
||||
walk(join(mobileRoot, 'app'))
|
||||
// This file is in the list because it carries the line as the text it greps for; the module
|
||||
// beside it is the walk itself. Named rather than filtered out, so a third entry is a failure
|
||||
// that reads as one.
|
||||
expect(copies.sort()).toEqual([
|
||||
'src/test-support/census-source-files.test.ts',
|
||||
'src/test-support/census-source-files.ts'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Build output, which a source census reads as source and must not.
|
||||
*
|
||||
* `mobile/.gitignore` is the list: six `*.generated.ts` files under `mobile/src`, written by the
|
||||
* four postinstall generators. Two are vendored engines — 3.7 MB of mermaid for the native WebView
|
||||
* and 3.5 MB of it for the page — and 7.9 MB of what a walk over this tree returns is generated. A
|
||||
* census that parses them parses minified third-party code looking for call sites nobody in this
|
||||
* repo wrote and nobody can move, and pays the whole parse to find them: five of those files is
|
||||
* what took `rpc-params-contract-type-only-boundary` from 1.5 s to over its 5 s timeout in CI.
|
||||
*
|
||||
* The sixth is the page's copy of the terminal document (C7.5b), which is this repo's own emitted
|
||||
* text rather than a vendored bundle — and is walked as source at every one of its 38 modules.
|
||||
*
|
||||
* The generator that writes each one is ordinary source and is still walked, which is where a real
|
||||
* reach into whatever a census is fencing would be.
|
||||
*/
|
||||
export function isGeneratedSource(name: string): boolean {
|
||||
return /\.generated\.tsx?$/.test(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file under `directory`, absolute, without `node_modules` or build output.
|
||||
*
|
||||
* Nine censuses in this tree held a copy of this walk, and two of them had grown a private opinion
|
||||
* about generated files while the rest had none. What each census counts as *interesting* — which
|
||||
* extensions, whether test files are in — stays its own business, because they genuinely disagree;
|
||||
* what counts as a source file at all does not.
|
||||
*/
|
||||
export function censusSourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : censusSourceFiles(path)
|
||||
}
|
||||
return isGeneratedSource(entry.name) ? [] : [path]
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
import {
|
||||
GenerationScopedRequestOwner,
|
||||
type LoadedRequest,
|
||||
@@ -370,16 +371,6 @@ function importsOwner(path: string, source: string): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
function declaredInside(callback: ts.Node): Set<string> {
|
||||
const names = new Set<string>()
|
||||
const bind = (name: ts.BindingName): void => {
|
||||
@@ -492,7 +483,7 @@ function loaderWrites(path: string, source: string): string[] {
|
||||
describe('loader write fence', () => {
|
||||
const holders = ['app', 'src']
|
||||
.map((directory) => join(mobileRoot, directory))
|
||||
.flatMap(sourceFiles)
|
||||
.flatMap(censusSourceFiles)
|
||||
.filter((path) => ['.ts', '.tsx'].includes(extname(path)))
|
||||
.filter((path) => !/\.test\.tsx?$/.test(path))
|
||||
.map((path) => ({ path, source: readFileSync(path, 'utf8') }))
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
|
||||
/**
|
||||
* Bans the escapes that would make the typed boundary decorative.
|
||||
@@ -68,16 +69,6 @@ const CAST_FENCE_EXCEPTIONS: readonly CastFenceException[] = [
|
||||
// inside a string literal therefore reads as one — which fails closed.
|
||||
const SUPPRESSION = /@ts-(?:expect-error|ignore|nocheck)\b/
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
function parse(path: string, source: string): ts.SourceFile {
|
||||
const extension = extname(path)
|
||||
return ts.createSourceFile(
|
||||
@@ -150,7 +141,7 @@ function moduleEdges(path: string, source: string): { imports: string[]; reExpor
|
||||
}
|
||||
|
||||
const scanned = scannedRoots
|
||||
.flatMap(sourceFiles)
|
||||
.flatMap(censusSourceFiles)
|
||||
.filter((path) => sourceExtensions.has(extname(path)))
|
||||
.filter((path) => !/\.test\.tsx?$/.test(path))
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
import { readScenarios } from '../test-support/rpc-recording/scenario-input'
|
||||
import { RPC_SUBSCRIPTION_SITES, type RpcSubscriptionSite } from './rpc-subscription-inventory'
|
||||
|
||||
@@ -38,16 +39,6 @@ const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
|
||||
/** The port's own implementation and the oracle that scripts it. Neither consumes a stream. */
|
||||
const EXCLUDED_DIRECTORIES = ['src/transport/', 'src/test-support/']
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
function parse(path: string, source: string): ts.SourceFile {
|
||||
const extension = extname(path)
|
||||
return ts.createSourceFile(
|
||||
@@ -80,7 +71,7 @@ export function subscribedMethods(path: string, source: string): string[] {
|
||||
}
|
||||
|
||||
const scanned = scannedRoots
|
||||
.flatMap(sourceFiles)
|
||||
.flatMap(censusSourceFiles)
|
||||
.filter((path) => sourceExtensions.has(extname(path)))
|
||||
.filter((path) => !/\.test\.tsx?$/.test(path))
|
||||
.map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/'))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript-api'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const TIMER_GLOBALS = new Set(['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'])
|
||||
@@ -28,9 +30,9 @@ const PARKING_OPERATORS = new Set([
|
||||
type Census = { parked: string[]; shared: string[] }
|
||||
|
||||
function productFiles(): string[] {
|
||||
return readdirSync(SOURCE_ROOT, { recursive: true, encoding: 'utf8' })
|
||||
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$|\.generated\.ts$/.test(entry))
|
||||
.map((entry) => entry.replaceAll('\\', '/'))
|
||||
return censusSourceFiles(SOURCE_ROOT)
|
||||
.map((path) => relative(SOURCE_ROOT, path).replaceAll('\\', '/'))
|
||||
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry))
|
||||
}
|
||||
|
||||
function timerName(node: ts.Node): string | null {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
import {
|
||||
UNCHECKED_RPC_READERS,
|
||||
type UncheckedRpcReaderEntry
|
||||
@@ -46,16 +47,6 @@ const UNCHECKED_READER_NAMES = new Set([
|
||||
// them in prose alone.
|
||||
const SELF_FILES = new Set(['src/transport/rpc-reader-payload.ts'])
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
function parse(path: string, source: string): ts.SourceFile {
|
||||
const extension = extname(path)
|
||||
return ts.createSourceFile(
|
||||
@@ -85,7 +76,7 @@ function uncheckedReaderCount(path: string, source: string): number {
|
||||
}
|
||||
|
||||
const scanned = scannedRoots
|
||||
.flatMap(sourceFiles)
|
||||
.flatMap(censusSourceFiles)
|
||||
.filter((path) => sourceExtensions.has(extname(path)))
|
||||
.filter((path) => !/\.test\.tsx?$/.test(path))
|
||||
.map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/'))
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
import {
|
||||
UNVALIDATED_RPC_REQUEST_PORT_OWNERS,
|
||||
UNVALIDATED_RPC_REQUEST_PORT_PENDING,
|
||||
@@ -33,6 +34,10 @@ import {
|
||||
* - Test files. `*.test.ts(x)` is not scanned: faking the port is how these suites work, and a
|
||||
* test does not ship. A non-test file that fakes it (tsconfig excludes tests, so some do) is
|
||||
* scanned and listed.
|
||||
* - Build output. `censusSourceFiles` leaves every `*.generated.ts` out, and one of them is a
|
||||
* bundled vendor engine whose own dependencies contain the token `sendRequest` — minified
|
||||
* third-party code, not a call site anybody in this repo wrote or can move onto an
|
||||
* RpcOperation. The script that emits each of them is ordinary source and is walked.
|
||||
* A compile-time fence would catch the first two. That needs `RpcClient` to stop carrying the
|
||||
* port, which needs the call sites migrated first — the thing this list is counting down.
|
||||
*/
|
||||
@@ -51,16 +56,6 @@ const SELF_FILES = new Set([
|
||||
/** The coalescing second sender: same unchecked string in, same unread envelope out. */
|
||||
const SECOND_SENDER = 'sendSingleFlightRequest'
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
function parse(path: string, source: string): ts.SourceFile {
|
||||
const extension = extname(path)
|
||||
return ts.createSourceFile(
|
||||
@@ -144,7 +139,7 @@ const inventory: readonly UnvalidatedRpcRequestPortEntry[] = [
|
||||
]
|
||||
|
||||
const scanned = scannedRoots
|
||||
.flatMap(sourceFiles)
|
||||
.flatMap(censusSourceFiles)
|
||||
.filter((path) => sourceExtensions.has(extname(path)))
|
||||
.filter((path) => !/\.test\.tsx?$/.test(path))
|
||||
.map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/'))
|
||||
|
||||
@@ -14,6 +14,7 @@ import { publishPageStorage } from '../src/mobile-web-shell/bridge/page-async-st
|
||||
import { PageFaultBoundary } from '../src/mobile-web-shell/bridge/page-fault-boundary'
|
||||
import { publishPageHostProfile } from '../src/mobile-web-shell/bridge/page-host-profile'
|
||||
import { publishExternalLinkOpener } from '../src/platform/external-link.web'
|
||||
import { publishHapticsNotifier } from '../src/platform/haptics.web'
|
||||
// Named with its extension: this entry is the web build's and the provider it needs is the web
|
||||
// sibling's, which takes the page's client. The screens below still import `./client-context`
|
||||
// and reach the same module, because the builder resolves both specifiers to the same file.
|
||||
@@ -82,6 +83,9 @@ bootstrapShellPage({
|
||||
// Same reason, and the same shape: the seam is a plain function in render trees the provider
|
||||
// does not wrap, so the client's notify is published rather than read from context.
|
||||
publishExternalLinkOpener((url) => client.notifyExternalLink(url))
|
||||
// The same shape again, and for the same reason: every haptic on this page is played from a
|
||||
// plain function inside a row's press handler, which no provider wraps.
|
||||
publishHapticsNotifier((kind) => client.notifyHaptics(kind))
|
||||
// Scoped to the host `init` named: with none, no key is writable, which is the right answer
|
||||
// for a shell too old to say whose list this is.
|
||||
publishPageStorage(
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/platform/haptics.web.ts",
|
||||
"reason": "expo-haptics has a web build that fakes an iOS haptic by appending a hidden <label><input type=\"checkbox\" switch> to document.head, clicking it and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to that stray click, and the file explorer calls triggerSelection on every row tap. The page has no haptics instead; the bundle test reads the shipped bytes for the shim."
|
||||
"reason": "expo-haptics has a web build that fakes an iOS haptic by appending a hidden <label><input type=\"checkbox\" switch> to document.head, clicking it and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to that stray click, and the file explorer calls triggerSelection on every row tap. So the page asks the shell for the device's own haptic instead, over the native.haptics.trigger notify behind the haptics grant: 70 to 77 bytes per frame, no reply, and haptics.ts's own Platform.OS split on the other side. A notify rather than a verb because nothing is owed back and a reply would spend a slot in the same in-flight window a forwarded request does, at 90 call sites (rulings-ota-c7.md ruling 30). The five exports are the five kinds the frame admits; mobile-web-app-haptics-seam.test.mjs is the fence."
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/files/[worktreeId].web.tsx",
|
||||
@@ -63,7 +63,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/components/pr-sidebar/MermaidDiagram.web.tsx",
|
||||
"reason": "The native component renders the diagram inside a sandboxed WebView, and react-native-webview is a native component with no browser counterpart: importing it runs a codegen lookup that throws, and the route manifest imports every route, so one such import takes the whole page down rather than one diagram. This one renders the labelled source box the native component already falls back to on a parse or render error. A real browser renderer is reachable — mermaid is a browser library and the engine bundle is vendored — but it is a different shape rather than a smaller one: with no WebView to sandbox untrusted diagram source in, the escaping buildHtml does for </script> and the line separators has to be replaced by whatever the DOM path needs, which is its own change with its own proof."
|
||||
"reason": "The native component seals the diagram inside a WebView whose document embeds the whole mermaid bundle as a string, because react-native-webview is a native component with no browser counterpart: importing it runs a codegen lookup that throws, and the route manifest imports every route, so one such import takes the whole page down rather than one diagram. This one renders the same diagram in this document instead — mermaid is a browser library, so it is an import() inside the render effect rather than a 3.7 MB literal, and the two hosts share one MERMAID_DIAGRAM_CONFIG. What replaces the sandbox is mermaid's own securityLevel: 'strict', which runs the serialized SVG through DOMPurify; the native path's </script> escaping has no analogue because the source is a JS string argument rather than text spliced into an inline script. Both are measured in both engines under the shipped CSP by config/scripts/mobile-web-app-mermaid-render.test.mjs, which also pins the rendered SVG byte for byte against the native document's own render."
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/tasks.web.tsx",
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
|
||||
import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import { AgentHookServer } from './server'
|
||||
import type { OpenCodeBinderLoopDeps } from './server/server-opencode-binder'
|
||||
import { defaultOpenCodeDbPath, listOpenCodeDbSessions } from '../opencode/opencode-session-binder'
|
||||
|
||||
const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const PANE_A = makePaneKey('binder-a', LEAF_A)
|
||||
const PANE_B = makePaneKey('binder-b', LEAF_B)
|
||||
const DIR = '/tmp/binder-worktree-a'
|
||||
|
||||
class BinderTestServer extends AgentHookServer {
|
||||
public bindDeps(deps: Partial<OpenCodeBinderLoopDeps>): void {
|
||||
this._setOpenCodeBinderDepsForTests(deps)
|
||||
}
|
||||
|
||||
public runBinderRound(): Promise<number> {
|
||||
return this.runOpenCodeBinderRoundOnce()
|
||||
}
|
||||
|
||||
public startBinderLoop(): void {
|
||||
this.startOpenCodeBinderLoop()
|
||||
}
|
||||
|
||||
public ingest(source: AgentHookSource, body: unknown): void {
|
||||
this.normalizeLocalHookPayload(source, body)
|
||||
}
|
||||
|
||||
public readRegistry(sessionId: string): string | undefined {
|
||||
return lookupOpenCodeSessionPane(this._getStateForTests(), sessionId)?.paneKey
|
||||
}
|
||||
}
|
||||
|
||||
function writeDb(dbPath: string, table: 'session_v2' | 'session'): void {
|
||||
const db = new SyncDatabase(dbPath)
|
||||
try {
|
||||
db.exec(
|
||||
`CREATE TABLE ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)`
|
||||
)
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
insert.run('ses_live', DIR, Date.now() - 60_000, null)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
describe('opencode binder loop', () => {
|
||||
let dir = ''
|
||||
let dbPath = ''
|
||||
let server: BinderTestServer
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'binder-db-'))
|
||||
dbPath = join(dir, 'opencode.db')
|
||||
server = new BinderTestServer()
|
||||
server.bindDeps({
|
||||
now: () => Date.now(),
|
||||
dbPath: () => dbPath,
|
||||
listPanes: () => [
|
||||
{ paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 }
|
||||
],
|
||||
sweep: async () => [
|
||||
{ pid: 112, ppid: 111, startedAtMs: Date.now() - 120_000, executable: 'opencode', argv: ['opencode'] }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('binds a fresh session to its pane', async () => {
|
||||
writeDb(dbPath, 'session_v2')
|
||||
const applied = await server.runBinderRound()
|
||||
expect(applied).toBe(1)
|
||||
expect(server.readRegistry('ses_live')).toBe(PANE_A)
|
||||
})
|
||||
|
||||
it('falls back to the v1 session table', async () => {
|
||||
writeDb(dbPath, 'session')
|
||||
const applied = await server.runBinderRound()
|
||||
expect(applied).toBe(1)
|
||||
expect(server.readRegistry('ses_live')).toBe(PANE_A)
|
||||
})
|
||||
|
||||
it('an opencode SessionStart kicks a round that binds before the poll', async () => {
|
||||
writeDb(dbPath, 'session_v2')
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// Birth arrives stamped with the wrong (server-starter) pane.
|
||||
server.ingest('opencode', {
|
||||
paneKey: PANE_B,
|
||||
launchToken: '',
|
||||
payload: { hook_event_name: 'SessionStart', sessionID: 'ses_live' }
|
||||
})
|
||||
expect(server.readRegistry('ses_live')).toBeUndefined()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(server.readRegistry('ses_live')).toBe(PANE_A)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('pane teardown unbinds its sessions', async () => {
|
||||
writeDb(dbPath, 'session_v2')
|
||||
await server.runBinderRound()
|
||||
expect(server.readRegistry('ses_live')).toBe(PANE_A)
|
||||
server.clearPaneState(PANE_A)
|
||||
expect(server.readRegistry('ses_live')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops the loop without hanging the process', () => {
|
||||
writeDb(dbPath, 'session_v2')
|
||||
expect(() => server.stop()).not.toThrow()
|
||||
})
|
||||
|
||||
it('runs a round immediately on loop start', async () => {
|
||||
writeDb(dbPath, 'session_v2')
|
||||
server.startBinderLoop()
|
||||
try {
|
||||
await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A))
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('discards a round that was in flight across stop', async () => {
|
||||
writeDb(dbPath, 'session_v2')
|
||||
let releaseSweep!: () => void
|
||||
const sweepGate = new Promise<void>((resolve) => {
|
||||
releaseSweep = resolve
|
||||
})
|
||||
server.bindDeps({
|
||||
sweep: async () => {
|
||||
await sweepGate
|
||||
return [
|
||||
{
|
||||
pid: 112,
|
||||
ppid: 111,
|
||||
startedAtMs: Date.now() - 120_000,
|
||||
executable: 'opencode',
|
||||
argv: ['opencode']
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const round = server.runBinderRound()
|
||||
server.stop()
|
||||
releaseSweep()
|
||||
expect(await round).toBe(0)
|
||||
expect(server.readRegistry('ses_live')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an obsolete round does not clear the new round running flag', async () => {
|
||||
writeDb(dbPath, 'session_v2')
|
||||
let releaseFirst!: () => void
|
||||
let releaseLater!: () => void
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
const laterGate = new Promise<void>((resolve) => {
|
||||
releaseLater = resolve
|
||||
})
|
||||
const clientRow = {
|
||||
pid: 112,
|
||||
ppid: 111,
|
||||
startedAtMs: Date.now() - 120_000,
|
||||
executable: 'opencode',
|
||||
argv: ['opencode']
|
||||
}
|
||||
let sweepCalls = 0
|
||||
server.bindDeps({
|
||||
sweep: async () => {
|
||||
sweepCalls += 1
|
||||
await (sweepCalls === 1 ? firstGate : laterGate)
|
||||
return [clientRow]
|
||||
}
|
||||
})
|
||||
server.startBinderLoop()
|
||||
await vi.waitFor(() => expect(sweepCalls).toBe(1))
|
||||
server.stop()
|
||||
server.startBinderLoop()
|
||||
await vi.waitFor(() => expect(sweepCalls).toBe(2))
|
||||
// The obsolete round finishes while the new round is still parked: its
|
||||
// finally must not clear the flag the new round holds.
|
||||
releaseFirst()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
// A third round attempted now must be refused at the flag check, calling
|
||||
// no sweep. With the unguarded finally it would be admitted instead.
|
||||
const extraRound = server.runBinderRound()
|
||||
expect(sweepCalls).toBe(2)
|
||||
releaseLater()
|
||||
await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A))
|
||||
await extraRound
|
||||
server.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listOpenCodeDbSessions', () => {
|
||||
let dir = ''
|
||||
let dbPath = ''
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'binder-reader-'))
|
||||
dbPath = join(dir, 'opencode.db')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads session_v2 rows newer than the watermark', () => {
|
||||
writeDb(dbPath, 'session_v2')
|
||||
const rows = listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ id: 'ses_live', directory: DIR, parentId: null })
|
||||
expect(listOpenCodeDbSessions(dbPath, { ms: Date.now(), id: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for a missing database instead of throwing', () => {
|
||||
expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('the default path points at the local opencode store', () => {
|
||||
expect(defaultOpenCodeDbPath()).toMatch(/opencode\.db$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('binder registry isolation', () => {
|
||||
it('a fresh listener state starts unbound', () => {
|
||||
const state = createHookListenerState()
|
||||
expect(lookupOpenCodeSessionPane(state, 'ses_live')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,9 @@ import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook
|
||||
import { normalizeHookPayload } from '../../../shared/agent-hook-listener'
|
||||
import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay'
|
||||
import type { NormalizedLocalHook } from './server-types'
|
||||
import { AgentHookServerPersistence } from './server-persistence'
|
||||
import { AgentHookServerOpenCodeBinder } from './server-opencode-binder'
|
||||
|
||||
export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence {
|
||||
export abstract class AgentHookServerIngestNormalization extends AgentHookServerOpenCodeBinder {
|
||||
protected setClaudeBackgroundEvidence(
|
||||
paneKey: string,
|
||||
hasRunningTask: boolean,
|
||||
@@ -24,7 +24,16 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer
|
||||
|
||||
protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook {
|
||||
if (source !== 'claude' || typeof body !== 'object' || body === null) {
|
||||
return { event: normalizeHookPayload(this.state, source, body, this.env) }
|
||||
const event = normalizeHookPayload(this.state, source, body, this.env)
|
||||
if (
|
||||
event &&
|
||||
(source === 'opencode' || source === 'mimo-code') &&
|
||||
event.hookEventName === 'SessionStart'
|
||||
) {
|
||||
// Why: a birth just arrived; bind it now instead of waiting out the poll interval.
|
||||
this.kickOpenCodeBinder()
|
||||
}
|
||||
return { event }
|
||||
}
|
||||
const rawPaneKey = (body as Record<string, unknown>).paneKey
|
||||
const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : ''
|
||||
|
||||
@@ -178,6 +178,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
this.rollbackTransportStart()
|
||||
throw error
|
||||
}
|
||||
this.startOpenCodeBinderLoop()
|
||||
}
|
||||
|
||||
private rollbackTransportStart(): void {
|
||||
@@ -191,6 +192,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
stop(): void {
|
||||
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
|
||||
this.flushStatusPersistSync()
|
||||
this.stopOpenCodeBinderLoop()
|
||||
this.rollbackTransportStart()
|
||||
this.env = 'production'
|
||||
this.onAgentStatus = null
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
advanceBinderCursor,
|
||||
applyBinderOwnerships,
|
||||
defaultOpenCodeDbPath,
|
||||
listBinderPaneSnapshots,
|
||||
listOpenCodeDbSessions,
|
||||
OPENCODE_SESSION_CURSOR_START,
|
||||
runOpenCodeBinderRound,
|
||||
type BinderPaneSnapshot,
|
||||
type BinderSessionRow,
|
||||
type OpenCodeSessionCursor
|
||||
} from '../../opencode/opencode-session-binder'
|
||||
import {
|
||||
sweepProcessIdentities,
|
||||
type ProcessIdentityRow
|
||||
} from '../../opencode/opencode-client-sweep'
|
||||
import { lookupOpenCodeSessionPane } from '../../../shared/agent-hook-listener/opencode-session-registry'
|
||||
import { AgentHookServerPersistence } from './server-persistence'
|
||||
|
||||
/** Poll cadence; hook-triggered kicks cover births between polls. */
|
||||
const OPENCODE_BINDER_INTERVAL_MS = 60_000
|
||||
const OPENCODE_BINDER_KICK_DEBOUNCE_MS = 10_000
|
||||
/** Unbound sessions get re-correlated this long (pane inventory may lag births). */
|
||||
const OPENCODE_BINDER_UNBOUND_RETRY_MS = 10 * 60_000
|
||||
const OPENCODE_BINDER_PARENTS_MAX = 2_000
|
||||
const OPENCODE_BINDER_UNBOUND_MAX = 500
|
||||
|
||||
/** Injectable I/O for the binder loop; real singletons by default, fakes in tests. */
|
||||
export type OpenCodeBinderLoopDeps = {
|
||||
now: () => number
|
||||
dbPath: () => string
|
||||
listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => BinderSessionRow[]
|
||||
listPanes: () => BinderPaneSnapshot[]
|
||||
sweep: () => Promise<ProcessIdentityRow[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Session→pane binder loop for the shared OpenCode server (#21359).
|
||||
*
|
||||
* Sits just above persistence in the chain so ingest layers can kick a round
|
||||
* when a birth arrives early, and lifecycle can start/stop the timer. All
|
||||
* I/O rides injectable deps (real singletons by default) so tests drive the
|
||||
* whole loop without touching the user's opencode.db or process table.
|
||||
*/
|
||||
export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersistence {
|
||||
private openCodeBinderTimer: ReturnType<typeof setInterval> | null = null
|
||||
private openCodeBinderKickTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private openCodeBinderRunning = false
|
||||
private openCodeBinderGeneration = 0
|
||||
private openCodeBinderWatermark: OpenCodeSessionCursor = { ...OPENCODE_SESSION_CURSOR_START }
|
||||
private openCodeBinderParents = new Map<string, string | null>()
|
||||
private openCodeBinderUnbound = new Map<string, { row: BinderSessionRow; firstSeenMs: number }>()
|
||||
private openCodeBinderDeps: OpenCodeBinderLoopDeps = {
|
||||
now: () => Date.now(),
|
||||
dbPath: () => defaultOpenCodeDbPath(),
|
||||
listSessions: (dbPath, sinceMs) => listOpenCodeDbSessions(dbPath, sinceMs),
|
||||
listPanes: () => listBinderPaneSnapshots(),
|
||||
sweep: () => sweepProcessIdentities()
|
||||
}
|
||||
|
||||
/** Test seam: drive the loop without the user's database or process table. */
|
||||
protected _setOpenCodeBinderDepsForTests(deps: Partial<OpenCodeBinderLoopDeps>): void {
|
||||
this.openCodeBinderDeps = { ...this.openCodeBinderDeps, ...deps }
|
||||
}
|
||||
|
||||
/** Start the 60s poll loop plus one immediate round, idempotently. */
|
||||
protected startOpenCodeBinderLoop(): void {
|
||||
if (this.openCodeBinderTimer) {
|
||||
return
|
||||
}
|
||||
this.openCodeBinderTimer = setInterval(() => {
|
||||
void this.runOpenCodeBinderRoundOnce()
|
||||
}, OPENCODE_BINDER_INTERVAL_MS)
|
||||
if (this.openCodeBinderTimer.unref) {
|
||||
this.openCodeBinderTimer.unref()
|
||||
}
|
||||
// Why immediately: existing sessions would otherwise keep the frozen
|
||||
// stamp for up to a full interval after launch or restart.
|
||||
void this.runOpenCodeBinderRoundOnce()
|
||||
}
|
||||
|
||||
/** Stop timers and drop ephemeral binder state; in-flight rounds are discarded by generation. */
|
||||
protected stopOpenCodeBinderLoop(): void {
|
||||
// Why the generation bump: a round awaiting the process sweep must not
|
||||
// apply ownerships — or resurrect the watermark — after the loop stopped.
|
||||
this.openCodeBinderGeneration += 1
|
||||
if (this.openCodeBinderTimer) {
|
||||
clearInterval(this.openCodeBinderTimer)
|
||||
this.openCodeBinderTimer = null
|
||||
}
|
||||
if (this.openCodeBinderKickTimer) {
|
||||
clearTimeout(this.openCodeBinderKickTimer)
|
||||
this.openCodeBinderKickTimer = null
|
||||
}
|
||||
this.openCodeBinderRunning = false
|
||||
this.openCodeBinderWatermark = { ...OPENCODE_SESSION_CURSOR_START }
|
||||
this.openCodeBinderParents.clear()
|
||||
this.openCodeBinderUnbound.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* A birth may have arrived (opencode SessionStart): run one round soon so
|
||||
* the session binds before its first busy stretch, instead of waiting out
|
||||
* the poll interval. Trailing-edge debounced; concurrent rounds collapse.
|
||||
*/
|
||||
protected kickOpenCodeBinder(): void {
|
||||
if (this.openCodeBinderKickTimer) {
|
||||
return
|
||||
}
|
||||
this.openCodeBinderKickTimer = setTimeout(() => {
|
||||
this.openCodeBinderKickTimer = null
|
||||
void this.runOpenCodeBinderRoundOnce()
|
||||
}, OPENCODE_BINDER_KICK_DEBOUNCE_MS)
|
||||
if (this.openCodeBinderKickTimer.unref) {
|
||||
this.openCodeBinderKickTimer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one correlate-and-bind round; returns applied binding count. */
|
||||
protected async runOpenCodeBinderRoundOnce(): Promise<number> {
|
||||
if (this.openCodeBinderRunning) {
|
||||
return 0
|
||||
}
|
||||
this.openCodeBinderRunning = true
|
||||
// Why capture before the try: if stop() lands while the sweep is in
|
||||
// flight and a restart begins a new round, the obsolete round must not
|
||||
// clear the new round's running flag (or two rounds overlap and apply
|
||||
// ownership snapshots out of order).
|
||||
const generation = this.openCodeBinderGeneration
|
||||
try {
|
||||
const deps = this.openCodeBinderDeps
|
||||
const nowMs = deps.now()
|
||||
const fresh = deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark)
|
||||
const sessions = [...fresh]
|
||||
for (const [id, entry] of this.openCodeBinderUnbound) {
|
||||
if (nowMs - entry.firstSeenMs > OPENCODE_BINDER_UNBOUND_RETRY_MS) {
|
||||
this.openCodeBinderUnbound.delete(id)
|
||||
continue
|
||||
}
|
||||
if (!fresh.some((row) => row.id === id)) {
|
||||
sessions.push(entry.row)
|
||||
}
|
||||
}
|
||||
if (sessions.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const panes = deps.listPanes()
|
||||
const processes = await deps.sweep()
|
||||
if (generation !== this.openCodeBinderGeneration) {
|
||||
return 0
|
||||
}
|
||||
const knownOwners = new Map<string, string>()
|
||||
for (const session of sessions) {
|
||||
const bound = lookupOpenCodeSessionPane(this.state, session.id)
|
||||
if (bound) {
|
||||
knownOwners.set(session.id, bound.paneKey)
|
||||
}
|
||||
this.openCodeBinderParents.delete(session.id)
|
||||
this.openCodeBinderParents.set(session.id, session.parentId)
|
||||
}
|
||||
while (this.openCodeBinderParents.size > OPENCODE_BINDER_PARENTS_MAX) {
|
||||
const oldest = this.openCodeBinderParents.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
this.openCodeBinderParents.delete(oldest)
|
||||
}
|
||||
const { ownerships } = runOpenCodeBinderRound({
|
||||
nowMs,
|
||||
sessions,
|
||||
panes,
|
||||
processes,
|
||||
knownOwners,
|
||||
parentBySessionId: this.openCodeBinderParents
|
||||
})
|
||||
const boundIds = new Set(ownerships.map((ownership) => ownership.sessionId))
|
||||
const applied = applyBinderOwnerships(this.state, panes, ownerships, nowMs)
|
||||
for (const session of sessions) {
|
||||
if (knownOwners.has(session.id) || boundIds.has(session.id)) {
|
||||
this.openCodeBinderUnbound.delete(session.id)
|
||||
continue
|
||||
}
|
||||
if (!this.openCodeBinderUnbound.has(session.id)) {
|
||||
if (this.openCodeBinderUnbound.size >= OPENCODE_BINDER_UNBOUND_MAX) {
|
||||
break
|
||||
}
|
||||
this.openCodeBinderUnbound.set(session.id, { row: session, firstSeenMs: nowMs })
|
||||
}
|
||||
}
|
||||
// Why from handled rows only: a session the full map could not track
|
||||
// must stay re-listable next round instead of being silently passed by
|
||||
// the watermark.
|
||||
this.openCodeBinderWatermark = advanceBinderCursor({
|
||||
fresh,
|
||||
isHandled: (sessionId) =>
|
||||
knownOwners.has(sessionId) ||
|
||||
boundIds.has(sessionId) ||
|
||||
this.openCodeBinderUnbound.has(sessionId),
|
||||
current: this.openCodeBinderWatermark
|
||||
})
|
||||
return applied
|
||||
} catch (err) {
|
||||
// Why swallow: a binder failure must never break hook serving; the next
|
||||
// round retries, and unbound sessions keep today's stamped behavior.
|
||||
console.warn('[opencode-binder] round failed; keeping stamped attribution', err)
|
||||
return 0
|
||||
} finally {
|
||||
if (generation === this.openCodeBinderGeneration) {
|
||||
this.openCodeBinderRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export function buildPtyHostEnv(
|
||||
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
|
||||
const openCodeStatusService =
|
||||
openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
|
||||
baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
|
||||
Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
|
||||
if (baseEnv.OPENCODE_CONFIG_DIR) {
|
||||
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
|
||||
|
||||
@@ -27,6 +27,7 @@ type OpenCodeSessionUsageRow = {
|
||||
tokens_output: number
|
||||
tokens_reasoning: number
|
||||
tokens_cache_read: number
|
||||
tokens_cache_write: number
|
||||
}
|
||||
|
||||
function getProjectJoin(db: Database.Database): string {
|
||||
@@ -46,6 +47,7 @@ function getAssistantSessionMessageCount(db: Database.Database): number {
|
||||
const assistantPredicate = columnExists(db, 'session_message', 'type')
|
||||
? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL"
|
||||
: "json_extract(data, '$.tokens.input') IS NOT NULL"
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below.
|
||||
const row = db
|
||||
.prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`)
|
||||
.get() as { count?: number } | undefined
|
||||
@@ -61,16 +63,29 @@ function canReadSessionUsageRows(db: Database.Database): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function getSessionCacheWriteSelect(db: Database.Database): string {
|
||||
return columnExists(db, 'session', 'tokens_cache_write') ? 's.tokens_cache_write' : '0'
|
||||
}
|
||||
|
||||
function getSessionTokenTotalExpression(db: Database.Database): string {
|
||||
const cacheWrite = columnExists(db, 'session', 'tokens_cache_write')
|
||||
? ' + tokens_cache_write'
|
||||
: ''
|
||||
return `tokens_input + tokens_output + tokens_reasoning + tokens_cache_read${cacheWrite}`
|
||||
}
|
||||
|
||||
function getSessionUsageRowCount(db: Database.Database): number {
|
||||
if (!canReadSessionUsageRows(db)) {
|
||||
return 0
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below.
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM session
|
||||
WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0`
|
||||
WHERE ${getSessionTokenTotalExpression(db)} > 0`
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below.
|
||||
.get() as { count?: number } | undefined
|
||||
return row?.count ?? 0
|
||||
}
|
||||
@@ -78,14 +93,18 @@ function getSessionUsageRowCount(db: Database.Database): number {
|
||||
function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] {
|
||||
const projectJoin = getProjectJoin(db)
|
||||
const sessionModelSelect = getSessionModelSelect(db)
|
||||
const cacheWriteSelect = getSessionCacheWriteSelect(db)
|
||||
const tokenTotalExpression = getSessionTokenTotalExpression(db)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT aliases match OpenCodeSessionUsageRow across supported schemas.
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT s.id, s.id AS session_id, s.time_created, s.time_updated,
|
||||
s.directory, s.title, p.worktree, ${sessionModelSelect},
|
||||
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read
|
||||
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read,
|
||||
${cacheWriteSelect} AS tokens_cache_write
|
||||
FROM session s
|
||||
${projectJoin}
|
||||
WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0
|
||||
WHERE ${tokenTotalExpression.replaceAll('tokens_', 's.tokens_')} > 0
|
||||
ORDER BY s.time_created, s.id`
|
||||
)
|
||||
.all() as OpenCodeSessionUsageRow[]
|
||||
@@ -105,10 +124,15 @@ function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] {
|
||||
input: row.tokens_input,
|
||||
output: row.tokens_output,
|
||||
reasoning: row.tokens_reasoning,
|
||||
total: row.tokens_input + row.tokens_output + row.tokens_reasoning,
|
||||
total:
|
||||
row.tokens_input +
|
||||
row.tokens_output +
|
||||
row.tokens_reasoning +
|
||||
row.tokens_cache_read +
|
||||
row.tokens_cache_write,
|
||||
cache: {
|
||||
read: row.tokens_cache_read,
|
||||
write: 0
|
||||
write: row.tokens_cache_write
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ function createSessionTotalsSchema(db: Database.Database): void {
|
||||
tokens_output INTEGER,
|
||||
tokens_reasoning INTEGER,
|
||||
tokens_cache_read INTEGER,
|
||||
tokens_cache_write INTEGER,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER
|
||||
);
|
||||
@@ -57,9 +58,9 @@ function insertSessionTotalsRow(
|
||||
db.prepare(
|
||||
`INSERT INTO session (
|
||||
id, directory, title, model, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
time_created, time_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
sessionId,
|
||||
`${WORKTREE}/packages/app`,
|
||||
@@ -70,6 +71,7 @@ function insertSessionTotalsRow(
|
||||
100,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1_777_777_700_000,
|
||||
1_777_777_800_000
|
||||
)
|
||||
@@ -235,6 +237,7 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
tokens_output INTEGER,
|
||||
tokens_reasoning INTEGER,
|
||||
tokens_cache_read INTEGER,
|
||||
tokens_cache_write INTEGER,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER
|
||||
);
|
||||
@@ -243,9 +246,9 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO session (
|
||||
id, project_id, directory, title, model, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
time_created, time_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
'session-1',
|
||||
'project-1',
|
||||
@@ -257,6 +260,7 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
500,
|
||||
100,
|
||||
250,
|
||||
75,
|
||||
1_777_777_700_000,
|
||||
1_777_777_800_000
|
||||
)
|
||||
@@ -274,7 +278,7 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
totalCachedInputTokens: 250,
|
||||
totalOutputTokens: 500,
|
||||
totalReasoningOutputTokens: 100,
|
||||
totalTokens: 1850,
|
||||
totalTokens: 1925,
|
||||
estimatedCostUsd: 0.06
|
||||
})
|
||||
expect(parsed.dailyAggregates).toEqual([
|
||||
@@ -284,7 +288,7 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
cachedInputTokens: 250,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1850,
|
||||
totalTokens: 1925,
|
||||
estimatedCostUsd: 0.06
|
||||
})
|
||||
])
|
||||
|
||||
@@ -91,11 +91,11 @@ describe('OpenCode hook plugin source', () => {
|
||||
const digest = (source: string): string => createHash('sha256').update(source).digest('hex')
|
||||
|
||||
expect(digest(getOpenCodePluginSource())).toBe(
|
||||
'd14859a36c88aefe3a45cd232789503296e0a23438b151c773414bad64ab8eaa'
|
||||
'938867eae97b7ae4a7193a07755ee74a77c7a7352edcc28b59543b1b00713612'
|
||||
)
|
||||
expect(
|
||||
digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false }))
|
||||
).toBe('4de14bee0c27ce55f29f70b19aa6ce9967e09b098bba139fb88f0511af7d4fca')
|
||||
).toBe('4c9c27af603a9e85e3e33a30c439d9dfb6785936dea0be76fdd64cf7dc2f7174')
|
||||
})
|
||||
|
||||
it('filters child sessions via parentID lookup before forwarding events', () => {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProcessResult, ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import {
|
||||
isOpenCodeClientArgv,
|
||||
isOpenCodeClientProcess,
|
||||
nativeWindowsRowToIdentity,
|
||||
parsePsArgsLine,
|
||||
parsePsCommLine,
|
||||
parsePsElapsedToMs,
|
||||
splitCommandLineArgv,
|
||||
sweepProcessIdentities
|
||||
} from './opencode-client-sweep'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
|
||||
describe('parsePsElapsedToMs', () => {
|
||||
it('reads mm:ss, hh:mm:ss and dd-hh:mm:ss', () => {
|
||||
expect(parsePsElapsedToMs('02:11', NOW)).toBe(NOW - 131_000)
|
||||
expect(parsePsElapsedToMs('1:02:11', NOW)).toBe(NOW - 3_731_000)
|
||||
expect(parsePsElapsedToMs('2-01:02:11', NOW)).toBe(NOW - 176_531_000)
|
||||
})
|
||||
|
||||
it('rejects unknown shapes', () => {
|
||||
expect(parsePsElapsedToMs('', NOW)).toBeNull()
|
||||
expect(parsePsElapsedToMs('yesterday', NOW)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePsArgsLine', () => {
|
||||
it('parses a client row', () => {
|
||||
const row = parsePsArgsLine('23487 22618 2:11:51 opencode', NOW)
|
||||
expect(row).toMatchObject({ pid: 23487, ppid: 22618, argv: ['opencode'] })
|
||||
expect(row?.startedAtMs).toBe(NOW - (2 * 3_600 + 11 * 60 + 51) * 1000)
|
||||
})
|
||||
|
||||
it('keeps session flags in argv', () => {
|
||||
const row = parsePsArgsLine('999 100 00:05 opencode --session ses_abc', NOW)
|
||||
expect(row?.argv).toEqual(['opencode', '--session', 'ses_abc'])
|
||||
})
|
||||
|
||||
it('keeps a quoted executable path as argv[0]', () => {
|
||||
const row = parsePsArgsLine('999 100 00:05 "/opt/my tools/opencode" --session ses_abc', NOW)
|
||||
expect(row?.argv).toEqual(['/opt/my tools/opencode', '--session', 'ses_abc'])
|
||||
expect(row?.executable).toBe('')
|
||||
})
|
||||
|
||||
it('drops header-shaped and truncated rows', () => {
|
||||
expect(parsePsArgsLine('PID PPID ELAPSED COMMAND', NOW)).toBeNull()
|
||||
expect(parsePsArgsLine('1 0', NOW)).toBeNull()
|
||||
expect(parsePsArgsLine('', NOW)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePsCommLine', () => {
|
||||
it('reads the executable name past the pid', () => {
|
||||
expect(parsePsCommLine('23487 opencode')).toEqual({ pid: 23487, executable: 'opencode' })
|
||||
})
|
||||
|
||||
it('keeps executable names containing spaces whole', () => {
|
||||
expect(parsePsCommLine(' 999 My App Helper ')).toEqual({
|
||||
pid: 999,
|
||||
executable: 'My App Helper'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops header-shaped and truncated rows', () => {
|
||||
expect(parsePsCommLine('PID COMMAND')).toBeNull()
|
||||
expect(parsePsCommLine('1')).toBeNull()
|
||||
expect(parsePsCommLine('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitCommandLineArgv', () => {
|
||||
it('groups double-quoted spans', () => {
|
||||
expect(
|
||||
splitCommandLineArgv('"C:\\Program Files\\OpenCode\\opencode.exe" --session ses_1')
|
||||
).toEqual(['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1'])
|
||||
})
|
||||
|
||||
it('splits plain argv on whitespace', () => {
|
||||
expect(splitCommandLineArgv('opencode --session ses_1')).toEqual([
|
||||
'opencode',
|
||||
'--session',
|
||||
'ses_1'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops empties', () => {
|
||||
expect(splitCommandLineArgv('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeWindowsRowToIdentity', () => {
|
||||
it('maps pid, creation time and quoted command line', () => {
|
||||
const row = nativeWindowsRowToIdentity({
|
||||
pid: 23487,
|
||||
ppid: 22618,
|
||||
name: 'opencode.exe',
|
||||
creationTimeMs: NOW - 60_000,
|
||||
command: '"C:\\Program Files\\OpenCode\\opencode.exe" --session ses_1'
|
||||
})
|
||||
expect(row).toMatchObject({
|
||||
pid: 23487,
|
||||
ppid: 22618,
|
||||
startedAtMs: NOW - 60_000,
|
||||
executable: 'opencode.exe',
|
||||
argv: ['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1']
|
||||
})
|
||||
})
|
||||
|
||||
it('skips rows without a creation time or command line', () => {
|
||||
expect(nativeWindowsRowToIdentity({ pid: 4, ppid: 0, name: 'System', command: '' })).toBeNull()
|
||||
expect(
|
||||
nativeWindowsRowToIdentity({ pid: 4, ppid: 0, name: 'System', command: 'opencode' })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenCodeClientArgv', () => {
|
||||
it('matches clients and rejects the serve daemon', () => {
|
||||
expect(isOpenCodeClientArgv(['opencode'])).toBe(true)
|
||||
expect(isOpenCodeClientArgv(['/opt/homebrew/bin/opencode', '--session', 'ses_1'])).toBe(true)
|
||||
expect(isOpenCodeClientArgv(['C:\\tools\\opencode.exe'])).toBe(true)
|
||||
expect(
|
||||
isOpenCodeClientArgv(['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1'])
|
||||
).toBe(true)
|
||||
expect(isOpenCodeClientArgv(['opencode.exe', 'serve', '--service'])).toBe(false)
|
||||
expect(isOpenCodeClientArgv(['node', 'server.js'])).toBe(false)
|
||||
expect(isOpenCodeClientArgv([])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenCodeClientProcess', () => {
|
||||
it('trusts the executable when argv[0] is truncated by spaces', () => {
|
||||
expect(
|
||||
isOpenCodeClientProcess({ executable: 'opencode', argv: ['/opt/Open', 'Code/opencode'] })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('still rejects the serve daemon', () => {
|
||||
expect(
|
||||
isOpenCodeClientProcess({ executable: 'opencode', argv: ['opencode', 'serve', '--service'] })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to argv[0] without an executable', () => {
|
||||
expect(isOpenCodeClientProcess({ executable: '', argv: ['opencode'] })).toBe(true)
|
||||
expect(isOpenCodeClientProcess({ executable: '', argv: ['node', 'server.js'] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepProcessIdentities', () => {
|
||||
function psRunner(outputs: Record<'args' | 'comm', string | Error>): (
|
||||
spec: ProcessSpec
|
||||
) => Promise<ProcessResult> {
|
||||
return async (spec: ProcessSpec): Promise<ProcessResult> => {
|
||||
const kind = spec.args?.some((arg) => arg.includes('comm=')) ? 'comm' : 'args'
|
||||
const output = outputs[kind]
|
||||
if (output instanceof Error) {
|
||||
throw output
|
||||
}
|
||||
return {
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: output,
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('joins the comm executable onto args rows on POSIX', async () => {
|
||||
const rows = await sweepProcessIdentities({
|
||||
platform: 'darwin',
|
||||
nowMs: NOW,
|
||||
run: psRunner({
|
||||
args: '23487 22618 00:05 /opt/Open Code/opencode --session ses_1\n',
|
||||
comm: '23487 opencode\n999 My App Helper\n'
|
||||
})
|
||||
})
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ pid: 23487, executable: 'opencode' })
|
||||
expect(rows[0]?.argv).toEqual(['/opt/Open', 'Code/opencode', '--session', 'ses_1'])
|
||||
})
|
||||
|
||||
it('degrades to argv[0] matching when the comm sweep fails', async () => {
|
||||
const rows = await sweepProcessIdentities({
|
||||
platform: 'darwin',
|
||||
nowMs: NOW,
|
||||
run: psRunner({
|
||||
args: '23487 22618 00:05 opencode --session ses_1\n',
|
||||
comm: new Error('comm unavailable')
|
||||
})
|
||||
})
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ pid: 23487, executable: '' })
|
||||
})
|
||||
|
||||
it('reads the Windows table through the injected reader', async () => {
|
||||
const rows = await sweepProcessIdentities({
|
||||
platform: 'win32',
|
||||
readWindowsTable: async () => [
|
||||
{
|
||||
pid: 23487,
|
||||
ppid: 22618,
|
||||
name: 'opencode.exe',
|
||||
creationTimeMs: NOW - 60_000,
|
||||
command: '"C:\\Program Files\\OpenCode\\opencode.exe"'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ pid: 23487, ppid: 22618, startedAtMs: NOW - 60_000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import {
|
||||
readWindowsProcessTable,
|
||||
type WindowsProcessRow as NativeWindowsProcessRow
|
||||
} from '../windows/windows-process-table'
|
||||
|
||||
/**
|
||||
* Host-wide sweep locating live OpenCode client processes for the
|
||||
* session→pane binder (#21359).
|
||||
*
|
||||
* Why a dedicated sweep instead of reusing the memory collector's: that
|
||||
* index carries pid/ppid/cpu/rss but no argv or start times, and importing
|
||||
* the memory subsystem here would drag its Electron app-metrics dependency
|
||||
* into the hook path. On Windows the table is read only through the native
|
||||
* reader (`windows-process-table.ts`); on macOS/Linux through one `ps` call.
|
||||
* The invocation pattern (5 s timeout, 10 MB cap, fail-open []) mirrors
|
||||
* `windows-process-resource-collector.ts`.
|
||||
*/
|
||||
|
||||
/** One process identity row from a host sweep. */
|
||||
export type ProcessIdentityRow = {
|
||||
pid: number
|
||||
ppid: number
|
||||
/** ms epoch the process started. */
|
||||
startedAtMs: number
|
||||
/**
|
||||
* Kernel-reported executable name (`comm=` on POSIX, `name` on Windows).
|
||||
* Unlike `args=`, this is not a reconstructed string, so an install path
|
||||
* containing spaces cannot split it. Empty when the sweep could not read it;
|
||||
* classification then falls back to argv[0].
|
||||
*/
|
||||
executable: string
|
||||
/** argv approximation; see parsePsArgsLine. */
|
||||
argv: string[]
|
||||
}
|
||||
|
||||
const SWEEP_TIMEOUT_MS = 5_000
|
||||
const SWEEP_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
/** `[[dd-]hh:]mm:ss` → elapsed ms, or null when the shape is unknown. */
|
||||
export function parsePsElapsedToMs(etime: string, nowMs: number): number | null {
|
||||
const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const days = Number.parseInt(match[1] ?? '0', 10)
|
||||
const hours = Number.parseInt(match[2] ?? '0', 10)
|
||||
const minutes = Number.parseInt(match[3] ?? '0', 10)
|
||||
const seconds = Number.parseInt(match[4] ?? '0', 10)
|
||||
if ([days, hours, minutes, seconds].some((n) => !Number.isFinite(n) || n < 0)) {
|
||||
return null
|
||||
}
|
||||
return nowMs - ((days * 24 + hours) * 3_600 + minutes * 60 + seconds) * 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a command line into argv, grouping `"..."` so a quoted executable
|
||||
* path survives as argv[0]. Covers the shapes that matter here (a quoted
|
||||
* install path plus plain flags); it is not a full shell parser — an escaped
|
||||
* quote inside a quoted span still splits. Downstream only flag-adjacent
|
||||
* values (`--session <id>`) are read from this argv; classification uses the
|
||||
* kernel executable name, because `ps` `args=` cannot preserve argv
|
||||
* boundaries for unquoted paths.
|
||||
*/
|
||||
export function splitCommandLineArgv(commandLine: string): string[] {
|
||||
const argv: string[] = []
|
||||
const pattern = /"([^"]*)"|(\S+)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(commandLine)) !== null) {
|
||||
argv.push(match[1] ?? match[2] ?? '')
|
||||
}
|
||||
return argv.filter((part) => part.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `ps -eo pid=,ppid=,etime=,args=` line. `args` is a reconstructed
|
||||
* command-and-arguments string: argv boundaries are lost, so a path with an
|
||||
* unquoted space (e.g. `/opt/Open Code/opencode`) splits argv[0] in two.
|
||||
* The executable name therefore comes from a separate `comm=` sweep;
|
||||
* this parser records the flags it can still read reliably (`--session`
|
||||
* values never contain spaces) and leaves `executable` empty for the join.
|
||||
*/
|
||||
export function parsePsArgsLine(line: string, nowMs: number): ProcessIdentityRow | null {
|
||||
// Why a regex instead of split-with-limit: split discards everything past
|
||||
// the limit, which would truncate argv to its first token.
|
||||
const match = line.trim().match(/^(\S+)\s+(\S+)\s+(\S+)\s+([\s\S]*\S)\s*$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [, pidText, ppidText, etimeText, argsText] = match
|
||||
if (!pidText || !ppidText || !etimeText || !argsText) {
|
||||
return null
|
||||
}
|
||||
const pid = Number.parseInt(pidText, 10)
|
||||
const ppid = Number.parseInt(ppidText, 10)
|
||||
const startedAtMs = parsePsElapsedToMs(etimeText, nowMs)
|
||||
const argv = splitCommandLineArgv(argsText)
|
||||
if (
|
||||
!Number.isFinite(pid) ||
|
||||
!Number.isFinite(ppid) ||
|
||||
startedAtMs === null ||
|
||||
argv.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { pid, ppid, startedAtMs, executable: '', argv }
|
||||
}
|
||||
|
||||
/**
|
||||
* One `ps -eo pid=,comm=` line. `comm` is the kernel's executable name as a
|
||||
* trailing field, so it may itself contain spaces — everything past the pid
|
||||
* is the name. Empty names are dropped; the join then falls back to argv[0].
|
||||
*/
|
||||
export function parsePsCommLine(line: string): { pid: number; executable: string } | null {
|
||||
const match = line.trim().match(/^(\S+)\s+([\s\S]*\S)\s*$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [, pidText, executable] = match
|
||||
const pid = Number.parseInt(pidText ?? '', 10)
|
||||
if (!Number.isFinite(pid) || !executable) {
|
||||
return null
|
||||
}
|
||||
return { pid, executable }
|
||||
}
|
||||
|
||||
/**
|
||||
* One native Windows process-table row. Rows without a kernel creation time
|
||||
* cannot bracket a session creation, so they are skipped rather than guessed.
|
||||
*/
|
||||
export function nativeWindowsRowToIdentity(
|
||||
row: NativeWindowsProcessRow
|
||||
): ProcessIdentityRow | null {
|
||||
if (!Number.isFinite(row.pid) || !Number.isFinite(row.ppid)) {
|
||||
return null
|
||||
}
|
||||
if (typeof row.creationTimeMs !== 'number' || !Number.isFinite(row.creationTimeMs)) {
|
||||
return null
|
||||
}
|
||||
const argv = splitCommandLineArgv(row.command)
|
||||
if (argv.length === 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
pid: row.pid,
|
||||
ppid: row.ppid,
|
||||
startedAtMs: row.creationTimeMs,
|
||||
executable: row.name,
|
||||
argv
|
||||
}
|
||||
}
|
||||
|
||||
function executableBaseName(value: string): string {
|
||||
const bare = value.split(/[\\/]/).at(-1) ?? ''
|
||||
return bare.toLowerCase().replace(/\.exe$/, '')
|
||||
}
|
||||
|
||||
function argvZeroBase(argv: readonly string[]): string {
|
||||
return executableBaseName(argv[0] ?? '')
|
||||
}
|
||||
|
||||
/** True for an OpenCode TUI/CLI client process (not the `serve` daemon). */
|
||||
export function isOpenCodeClientArgv(argv: readonly string[]): boolean {
|
||||
return isOpenCodeClientProcess({ executable: '', argv })
|
||||
}
|
||||
|
||||
/**
|
||||
* True for an OpenCode TUI/CLI client process (not the `serve` daemon).
|
||||
* The kernel-reported executable wins when present: `ps` `args=` cannot
|
||||
* preserve argv boundaries, so a truncated argv[0] must not veto a matching
|
||||
* executable. With no executable recorded this degrades to argv[0] matching.
|
||||
*/
|
||||
export function isOpenCodeClientProcess(row: {
|
||||
executable: string
|
||||
argv: readonly string[]
|
||||
}): boolean {
|
||||
const classified =
|
||||
row.executable && row.executable.length > 0
|
||||
? executableBaseName(row.executable)
|
||||
: argvZeroBase(row.argv)
|
||||
if (classified !== 'opencode') {
|
||||
return false
|
||||
}
|
||||
// Why exclude: the shared server's posts are the ones being reattributed;
|
||||
// mistaking the daemon for a pane client would bind sessions to its pane.
|
||||
return !row.argv.some((part) => part === 'serve' || part === '--service')
|
||||
}
|
||||
|
||||
/** Every process identity row on this host; fail-open [] like the memory sweeps. */
|
||||
export async function sweepProcessIdentities(
|
||||
deps: {
|
||||
platform?: NodeJS.Platform
|
||||
run?: typeof runProcess
|
||||
nowMs?: number
|
||||
readWindowsTable?: () => Promise<NativeWindowsProcessRow[]>
|
||||
} = {}
|
||||
): Promise<ProcessIdentityRow[]> {
|
||||
const platform = deps.platform ?? process.platform
|
||||
const run = deps.run ?? runProcess
|
||||
const nowMs = deps.nowMs ?? Date.now()
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
// Why the native table and nothing else: it is the only sanctioned
|
||||
// Windows process-table reader (see windows-process-enumeration.md);
|
||||
// forking powershell.exe for a whole-table CIM scan is exactly the
|
||||
// pattern it retired.
|
||||
const readTable = deps.readWindowsTable ?? readWindowsProcessTable
|
||||
const rows = await readTable()
|
||||
return rows
|
||||
.map((row) => nativeWindowsRowToIdentity(row))
|
||||
.filter((row): row is ProcessIdentityRow => row !== null)
|
||||
}
|
||||
const stdout = await execFileText(run, 'ps', ['-eo', 'pid=,ppid=,etime=,args='])
|
||||
const rows = stdout
|
||||
.split('\n')
|
||||
.map((line) => parsePsArgsLine(line, nowMs))
|
||||
.filter((row): row is ProcessIdentityRow => row !== null)
|
||||
// Why a second sweep: `args=` is one reconstructed string, so the
|
||||
// executable name for classification comes from `comm=` instead. A
|
||||
// failed comm sweep degrades to argv[0] matching rather than dropping
|
||||
// the whole round.
|
||||
try {
|
||||
const commOut = await execFileText(run, 'ps', ['-eo', 'pid=,comm='])
|
||||
const executables = new Map<number, string>()
|
||||
for (const line of commOut.split('\n')) {
|
||||
const parsed = parsePsCommLine(line)
|
||||
if (parsed && !executables.has(parsed.pid)) {
|
||||
executables.set(parsed.pid, parsed.executable)
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
const executable = executables.get(row.pid)
|
||||
if (executable) {
|
||||
row.executable = executable
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[opencode-binder] comm sweep failed; classifying from argv', err)
|
||||
}
|
||||
return rows
|
||||
} catch (err) {
|
||||
console.warn('[opencode-binder] process sweep failed; skipping round', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one child process to text, throwing on timeout or nonzero exit. */
|
||||
async function execFileText(
|
||||
run: typeof runProcess,
|
||||
program: string,
|
||||
args: string[]
|
||||
): Promise<string> {
|
||||
const result = await run({
|
||||
program,
|
||||
args,
|
||||
timeoutMs: SWEEP_TIMEOUT_MS,
|
||||
maxOutputBytes: SWEEP_MAX_BYTES
|
||||
})
|
||||
if (result.timedOut || result.code !== 0) {
|
||||
throw new Error(`${program} exited ${result.code ?? 'on timeout'}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
|
||||
import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { ProcessIdentityRow } from './opencode-client-sweep'
|
||||
import {
|
||||
advanceBinderCursor,
|
||||
applyBinderOwnerships,
|
||||
OPENCODE_SESSION_CURSOR_START,
|
||||
runOpenCodeBinderRound,
|
||||
type BinderPaneSnapshot
|
||||
} from './opencode-session-binder'
|
||||
|
||||
const NOW = 1_700_000_100_000
|
||||
const DIR = '/Users/jin/work/mocitec'
|
||||
const PANE_A = makePaneKey('tab-a', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
|
||||
const PANE_B = makePaneKey('tab-b', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
|
||||
|
||||
function proc(
|
||||
pid: number,
|
||||
ppid: number,
|
||||
argv: string[],
|
||||
startedAtMs = NOW - 120_000
|
||||
): ProcessIdentityRow {
|
||||
return { pid, ppid, startedAtMs, executable: argv[0] ?? '', argv }
|
||||
}
|
||||
|
||||
function pane(paneKey: string, shellPid: number | null): BinderPaneSnapshot {
|
||||
return { paneKey, directory: DIR, worktreeId: 'repo::/Users/jin/work/mocitec', shellPid }
|
||||
}
|
||||
|
||||
describe('runOpenCodeBinderRound', () => {
|
||||
it('attributes a client to its pane subtree and binds the session', () => {
|
||||
const { ownerships } = runOpenCodeBinderRound({
|
||||
nowMs: NOW,
|
||||
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
|
||||
panes: [pane(PANE_A, 100), pane(PANE_B, 200)],
|
||||
processes: [
|
||||
proc(100, 1, ['zsh']),
|
||||
proc(200, 1, ['zsh']),
|
||||
proc(210, 200, ['opencode'], NOW - 90_000)
|
||||
],
|
||||
knownOwners: new Map(),
|
||||
parentBySessionId: new Map()
|
||||
})
|
||||
expect(ownerships).toEqual([
|
||||
{ sessionId: 'ses_1', paneKey: PANE_B, basis: 'creation-correlation' }
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores clients outside every pane subtree', () => {
|
||||
const { ownerships } = runOpenCodeBinderRound({
|
||||
nowMs: NOW,
|
||||
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
|
||||
panes: [pane(PANE_A, 100)],
|
||||
processes: [proc(100, 1, ['zsh']), proc(999, 1, ['opencode'], NOW - 90_000)],
|
||||
knownOwners: new Map(),
|
||||
parentBySessionId: new Map()
|
||||
})
|
||||
expect(ownerships).toEqual([])
|
||||
})
|
||||
|
||||
it('inherits a root owner across the watermark via the parent map', () => {
|
||||
const { ownerships } = runOpenCodeBinderRound({
|
||||
nowMs: NOW,
|
||||
sessions: [
|
||||
{ id: 'ses_child', directory: DIR, createdAtMs: NOW - 30_000, parentId: 'ses_root' }
|
||||
],
|
||||
panes: [pane(PANE_A, 100)],
|
||||
processes: [proc(100, 1, ['zsh']), proc(101, 100, ['opencode'], NOW - 3_600_000)],
|
||||
knownOwners: new Map([['ses_root', PANE_A]]),
|
||||
parentBySessionId: new Map([['ses_child', 'ses_root']])
|
||||
})
|
||||
expect(ownerships).toEqual([
|
||||
{ sessionId: 'ses_child', paneKey: PANE_A, basis: 'creation-correlation' }
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes same-key snapshots newest-wins', () => {
|
||||
const { ownerships } = runOpenCodeBinderRound({
|
||||
nowMs: NOW,
|
||||
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
|
||||
panes: [
|
||||
{ ...pane(PANE_A, 100), directory: '/elsewhere' },
|
||||
{ ...pane(PANE_A, 101), directory: DIR }
|
||||
],
|
||||
processes: [
|
||||
proc(100, 1, ['zsh']),
|
||||
proc(101, 1, ['zsh']),
|
||||
proc(102, 101, ['opencode'], NOW - 90_000)
|
||||
],
|
||||
knownOwners: new Map(),
|
||||
parentBySessionId: new Map()
|
||||
})
|
||||
expect(ownerships).toEqual([
|
||||
{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'single-pane-directory' }
|
||||
])
|
||||
})
|
||||
|
||||
it('advances the cursor past handled rows only', () => {
|
||||
const fresh = [
|
||||
{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null },
|
||||
{ id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null }
|
||||
]
|
||||
expect(
|
||||
advanceBinderCursor({
|
||||
fresh,
|
||||
isHandled: () => true,
|
||||
current: OPENCODE_SESSION_CURSOR_START
|
||||
})
|
||||
).toEqual({ ms: NOW - 10_000, id: 'ses_2' })
|
||||
})
|
||||
|
||||
it('freezes the cursor before the first unhandled row so it is re-listed', () => {
|
||||
const fresh = [
|
||||
{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null },
|
||||
{ id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null }
|
||||
]
|
||||
expect(
|
||||
advanceBinderCursor({
|
||||
fresh,
|
||||
isHandled: (id) => id === 'ses_1',
|
||||
current: OPENCODE_SESSION_CURSOR_START
|
||||
})
|
||||
).toEqual({ ms: NOW - 60_000, id: 'ses_1' })
|
||||
})
|
||||
|
||||
it('keeps the cursor when nothing was handled', () => {
|
||||
const current = { ms: NOW - 120_000, id: 'ses_0' }
|
||||
expect(
|
||||
advanceBinderCursor({
|
||||
fresh: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
|
||||
isHandled: () => false,
|
||||
current
|
||||
})
|
||||
).toBe(current)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBinderOwnerships', () => {
|
||||
it('writes bindings with the pane worktree into the registry', () => {
|
||||
const state = createHookListenerState()
|
||||
const applied = applyBinderOwnerships(
|
||||
state,
|
||||
[pane(PANE_A, 100)],
|
||||
[{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }],
|
||||
NOW
|
||||
)
|
||||
expect(applied).toBe(1)
|
||||
expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({
|
||||
paneKey: PANE_A,
|
||||
worktreeId: 'repo::/Users/jin/work/mocitec'
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the newest row worktree when a pane remints', () => {
|
||||
const state = createHookListenerState()
|
||||
// Registry insertion order puts the stale row first; the live remint row
|
||||
// carries a different worktree and must win, matching the round's
|
||||
// newest-wins pane dedupe.
|
||||
const applied = applyBinderOwnerships(
|
||||
state,
|
||||
[
|
||||
{ paneKey: PANE_A, directory: '/elsewhere', worktreeId: 'repo::/elsewhere', shellPid: 100 },
|
||||
{ ...pane(PANE_A, 101) }
|
||||
],
|
||||
[{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }],
|
||||
NOW
|
||||
)
|
||||
expect(applied).toBe(1)
|
||||
expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({
|
||||
paneKey: PANE_A,
|
||||
worktreeId: 'repo::/Users/jin/work/mocitec'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,341 @@
|
||||
import { resolveOpenCodeDataDirectory } from './opencode-data-directory'
|
||||
import {
|
||||
bindOpenCodeSession,
|
||||
type OpenCodeSessionBinding
|
||||
} from '../../shared/agent-hook-listener/opencode-session-registry'
|
||||
import {
|
||||
correlateOpenCodeSessionOwners,
|
||||
type CorrelatedClient,
|
||||
type CorrelatedPane,
|
||||
type CorrelatedSession,
|
||||
type SessionOwnership
|
||||
} from '../../shared/agent-hook-listener/opencode-session-correlation'
|
||||
import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open'
|
||||
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import { listRegisteredPtys } from '../memory/pty-registry'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { isOpenCodeClientProcess, type ProcessIdentityRow } from './opencode-client-sweep'
|
||||
import type { HookListenerState } from '../../shared/agent-hook-listener/listener-state'
|
||||
|
||||
/**
|
||||
* Main-process binder feeding the session→pane registry (#21359).
|
||||
*
|
||||
* Each round: read new sessions from the shared server's SQLite store,
|
||||
* snapshot panes, sweep for live clients, correlate, bind. Everything the
|
||||
* round needs is injected so the decision core stays unit-testable; only
|
||||
* the SQLite reader below touches disk, reusing ai-vault's guarded open
|
||||
* (read-only + query_only + busy timeout).
|
||||
*/
|
||||
|
||||
/** One pane snapshot feeding a binder round. */
|
||||
export type BinderPaneSnapshot = {
|
||||
paneKey: string
|
||||
/** Worktree root backing the pane (null when unknown); sessions beneath it are candidates. */
|
||||
directory: string | null
|
||||
worktreeId: string | null
|
||||
shellPid: number | null
|
||||
}
|
||||
|
||||
/** One session store row feeding a binder round. */
|
||||
export type BinderSessionRow = {
|
||||
id: string
|
||||
directory: string
|
||||
createdAtMs: number
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
/** Everything one binder round needs, injected for tests. */
|
||||
export type BinderRoundDeps = {
|
||||
nowMs: number
|
||||
sessions: readonly BinderSessionRow[]
|
||||
panes: readonly BinderPaneSnapshot[]
|
||||
processes: readonly ProcessIdentityRow[]
|
||||
knownOwners: ReadonlyMap<string, string>
|
||||
parentBySessionId: ReadonlyMap<string, string | null>
|
||||
}
|
||||
|
||||
/** Ownership decisions from one binder round. */
|
||||
export type BinderRoundResult = {
|
||||
ownerships: SessionOwnership[]
|
||||
}
|
||||
|
||||
/** Position in the session store; composite so same-millisecond rows are never skipped. */
|
||||
export type OpenCodeSessionCursor = {
|
||||
ms: number
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Cursor before anything was ever read. */
|
||||
export const OPENCODE_SESSION_CURSOR_START: OpenCodeSessionCursor = { ms: 0, id: '' }
|
||||
|
||||
/** Order cursors the way the store lists rows: oldest first, id as tiebreak. */
|
||||
function compareSessionRows(left: OpenCodeSessionCursor, right: OpenCodeSessionCursor): number {
|
||||
return left.ms - right.ms || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the store cursor past handled rows only. `fresh` arrives in store
|
||||
* order; handling is prefix-closed (the unbound-cap break only ever skips the
|
||||
* tail), so the first unhandled row freezes the cursor and every row at or
|
||||
* past it is re-listed next round instead of silently dropped.
|
||||
*/
|
||||
export function advanceBinderCursor(args: {
|
||||
fresh: readonly BinderSessionRow[]
|
||||
isHandled: (sessionId: string) => boolean
|
||||
current: OpenCodeSessionCursor
|
||||
}): OpenCodeSessionCursor {
|
||||
let cursor = args.current
|
||||
for (const session of args.fresh) {
|
||||
if (!args.isHandled(session.id)) {
|
||||
break
|
||||
}
|
||||
const candidate: OpenCodeSessionCursor = { ms: session.createdAtMs, id: session.id }
|
||||
if (compareSessionRows(candidate, cursor) > 0) {
|
||||
cursor = candidate
|
||||
}
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
/** pid→ppid index for one sweep; first row wins on duplicate pids. */
|
||||
function childrenIndex(processes: readonly ProcessIdentityRow[]): Map<number, number> {
|
||||
const ppidByPid = new Map<number, number>()
|
||||
for (const row of processes) {
|
||||
if (!ppidByPid.has(row.pid)) {
|
||||
ppidByPid.set(row.pid, row.ppid)
|
||||
}
|
||||
}
|
||||
return ppidByPid
|
||||
}
|
||||
|
||||
/** Nearest pane shell at or above this pid; external terminals stay unattributed. */
|
||||
function owningPane(
|
||||
ppidByPid: Map<number, number>,
|
||||
shellPidByPid: Map<number, string>,
|
||||
pid: number
|
||||
): string | null {
|
||||
const seen = new Set<number>()
|
||||
let current: number | undefined = pid
|
||||
while (current !== undefined && !seen.has(current)) {
|
||||
seen.add(current)
|
||||
const owner = shellPidByPid.get(current)
|
||||
if (owner) {
|
||||
return owner
|
||||
}
|
||||
current = ppidByPid.get(current)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Attribute opencode client rows to panes via shell-subtree walks. */
|
||||
function toCorrelatedClients(
|
||||
processes: readonly ProcessIdentityRow[],
|
||||
panes: readonly BinderPaneSnapshot[],
|
||||
nowMs: number
|
||||
): CorrelatedClient[] {
|
||||
const ppidByPid = childrenIndex(processes)
|
||||
const shellPidByPid = new Map<number, string>()
|
||||
for (const pane of panes) {
|
||||
if (pane.shellPid !== null && !shellPidByPid.has(pane.shellPid)) {
|
||||
shellPidByPid.set(pane.shellPid, pane.paneKey)
|
||||
}
|
||||
}
|
||||
const clients: CorrelatedClient[] = []
|
||||
for (const row of processes) {
|
||||
if (!isOpenCodeClientProcess(row)) {
|
||||
continue
|
||||
}
|
||||
const paneKey = owningPane(ppidByPid, shellPidByPid, row.pid)
|
||||
if (!paneKey) {
|
||||
continue
|
||||
}
|
||||
// Why lastSeenAlive = now: the sweep just observed it. Bracketing uses
|
||||
// startedAt for the lower bound and this observation for the upper.
|
||||
clients.push({ paneKey, startedAtMs: row.startedAtMs, lastSeenAliveMs: nowMs, argv: row.argv })
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
/** Pure round core: correlate unbound sessions against panes and clients. */
|
||||
export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult {
|
||||
// Why dedupe by key, newest wins: remints and reattachments can leave a
|
||||
// stale registry row beside the live one; counting rows instead of panes
|
||||
// would turn every same-pane tie into a false ambiguous and nothing would
|
||||
// ever bind, while the oldest row would point the candidate set at a dead
|
||||
// worktree.
|
||||
const paneByKey = new Map<string, CorrelatedPane>()
|
||||
for (const pane of deps.panes) {
|
||||
paneByKey.set(pane.paneKey, { paneKey: pane.paneKey, directory: pane.directory })
|
||||
}
|
||||
const panes = [...paneByKey.values()]
|
||||
const clients = toCorrelatedClients(deps.processes, deps.panes, deps.nowMs)
|
||||
const sessions: CorrelatedSession[] = deps.sessions.map((row) => ({
|
||||
id: row.id,
|
||||
directory: row.directory,
|
||||
createdAtMs: row.createdAtMs,
|
||||
parentId: row.parentId
|
||||
}))
|
||||
// Why resolve inheritance here: the SQLite round only carries new rows, so
|
||||
// an old root is invisible to the correlator; the binder's parent map walks
|
||||
// the chain and the registry supplies the known root owner. Resolved heirs
|
||||
// are emitted as binds (the registry lacks them) and fed back as known so
|
||||
// the correlator skips what is already decided.
|
||||
const knownOwners = new Map(deps.knownOwners)
|
||||
const inherited: SessionOwnership[] = []
|
||||
for (const session of sessions) {
|
||||
if (knownOwners.has(session.id) || !session.parentId) {
|
||||
continue
|
||||
}
|
||||
let parent: string | null | undefined = session.parentId
|
||||
const seen = new Set<string>([session.id])
|
||||
while (parent && !seen.has(parent)) {
|
||||
seen.add(parent)
|
||||
const owner = knownOwners.get(parent)
|
||||
if (owner) {
|
||||
knownOwners.set(session.id, owner)
|
||||
inherited.push({ sessionId: session.id, paneKey: owner, basis: 'creation-correlation' })
|
||||
break
|
||||
}
|
||||
parent = deps.parentBySessionId.get(parent)
|
||||
}
|
||||
}
|
||||
const ownerships = [
|
||||
...inherited,
|
||||
...correlateOpenCodeSessionOwners({
|
||||
sessions,
|
||||
panes,
|
||||
clients,
|
||||
knownOwners
|
||||
})
|
||||
]
|
||||
return { ownerships }
|
||||
}
|
||||
|
||||
/** True when the v2 session table has every column the binder reads. */
|
||||
function canReadSessionV2(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'session_v2') &&
|
||||
columnExists(db, 'session_v2', 'directory') &&
|
||||
columnExists(db, 'session_v2', 'time_created')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions newer than `cursor`, oldest first. The composite
|
||||
* `(time_created, id)` position means rows sharing a millisecond with the
|
||||
* cursor — including rows the LIMIT cut off last round — are re-listed
|
||||
* instead of permanently skipped. Unknown shapes read as empty so an opencode
|
||||
* schema move degrades to unbound sessions, never a crash. Fail-open [] on
|
||||
* any read error for the same reason.
|
||||
*/
|
||||
export function listOpenCodeDbSessions(
|
||||
dbPath: string,
|
||||
cursor: OpenCodeSessionCursor
|
||||
): BinderSessionRow[] {
|
||||
try {
|
||||
return readOpenCodeDatabase({
|
||||
dbPath,
|
||||
read: (db) => {
|
||||
const table = canReadSessionV2(db) ? 'session_v2' : 'session'
|
||||
if (
|
||||
!tableExists(db, table) ||
|
||||
!columnExists(db, table, 'directory') ||
|
||||
!columnExists(db, table, 'time_created')
|
||||
) {
|
||||
return []
|
||||
}
|
||||
const parent = columnExists(db, table, 'parent_id') ? 'parent_id' : 'NULL'
|
||||
const rows: unknown[] = db
|
||||
.prepare(
|
||||
`SELECT id, directory, time_created, ${parent} AS parent_id FROM ${table} WHERE time_created > ? OR (time_created = ? AND id > ?) ORDER BY time_created ASC, id ASC LIMIT 500`
|
||||
)
|
||||
.all(cursor.ms, cursor.ms, cursor.id)
|
||||
const sessions: BinderSessionRow[] = []
|
||||
for (const row of rows) {
|
||||
if (typeof row !== 'object' || row === null) {
|
||||
continue
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite returns plain row objects; the object check above plus the per-field validation below reject anything else.
|
||||
const record = row as Record<string, unknown>
|
||||
if (
|
||||
typeof record.id !== 'string' ||
|
||||
typeof record.directory !== 'string' ||
|
||||
typeof record.time_created !== 'number'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
sessions.push({
|
||||
id: record.id,
|
||||
directory: record.directory,
|
||||
createdAtMs: record.time_created,
|
||||
parentId: typeof record.parent_id === 'string' ? record.parent_id : null
|
||||
})
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[opencode-binder] session store read failed; skipping round', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Default database path for the local shared server. */
|
||||
export function defaultOpenCodeDbPath(): string {
|
||||
return `${resolveOpenCodeDataDirectory()}/opencode.db`
|
||||
}
|
||||
|
||||
/**
|
||||
* Live local panes from the PTY registry: pane key, worktree root and shell
|
||||
* pid. Panes without a key or pid (hydrated gaps, remote panes) cannot own a
|
||||
* client subtree, so they are skipped — their sessions stay unbound rather
|
||||
* than guessed.
|
||||
*/
|
||||
export function listBinderPaneSnapshots(): BinderPaneSnapshot[] {
|
||||
const snapshots: BinderPaneSnapshot[] = []
|
||||
for (const pty of listRegisteredPtys()) {
|
||||
if (!pty.paneKey || pty.pid === null) {
|
||||
continue
|
||||
}
|
||||
const parsed = pty.worktreeId ? splitWorktreeIdForFilesystem(pty.worktreeId) : null
|
||||
snapshots.push({
|
||||
paneKey: pty.paneKey,
|
||||
directory: parsed?.worktreePath ?? null,
|
||||
worktreeId: pty.worktreeId,
|
||||
shellPid: pty.pid
|
||||
})
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
/** Apply one round's decisions to the listener registry. */
|
||||
export function applyBinderOwnerships(
|
||||
state: HookListenerState,
|
||||
panes: readonly BinderPaneSnapshot[],
|
||||
ownerships: readonly SessionOwnership[],
|
||||
nowMs: number
|
||||
): number {
|
||||
const worktreeByPane = new Map<string, string | null>()
|
||||
for (const pane of panes) {
|
||||
// Why overwrite: matching the round's newest-wins pane dedupe, so a
|
||||
// remint's live row wins over a stale row with a different worktree.
|
||||
worktreeByPane.set(pane.paneKey, pane.worktreeId)
|
||||
}
|
||||
let applied = 0
|
||||
for (const ownership of ownerships) {
|
||||
const binding: OpenCodeSessionBinding = {
|
||||
paneKey: ownership.paneKey,
|
||||
boundAt: nowMs,
|
||||
basis: ownership.basis
|
||||
}
|
||||
const worktreeId = worktreeByPane.get(ownership.paneKey)
|
||||
if (worktreeId) {
|
||||
binding.worktreeId = worktreeId
|
||||
}
|
||||
if (bindOpenCodeSession(state, ownership.sessionId, binding)) {
|
||||
applied += 1
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
emitSessionStart: boolean
|
||||
emitNextEvents?: boolean
|
||||
}): string[] {
|
||||
const expectedAgent = options.emitNextEvents ? 'opencode2' : 'opencode'
|
||||
return [
|
||||
...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []),
|
||||
'// Why: accept the factory argument as an optional opaque parameter instead',
|
||||
@@ -15,6 +16,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
'// destructuring form throw synchronously and crash OpenCode with an opaque',
|
||||
'// UnknownError before any event is ever dispatched.',
|
||||
'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {',
|
||||
` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`,
|
||||
' const client = _ctx?.client;',
|
||||
' const factoryID = ++nextFactoryID;',
|
||||
' activeFactoryIDs.add(factoryID);',
|
||||
|
||||
@@ -15,6 +15,53 @@ const OMP_RUNTIME_CASES = [
|
||||
] as const
|
||||
|
||||
describe('OMP agent_end contract', () => {
|
||||
it('keeps a Pi pane working until async subagents finish', async () => {
|
||||
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
harness.emitPiEvent('task:subagent:lifecycle', { id: 'child-1', status: 'started' })
|
||||
await harness.callHook('agent_settled', undefined, { isIdle: () => true })
|
||||
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start'])
|
||||
|
||||
harness.emitPiEvent('task:subagent:lifecycle', { id: 'child-1', status: 'completed' })
|
||||
await vi.waitFor(() =>
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores malformed or unknown Pi subagent lifecycle events', async () => {
|
||||
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
|
||||
harness.emitPiEvent('task:subagent:lifecycle', {})
|
||||
harness.emitPiEvent('task:subagent:lifecycle', { id: 'child-1', status: 'paused' })
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('agent_settled')
|
||||
await vi.waitFor(() =>
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps one lifecycle subscription across extension reloads', async () => {
|
||||
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
|
||||
harness.reload()
|
||||
expect(harness.piEventListenerCount('task:subagent:lifecycle')).toBe(1)
|
||||
expect(harness.piEventListenerCount('subagent:async-started')).toBe(1)
|
||||
expect(harness.piEventListenerCount('subagent:async-complete')).toBe(1)
|
||||
harness.emitPiEvent('task:subagent:lifecycle', { id: 'child-1', status: 'started' })
|
||||
await vi.waitFor(() => expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start']))
|
||||
})
|
||||
|
||||
it('accepts the pi-subagents async lifecycle aliases', async () => {
|
||||
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
|
||||
harness.emitPiEvent('subagent:async-started', { id: 'child-1' })
|
||||
await harness.callHook('agent_settled')
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start'])
|
||||
harness.emitPiEvent('subagent:async-complete', { id: 'child-1' })
|
||||
await vi.waitFor(() =>
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
|
||||
)
|
||||
})
|
||||
|
||||
it.each(OMP_RUNTIME_CASES)(
|
||||
'keeps %s working when agent_end will continue',
|
||||
async (_name, args) => {
|
||||
|
||||
@@ -46,6 +46,8 @@ export type AgentStatusExtensionHarness = {
|
||||
handlers: Record<string, HookHandler>
|
||||
processEnv: Record<string, string | undefined>
|
||||
callHook: (name: string, event?: unknown, context?: HookContext) => Promise<void>
|
||||
emitPiEvent: (name: string, event: unknown) => void
|
||||
piEventListenerCount: (name: string) => number
|
||||
// Re-invoke the extension factory in the same process (as Pi does on an
|
||||
// in-process extension reload), swapping in the freshly registered handlers.
|
||||
reload: () => void
|
||||
@@ -130,6 +132,7 @@ export function createAgentStatusExtensionHarness(args: {
|
||||
command: { handler: (args: string, context: HookContext) => Promise<void> }
|
||||
) => void
|
||||
setModel: (model: unknown) => Promise<boolean>
|
||||
events?: EventEmitter
|
||||
}) => void
|
||||
}
|
||||
} = { exports: {} }
|
||||
@@ -191,6 +194,7 @@ export function createAgentStatusExtensionHarness(args: {
|
||||
}
|
||||
|
||||
const handlers: Record<string, HookHandler> = {}
|
||||
const piEvents = new EventEmitter()
|
||||
const commands: AgentStatusExtensionHarness['commands'] = {}
|
||||
const setModelMock = vi.fn(async (_model: unknown) => true)
|
||||
const registerInto = (target: Record<string, HookHandler>): void => {
|
||||
@@ -199,6 +203,7 @@ export function createAgentStatusExtensionHarness(args: {
|
||||
commands[name] = command
|
||||
},
|
||||
setModel: setModelMock,
|
||||
events: piEvents,
|
||||
on(name: string, handler: HookHandler) {
|
||||
target[name] = handler
|
||||
}
|
||||
@@ -219,6 +224,10 @@ export function createAgentStatusExtensionHarness(args: {
|
||||
callHook: async (name, event, hookContext) => {
|
||||
await handlers[name]?.(event, hookContext)
|
||||
},
|
||||
emitPiEvent: (name, event) => {
|
||||
piEvents.emit(name, event)
|
||||
},
|
||||
piEventListenerCount: (name) => piEvents.listenerCount(name),
|
||||
reload: () => {
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
|
||||
@@ -129,13 +129,27 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return',
|
||||
` process.env.${ownerEnv} = selfPid`,
|
||||
' resetPostQueue()',
|
||||
' const piEventBus = (pi as { events?: { on?: (name: string, handler: (event: unknown) => void) => void } }).events',
|
||||
' const lifecycleState = (piEventBus as { __orcaPiSubagents?: { active: Set<string>; waiting: boolean; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void } } | undefined)?.__orcaPiSubagents ?? { active: new Set<string>(), waiting: false }',
|
||||
' if (piEventBus) (piEventBus as { __orcaPiSubagents?: unknown }).__orcaPiSubagents = lifecycleState',
|
||||
' if (piEventBus?.on && !(lifecycleState as { listener?: unknown }).listener) {',
|
||||
' const listener = (event: unknown) => lifecycleState.onEvent?.(event)',
|
||||
' lifecycleState.listener = listener',
|
||||
" piEventBus.on('task:subagent:lifecycle', listener)",
|
||||
" piEventBus.on('subagent:async-started', (event: unknown) => lifecycleState.onEvent?.(event, 'started'))",
|
||||
" piEventBus.on('subagent:async-complete', (event: unknown) => lifecycleState.onEvent?.(event, 'completed'))",
|
||||
' }',
|
||||
...(kind !== 'pi'
|
||||
? [" pi.on('session_shutdown', () => { resetPostQueue(); clearPendingAgentEndCheck() })"]
|
||||
? [
|
||||
" pi.on('session_shutdown', () => { lifecycleState.active.clear(); lifecycleState.waiting = false; resetPostQueue(); clearPendingAgentEndCheck() })"
|
||||
]
|
||||
: []),
|
||||
...(kind !== 'prime-agent'
|
||||
? [
|
||||
" pi.on('session_switch', (_event, ctx) => {",
|
||||
' if (!isOmpRuntime()) return',
|
||||
' lifecycleState.active.clear()',
|
||||
' lifecycleState.waiting = false',
|
||||
' resetPostQueue()',
|
||||
' clearPendingAgentEndCheck()',
|
||||
' updateRuntimeOmpSessionMetadata(ctx)',
|
||||
@@ -154,6 +168,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
` onStatus('agent_start', (${bareCtxParams}) => {`,
|
||||
...captureSessionMetadata,
|
||||
' clearPendingAgentEndCheck()',
|
||||
' lifecycleState.waiting = false',
|
||||
' runGeneration += 1',
|
||||
// Why: a turn cannot begin under a dialog holding input focus, so this is the one
|
||||
// boundary that can recover a modal whose close never arrived.
|
||||
@@ -224,11 +239,25 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' pendingAgentEndCheck = null',
|
||||
' pendingAgentEndContext = null',
|
||||
' }',
|
||||
'',
|
||||
' // Why: isIdle flips before agent_settled handlers run, so both paths',
|
||||
' // share a guard instead of racing duplicate completion posts — one keyed on the',
|
||||
' // generation of the run that ENDED, so a later run still reports its own end.',
|
||||
' // Defer completion while live child work remains.',
|
||||
' lifecycleState.onEvent = (event: unknown, forcedStatus?: string): void => {',
|
||||
" if (!event || typeof event !== 'object') return",
|
||||
" const id = typeof (event as { id?: unknown }).id === 'string' ? (event as { id: string }).id : ''",
|
||||
' const status = forcedStatus ?? (event as { status?: unknown }).status',
|
||||
' if (!id) return',
|
||||
" if (status === 'started') { lifecycleState.active.add(id); post('agent_start'); return }",
|
||||
" if (status !== 'completed' && status !== 'failed' && status !== 'aborted') return",
|
||||
' lifecycleState.active.delete(id)',
|
||||
' if (lifecycleState.active.size === 0 && lifecycleState.waiting) {',
|
||||
' lifecycleState.waiting = false',
|
||||
' postAgentEndOnce()',
|
||||
' }',
|
||||
' }',
|
||||
' function postAgentEndOnce(): void {',
|
||||
' if (lifecycleState.active.size > 0) {',
|
||||
' lifecycleState.waiting = true',
|
||||
' return',
|
||||
' }',
|
||||
' if (completionPostedGeneration === endedRunGeneration) return',
|
||||
' completionPostedGeneration = endedRunGeneration',
|
||||
// Why: distinct from the completion guard, which holds the generation of the posted run
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
// Why: the opencode.ai page is rendered with React Server Components. The
|
||||
// embedded JS uses a wire format where object references look like:
|
||||
// key:$R[28]={field:value,...}
|
||||
// rather than plain `key:{field:value,...}`. A single key (e.g. monthlyUsage)
|
||||
// can appear multiple times — once with real data and once as `null` inside a
|
||||
// different component's props. We must find the occurrence that is an object
|
||||
// with both usagePercent and resetInSec, not the null one.
|
||||
|
||||
/**
|
||||
* Finds the brace-balanced object block assigned to `key` anywhere in `text`.
|
||||
* Skips React Flight assignment tokens (e.g. `$R[N]=`) between the colon and
|
||||
* the opening brace. Returns the first block that contains `usagePercent` AND
|
||||
* `resetInSec` as direct numeric properties (not nested), so that placeholder
|
||||
* `null` occurrences and billing-context duplicates are ignored.
|
||||
*/
|
||||
function extractUsageBlock(text: string, key: string): string | null {
|
||||
// Match every occurrence of `key:` (with optional $R[N]= assignment)
|
||||
// Why: React Flight wire format embeds object references between the colon
|
||||
// and the literal brace, so we skip over any `$R[N]=` tokens to reach `{`.
|
||||
const keyRegex = new RegExp(`\\b${key}\\b\\s*:`, 'g')
|
||||
let keyMatch: RegExpExecArray | null
|
||||
|
||||
while ((keyMatch = keyRegex.exec(text)) !== null) {
|
||||
// Scan forward from after the colon to find the opening `{`,
|
||||
// allowing for the `$R[N]=` token or plain whitespace in between.
|
||||
// We only scan a short window so we don't accidentally land on the
|
||||
// next occurrence of the key.
|
||||
const searchStart = keyMatch.index + keyMatch[0].length
|
||||
const searchWindow = text.slice(searchStart, searchStart + 30)
|
||||
const braceOffset = searchWindow.indexOf('{')
|
||||
if (braceOffset === -1) {
|
||||
// This occurrence has no object (e.g. `monthlyUsage:null`) — skip.
|
||||
continue
|
||||
}
|
||||
|
||||
const openBrace = searchStart + braceOffset
|
||||
// Extract the balanced block
|
||||
// Why: this brace-depth parser does not skip string literals. React Flight's
|
||||
// current format does not emit raw { } inside strings, but this is a scraper
|
||||
// against HTML we don't control — treat as fragile.
|
||||
let depth = 0
|
||||
let block: string | null = null
|
||||
for (let i = openBrace; i < text.length; i++) {
|
||||
if (text[i] === '{') {
|
||||
depth++
|
||||
} else if (text[i] === '}') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
block = text.slice(openBrace, i + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify this block has both required numeric fields as direct properties
|
||||
// (depth 1 within the block). This rejects billing/plan objects that share
|
||||
// the key name but lack usage data.
|
||||
if (
|
||||
hasDirectNumericField(block, 'usagePercent') &&
|
||||
hasDirectNumericField(block, 'resetInSec')
|
||||
) {
|
||||
return block
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `fieldName` exists as a direct (depth-1) numeric property
|
||||
* of the object string `objText`.
|
||||
*/
|
||||
function hasDirectNumericField(objText: string, fieldName: string): boolean {
|
||||
return extractTopLevelNumber(objText, fieldName) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a numeric field at depth 1 of `objText` — ignores the same field
|
||||
* inside nested sub-objects.
|
||||
* Why: without depth tracking, a regex matches the first occurrence regardless
|
||||
* of nesting, returning wrong values when a sub-object contains the same name.
|
||||
*/
|
||||
function extractTopLevelNumber(objText: string, fieldName: string): number | null {
|
||||
const fieldRegex = new RegExp(`\\b${fieldName}\\b\\s*:\\s*(-?[0-9]+(?:\\.[0-9]+)?)`)
|
||||
// Why: this brace-depth parser does not skip string literals. React Flight's
|
||||
// current format does not emit raw { } inside strings, but this is a scraper
|
||||
// against HTML we don't control — treat as fragile.
|
||||
let depth = 0
|
||||
|
||||
for (let i = 0; i < objText.length; i++) {
|
||||
const ch = objText[i]
|
||||
if (ch === '{') {
|
||||
depth++
|
||||
continue
|
||||
}
|
||||
if (ch === '}') {
|
||||
depth--
|
||||
continue
|
||||
}
|
||||
|
||||
// Only match at depth 1 (direct property of the root object).
|
||||
if (depth === 1) {
|
||||
const slice = objText.slice(i, i + fieldName.length + 30)
|
||||
const m = fieldRegex.exec(slice)
|
||||
if (m && m.index === 0) {
|
||||
const n = Number.parseFloat(m[1])
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type ParsedSubscription = {
|
||||
rollingUsagePercent: number
|
||||
weeklyUsagePercent: number
|
||||
monthlyUsagePercent: number | null
|
||||
rollingResetInSec: number
|
||||
weeklyResetInSec: number
|
||||
monthlyResetInSec: number | null
|
||||
}
|
||||
|
||||
export function parseSubscriptionFromPageText(text: string): ParsedSubscription | null {
|
||||
// Why: OpenCode usage is scraped from HTML-embedded JS (React Flight wire
|
||||
// format). Defensive size check prevents runaway parsing on unexpected payloads.
|
||||
if (!text || text.length > 10_000_000) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the first occurrence of each usage key that has both usagePercent and
|
||||
// resetInSec as direct numeric fields. This skips null occurrences and
|
||||
// billing-context duplicates that use the same key name without usage data.
|
||||
const rollingBlock = extractUsageBlock(text, 'rollingUsage')
|
||||
const weeklyBlock = extractUsageBlock(text, 'weeklyUsage')
|
||||
const monthlyBlock = extractUsageBlock(text, 'monthlyUsage')
|
||||
|
||||
const rollingPercent =
|
||||
rollingBlock !== null ? extractTopLevelNumber(rollingBlock, 'usagePercent') : null
|
||||
const rollingReset =
|
||||
rollingBlock !== null ? extractTopLevelNumber(rollingBlock, 'resetInSec') : null
|
||||
const weeklyPercent =
|
||||
weeklyBlock !== null ? extractTopLevelNumber(weeklyBlock, 'usagePercent') : null
|
||||
const weeklyReset = weeklyBlock !== null ? extractTopLevelNumber(weeklyBlock, 'resetInSec') : null
|
||||
|
||||
if (
|
||||
rollingPercent === null ||
|
||||
rollingReset === null ||
|
||||
weeklyPercent === null ||
|
||||
weeklyReset === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const monthlyPercent =
|
||||
monthlyBlock !== null ? extractTopLevelNumber(monthlyBlock, 'usagePercent') : null
|
||||
const monthlyReset =
|
||||
monthlyBlock !== null ? extractTopLevelNumber(monthlyBlock, 'resetInSec') : null
|
||||
|
||||
return {
|
||||
rollingUsagePercent: Math.min(100, Math.max(0, rollingPercent)),
|
||||
weeklyUsagePercent: Math.min(100, Math.max(0, weeklyPercent)),
|
||||
monthlyUsagePercent:
|
||||
monthlyPercent !== null ? Math.min(100, Math.max(0, monthlyPercent)) : null,
|
||||
rollingResetInSec: rollingReset,
|
||||
weeklyResetInSec: weeklyReset,
|
||||
monthlyResetInSec: monthlyReset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing'
|
||||
|
||||
const ISSUE_PAYLOAD = {
|
||||
access: {
|
||||
meters: {
|
||||
fiveHour: {
|
||||
resetsAt: '2026-09-18T12:42:04.962Z',
|
||||
limitMicroCents: '1200000000',
|
||||
usedMicroCents: '121745383'
|
||||
},
|
||||
week: {
|
||||
resetsAt: '2026-09-21T00:00:00.000Z',
|
||||
limitMicroCents: '3000000000',
|
||||
usedMicroCents: '121745383'
|
||||
},
|
||||
month: {
|
||||
limitMicroCents: '6000000000',
|
||||
usedMicroCents: '121745383'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseOpenCodeGoStatusPayload', () => {
|
||||
it('maps fiveHour/week/month meters into session/weekly/monthly windows', () => {
|
||||
const parsed = parseOpenCodeGoStatusPayload(JSON.stringify(ISSUE_PAYLOAD))
|
||||
|
||||
expect(parsed).not.toBeNull()
|
||||
expect(parsed?.session).toEqual({
|
||||
usedPercent: (121745383 / 1_200_000_000) * 100,
|
||||
windowMinutes: 300,
|
||||
resetsAt: Date.parse('2026-09-18T12:42:04.962Z'),
|
||||
resetDescription: null
|
||||
})
|
||||
expect(parsed?.weekly).toEqual({
|
||||
usedPercent: (121745383 / 3_000_000_000) * 100,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: Date.parse('2026-09-21T00:00:00.000Z'),
|
||||
resetDescription: null
|
||||
})
|
||||
expect(parsed?.monthly).toEqual({
|
||||
usedPercent: (121745383 / 6_000_000_000) * 100,
|
||||
windowMinutes: 43_200,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts numeric microCents', () => {
|
||||
const parsed = parseOpenCodeGoStatusPayload(
|
||||
JSON.stringify({
|
||||
access: {
|
||||
meters: {
|
||||
fiveHour: {
|
||||
resetsAt: '2026-09-18T12:42:04.962Z',
|
||||
limitMicroCents: 100,
|
||||
usedMicroCents: 25
|
||||
},
|
||||
week: {
|
||||
resetsAt: '2026-09-21T00:00:00.000Z',
|
||||
limitMicroCents: 200,
|
||||
usedMicroCents: 50
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(parsed?.session?.usedPercent).toBe(25)
|
||||
expect(parsed?.weekly?.usedPercent).toBe(25)
|
||||
expect(parsed?.monthly).toBeNull()
|
||||
})
|
||||
|
||||
it('caps usedPercent at 100 and floors at 0', () => {
|
||||
const parsed = parseOpenCodeGoStatusPayload(
|
||||
JSON.stringify({
|
||||
access: {
|
||||
meters: {
|
||||
fiveHour: {
|
||||
resetsAt: '2026-09-18T12:42:04.962Z',
|
||||
limitMicroCents: '100',
|
||||
usedMicroCents: '150'
|
||||
},
|
||||
week: {
|
||||
resetsAt: '2026-09-21T00:00:00.000Z',
|
||||
limitMicroCents: '100',
|
||||
usedMicroCents: '-5'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(parsed?.session?.usedPercent).toBe(100)
|
||||
expect(parsed?.weekly?.usedPercent).toBe(0)
|
||||
})
|
||||
|
||||
it('returns null for HTML and other non-JSON bodies', () => {
|
||||
expect(parseOpenCodeGoStatusPayload('<html>rollingUsage:{usagePercent:30}</html>')).toBeNull()
|
||||
expect(parseOpenCodeGoStatusPayload('')).toBeNull()
|
||||
expect(parseOpenCodeGoStatusPayload('{not json')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when fiveHour or week meters are missing', () => {
|
||||
expect(
|
||||
parseOpenCodeGoStatusPayload(
|
||||
JSON.stringify({
|
||||
access: {
|
||||
meters: {
|
||||
week: { limitMicroCents: '100', usedMicroCents: '10' }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
|
||||
const SESSION_WINDOW_MINUTES = 300
|
||||
const WEEKLY_WINDOW_MINUTES = 10_080
|
||||
const MONTHLY_WINDOW_MINUTES = 43_200
|
||||
const MAX_STATUS_PAYLOAD_CHARS = 1_000_000
|
||||
|
||||
export type OpenCodeGoUsageWindows = {
|
||||
session: RateLimitWindow
|
||||
weekly: RateLimitWindow
|
||||
monthly: RateLimitWindow | null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseMicroCents(value: unknown): number | null {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function parseResetsAt(value: unknown): number | null {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return null
|
||||
}
|
||||
const resetsAt = Date.parse(value)
|
||||
return Number.isFinite(resetsAt) ? resetsAt : null
|
||||
}
|
||||
|
||||
function meterToWindow(meter: unknown, windowMinutes: number): RateLimitWindow | null {
|
||||
if (!isRecord(meter)) {
|
||||
return null
|
||||
}
|
||||
const used = parseMicroCents(meter.usedMicroCents)
|
||||
const limit = parseMicroCents(meter.limitMicroCents)
|
||||
if (used === null || limit === null || limit <= 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
usedPercent: Math.min(100, Math.max(0, (used / limit) * 100)),
|
||||
windowMinutes,
|
||||
resetsAt: parseResetsAt(meter.resetsAt),
|
||||
resetDescription: null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null {
|
||||
if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) {
|
||||
return null
|
||||
}
|
||||
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isRecord(payload) || !isRecord(payload.access) || !isRecord(payload.access.meters)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const meters = payload.access.meters
|
||||
const session = meterToWindow(meters.fiveHour, SESSION_WINDOW_MINUTES)
|
||||
const weekly = meterToWindow(meters.week, WEEKLY_WINDOW_MINUTES)
|
||||
if (!session || !weekly) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
weekly,
|
||||
monthly: meterToWindow(meters.month, MONTHLY_WINDOW_MINUTES)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,10 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher'
|
||||
|
||||
const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f'
|
||||
const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status'
|
||||
const LEGACY_WORKSPACE_GO_URL = /https:\/\/opencode\.ai\/workspace\/[^/]+\/go/
|
||||
|
||||
function makeResponse(body: string, status = 200): Response {
|
||||
return {
|
||||
@@ -22,25 +25,61 @@ function makeResponse(body: string, status = 200): Response {
|
||||
} as Response
|
||||
}
|
||||
|
||||
// Real React Flight wire format from opencode.ai — keys like `monthlyUsage`
|
||||
// appear multiple times: once with actual data (as `$R[N]={...}`) and once as
|
||||
// `null` inside a billing-context object. The parser must pick the data one.
|
||||
const USAGE_PAGE_WITH_MONTHLY = `
|
||||
<html><body><script>
|
||||
$RC=function(a,b){/*...*/};
|
||||
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:7200,usagePercent:30},weeklyUsage:$R[22]={status:"ok",resetInSec:259200,usagePercent:51},monthlyUsage:$R[23]={status:"ok",resetInSec:1296000,usagePercent:89}};
|
||||
$R[14]={customerID:"cus_ABC",reloadTrigger:5,monthlyLimit:null,monthlyUsage:null,timeMonthlyUsageUpdated:null};
|
||||
</script></body></html>
|
||||
`
|
||||
function makeJsonResponse(body: unknown, status = 200): Response {
|
||||
return makeResponse(JSON.stringify(body), status)
|
||||
}
|
||||
|
||||
const USAGE_PAGE_NO_MONTHLY = `
|
||||
const STATUS_WITH_MONTHLY = {
|
||||
access: {
|
||||
meters: {
|
||||
fiveHour: {
|
||||
resetsAt: '2026-04-24T14:00:00.000Z',
|
||||
limitMicroCents: '1000',
|
||||
usedMicroCents: '300'
|
||||
},
|
||||
week: {
|
||||
resetsAt: '2026-05-01T12:00:00.000Z',
|
||||
limitMicroCents: '1000',
|
||||
usedMicroCents: '510'
|
||||
},
|
||||
month: {
|
||||
resetsAt: '2026-05-24T12:00:00.000Z',
|
||||
limitMicroCents: '1000',
|
||||
usedMicroCents: '890'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_NO_MONTHLY = {
|
||||
access: {
|
||||
meters: {
|
||||
fiveHour: {
|
||||
resetsAt: '2026-04-24T13:00:00.000Z',
|
||||
limitMicroCents: '100',
|
||||
usedMicroCents: '10'
|
||||
},
|
||||
week: {
|
||||
resetsAt: '2026-04-25T12:00:00.000Z',
|
||||
limitMicroCents: '100',
|
||||
usedMicroCents: '20'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_USAGE_PAGE = `
|
||||
<html><body><script>
|
||||
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10},weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20}};
|
||||
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:7200,usagePercent:30},weeklyUsage:$R[22]={status:"ok",resetInSec:259200,usagePercent:51},monthlyUsage:$R[23]={status:"ok",resetInSec:1296000,usagePercent:89}};
|
||||
</script></body></html>
|
||||
`
|
||||
|
||||
const WORKSPACES_RESPONSE = 'id: "wrk_TESTWORKSPACEID123"'
|
||||
|
||||
function requestedUrls(): string[] {
|
||||
return netFetchMock.mock.calls.map(([url]) => String(url))
|
||||
}
|
||||
|
||||
describe('fetchOpenCodeGoRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -79,7 +118,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cookie has no auth or __Host-auth name', async () => {
|
||||
it('returns error when cookie has no known auth name', async () => {
|
||||
const result = await fetchOpenCodeGoRateLimits('session=abc123; other=xyz')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
@@ -105,8 +144,17 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
expect(normalizeCookieInput('__Host-auth=token')).toBe('__Host-auth=token')
|
||||
})
|
||||
|
||||
it('leaves __Host-console_session=... unchanged', () => {
|
||||
expect(normalizeCookieInput('__Host-console_session=consoleTok')).toBe(
|
||||
'__Host-console_session=consoleTok'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves multi-pair cookie headers unchanged', () => {
|
||||
expect(normalizeCookieInput('auth=tok; other=val')).toBe('auth=tok; other=val')
|
||||
expect(normalizeCookieInput('auth=tok; __Host-console_session=consoleTok')).toBe(
|
||||
'auth=tok; __Host-console_session=consoleTok'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace before wrapping', () => {
|
||||
@@ -123,7 +171,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
it('accepts a bare token (auto-wraps to auth=<token>)', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('Fe26.2**baretoken')
|
||||
|
||||
@@ -136,7 +184,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
it('uses GET /_server?id=<hash> with correct headers for workspaces', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
@@ -156,7 +204,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
it('uses an isolated session cookie jar and clears it after fetching', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
@@ -210,9 +258,9 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
it('applies configured proxy settings once to the isolated session', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
const proxySettings = {
|
||||
httpProxyUrl: 'http://proxy.example:8080',
|
||||
@@ -244,55 +292,61 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches usage from /workspace/<id>/go after resolving workspace ID', async () => {
|
||||
it('fetches usage from /console/api/go/status with x-org-id and never scrapes /workspace/<id>/go', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false)
|
||||
expect(netFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://opencode.ai/workspace/wrk_TESTWORKSPACEID123/go',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
CONSOLE_STATUS_URL,
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
'x-org-id': 'wrk_TESTWORKSPACEID123',
|
||||
Accept: 'application/json'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie')
|
||||
})
|
||||
|
||||
it('returns ok with session, weekly, and monthly windows', async () => {
|
||||
it('returns ok with session, weekly, and monthly windows from JSON meters', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
const now = Date.now()
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.error).toBeNull()
|
||||
|
||||
expect(result.session).toEqual({
|
||||
usedPercent: 30,
|
||||
windowMinutes: 300,
|
||||
resetsAt: now + 7200 * 1000,
|
||||
resetsAt: Date.parse('2026-04-24T14:00:00.000Z'),
|
||||
resetDescription: null
|
||||
})
|
||||
expect(result.weekly).toEqual({
|
||||
usedPercent: 51,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: now + 259200 * 1000,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: Date.parse('2026-05-01T12:00:00.000Z'),
|
||||
resetDescription: null
|
||||
})
|
||||
expect(result.monthly).toEqual({
|
||||
usedPercent: 89,
|
||||
windowMinutes: 43200,
|
||||
resetsAt: now + 1296000 * 1000,
|
||||
windowMinutes: 43_200,
|
||||
resetsAt: Date.parse('2026-05-24T12:00:00.000Z'),
|
||||
resetDescription: null
|
||||
})
|
||||
})
|
||||
|
||||
it('returns ok with null monthly when monthlyUsage is absent', async () => {
|
||||
it('returns ok with null monthly when the month meter is absent', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_NO_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
@@ -303,13 +357,24 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
})
|
||||
|
||||
it('caps usedPercent at 100 and floors at 0', async () => {
|
||||
const page = `
|
||||
rollingUsage: { usagePercent: 150, resetInSec: 3600 }
|
||||
weeklyUsage: { usagePercent: -5, resetInSec: 86400 }
|
||||
`
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(page))
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)).mockResolvedValueOnce(
|
||||
makeJsonResponse({
|
||||
access: {
|
||||
meters: {
|
||||
fiveHour: {
|
||||
resetsAt: '2026-04-24T13:00:00.000Z',
|
||||
limitMicroCents: '100',
|
||||
usedMicroCents: '150'
|
||||
},
|
||||
week: {
|
||||
resetsAt: '2026-04-25T12:00:00.000Z',
|
||||
limitMicroCents: '100',
|
||||
usedMicroCents: '-5'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=token')
|
||||
|
||||
@@ -318,77 +383,73 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
expect(result.weekly?.usedPercent).toBe(0)
|
||||
})
|
||||
|
||||
it('parses React Flight wire format with $R[N]= assignment tokens', async () => {
|
||||
// Real format from opencode.ai — keys have $R[N]= between the colon and brace.
|
||||
const page = `
|
||||
rollingUsage:$R[21]={status:"ok",resetInSec:1337,usagePercent:42},
|
||||
weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:68}
|
||||
`
|
||||
it('does not treat the old HTML usage page as success', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(page))
|
||||
.mockResolvedValueOnce(makeResponse(LEGACY_USAGE_PAGE))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=token')
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(42)
|
||||
expect(result.weekly?.usedPercent).toBe(68)
|
||||
})
|
||||
|
||||
it('skips null occurrences and finds the real data block for monthlyUsage', async () => {
|
||||
// Regression: on refresh, monthlyUsage:null appeared BEFORE the real
|
||||
// monthlyUsage:$R[N]={usagePercent:89,...} in a different component's props.
|
||||
// Parser must skip the null and find the data block.
|
||||
const page = `
|
||||
rollingUsage:$R[21]={status:"ok",resetInSec:18000,usagePercent:0},
|
||||
weeklyUsage:$R[22]={status:"ok",resetInSec:57781,usagePercent:51},
|
||||
monthlyUsage:null,timeMonthlyUsageUpdated:null,
|
||||
monthlyUsage:$R[28]={status:"ok",resetInSec:1214779,usagePercent:89}
|
||||
`
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(page))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=token')
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.monthly?.usedPercent).toBe(89)
|
||||
expect(result.monthly?.resetsAt).toBe(Date.now() + 1214779 * 1000)
|
||||
})
|
||||
|
||||
it('returns null monthly when all monthlyUsage occurrences are null', async () => {
|
||||
const page = `
|
||||
rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10},
|
||||
weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20},
|
||||
monthlyUsage:null,timeMonthlyUsageUpdated:null
|
||||
`
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(page))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=token')
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.monthly).toBeNull()
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('Could not parse usage data')
|
||||
expect(result.session).toBeNull()
|
||||
})
|
||||
|
||||
it('skips workspace lookup when workspaceIdOverride is provided', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false)
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://opencode.ai/workspace/wrk_OVERRIDE123/go',
|
||||
expect.anything()
|
||||
CONSOLE_STATUS_URL,
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({ 'x-org-id': 'wrk_OVERRIDE123' })
|
||||
})
|
||||
)
|
||||
expect(result.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('keeps __Host-console_session and drops unrelated cookie names', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
await fetchOpenCodeGoRateLimits(
|
||||
'session=secret; __Host-console_session=consoleTok; tracking=xyz; auth=realtoken',
|
||||
'wrk_OVERRIDE123'
|
||||
)
|
||||
|
||||
expect(cookiesSetMock).toHaveBeenCalledTimes(2)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: '__Host-console_session', value: 'consoleTok' })
|
||||
)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'auth', value: 'realtoken' })
|
||||
)
|
||||
expect(cookiesSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'session' }))
|
||||
expect(cookiesSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'tracking' }))
|
||||
})
|
||||
|
||||
it('accepts a console session cookie without wrapping it as auth=', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits(
|
||||
'__Host-console_session=consoleTok',
|
||||
'wrk_OVERRIDE123'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(cookiesSetMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: '__Host-console_session', value: 'consoleTok' })
|
||||
)
|
||||
})
|
||||
|
||||
it('filters cookie to auth name only', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
|
||||
await fetchOpenCodeGoRateLimits('session=secret; auth=realtoken; tracking=xyz')
|
||||
|
||||
@@ -426,7 +487,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
expect(result.error).toMatch(/No workspace ID found/)
|
||||
})
|
||||
|
||||
it('returns error on non-ok usage page response', async () => {
|
||||
it('returns error on non-ok usage response', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse('Not Found', 404))
|
||||
@@ -434,18 +495,31 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('Usage page fetch failed (404)')
|
||||
expect(result.error).toBe('Usage fetch failed (404)')
|
||||
})
|
||||
|
||||
it('returns error when usage data cannot be parsed from page', async () => {
|
||||
it('tells the user to include __Host-console_session when usage fetch returns 401', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse('<html>no usage data here</html>'))
|
||||
.mockResolvedValueOnce(makeResponse('Unauthorized', 401))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('Could not parse usage data from page')
|
||||
expect(result.error).toBe(
|
||||
'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns error when usage data cannot be parsed', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeResponse('{"access":{}}'))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('Could not parse usage data')
|
||||
})
|
||||
|
||||
it('never logs the cookie in error messages', async () => {
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import type { Session } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { NetworkProxySettings } from '../../shared/network-proxy'
|
||||
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
clearOpenCodeSessionCookies,
|
||||
createOpenCodeRequestSession,
|
||||
OPENCODE_BASE_URL
|
||||
} from './opencode-go-request-session'
|
||||
import { parseSubscriptionFromPageText } from './opencode-go-page-scraper'
|
||||
import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing'
|
||||
|
||||
const OPENCODE_SERVER_URL = 'https://opencode.ai/_server'
|
||||
const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status`
|
||||
const API_TIMEOUT_MS = 15_000
|
||||
|
||||
// Server-function hash for the workspaces endpoint — stable identifier used by
|
||||
// the opencode.ai SST/TanStack router server-fn protocol.
|
||||
const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f'
|
||||
|
||||
// Only these cookie names carry session auth on opencode.ai. Sending unrelated
|
||||
// cookies pollutes the header and can expose sensitive data from other sites.
|
||||
const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth'])
|
||||
// Closed allowlist: only known opencode.ai auth cookies. Console Go usage is
|
||||
// authed by __Host-console_session; /_server workspace discovery still uses auth.
|
||||
const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth', '__Host-console_session'])
|
||||
|
||||
// Why: users may paste just the token value (e.g. "Fe26.2**...") instead of
|
||||
// the full cookie header ("auth=Fe26.2**..."). Auto-wrapping avoids a confusing
|
||||
@@ -29,7 +30,7 @@ export function normalizeCookieInput(raw: string): string {
|
||||
return trimmed
|
||||
}
|
||||
// Already a valid cookie header: has multiple pairs or starts with known name.
|
||||
if (trimmed.includes(';') || /^(?:auth|__Host-auth)=/i.test(trimmed)) {
|
||||
if (trimmed.includes(';') || /^(?:auth|__Host-auth|__Host-console_session)=/i.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
// Only wrap if it looks like an Iron Session seal (starts with Fe26.2**)
|
||||
@@ -73,19 +74,6 @@ function parseWorkspaceIds(text: string): string[] {
|
||||
return ids
|
||||
}
|
||||
|
||||
function makeWindow(
|
||||
usedPercent: number,
|
||||
resetInSec: number,
|
||||
windowMinutes: number
|
||||
): RateLimitWindow {
|
||||
return {
|
||||
usedPercent,
|
||||
windowMinutes,
|
||||
resetsAt: Date.now() + resetInSec * 1000,
|
||||
resetDescription: null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOpenCodeGoRateLimits(
|
||||
cookie: string,
|
||||
workspaceIdOverride?: string,
|
||||
@@ -229,47 +217,43 @@ async function fetchOpenCodeGoRateLimitsWithSession(
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Robust workspace resolution. Try each candidate ID until one returns 200 OK
|
||||
// and valid usage data. Each candidate gets its own timeout so a slow or
|
||||
// hung candidate cannot starve the rest.
|
||||
// Why: /workspace/<id>/go now 302s to console login. Usage is JSON at
|
||||
// /console/api/go/status, scoped by x-org-id and authed by the console session.
|
||||
let lastError = ''
|
||||
for (const candidateId of ids) {
|
||||
try {
|
||||
const usagePageUrl = `${OPENCODE_BASE_URL}/workspace/${candidateId}/go`
|
||||
const pageRes = await openCodeSession.fetch(usagePageUrl, {
|
||||
const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
Accept: 'application/json',
|
||||
Origin: OPENCODE_BASE_URL,
|
||||
Referer: OPENCODE_BASE_URL
|
||||
Referer: `${OPENCODE_BASE_URL}/console/${candidateId}/go`,
|
||||
'x-org-id': candidateId
|
||||
},
|
||||
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
if (!pageRes.ok) {
|
||||
lastError = `Usage page fetch failed (${pageRes.status})`
|
||||
if (!statusRes.ok) {
|
||||
lastError =
|
||||
statusRes.status === 401
|
||||
? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)'
|
||||
: `Usage fetch failed (${statusRes.status})`
|
||||
continue
|
||||
}
|
||||
|
||||
const pageText = await pageRes.text()
|
||||
const parsed = parseSubscriptionFromPageText(pageText)
|
||||
const parsed = parseOpenCodeGoStatusPayload(await statusRes.text())
|
||||
if (parsed) {
|
||||
const monthly =
|
||||
parsed.monthlyUsagePercent !== null && parsed.monthlyResetInSec !== null
|
||||
? makeWindow(parsed.monthlyUsagePercent, parsed.monthlyResetInSec, 43200) // 30d
|
||||
: null
|
||||
|
||||
return {
|
||||
provider: 'opencode-go',
|
||||
session: makeWindow(parsed.rollingUsagePercent, parsed.rollingResetInSec, 300),
|
||||
weekly: makeWindow(parsed.weeklyUsagePercent, parsed.weeklyResetInSec, 10080),
|
||||
monthly,
|
||||
session: parsed.session,
|
||||
weekly: parsed.weekly,
|
||||
monthly: parsed.monthly,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
}
|
||||
lastError = 'Could not parse usage data from page'
|
||||
lastError = 'Could not parse usage data'
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
lastError = message
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
AGENT_PROMPT_BRACKETED_PASTE_END,
|
||||
AGENT_PROMPT_BRACKETED_PASTE_START,
|
||||
buildAgentPromptPasteBytes,
|
||||
getAgentPromptSubmitDelayMs
|
||||
resolveAgentPromptSubmitDelayForAgent
|
||||
} from '../../../shared/agent-prompt-injection'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
@@ -610,9 +610,13 @@ describe('OrcaRuntimeService', () => {
|
||||
launchAgent: agent
|
||||
})
|
||||
|
||||
const submitDelayMs = getAgentPromptSubmitDelayMs(
|
||||
// The agent's own policy, not the byte-only delay: antigravity adds a per-line settle
|
||||
// (#21665), and advancing fake timers by less than the policy waits leaves the submit
|
||||
// pending until the real 30 s timeout.
|
||||
const submitDelayMs = resolveAgentPromptSubmitDelayForAgent(
|
||||
process.platform,
|
||||
Buffer.byteLength(buildAgentPromptPasteBytes('review this change'), 'utf8')
|
||||
'review this change',
|
||||
agent
|
||||
)
|
||||
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
|
||||
if (agent === 'omp') {
|
||||
|
||||
@@ -91,7 +91,7 @@ it('prepares the execution host OMP config and status extension for a guarded la
|
||||
source.mockReturnValue(false)
|
||||
expect(
|
||||
await augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' })
|
||||
).toEqual({})
|
||||
).toEqual({ ORCA_OPENCODE_AGENT: 'opencode' })
|
||||
} finally {
|
||||
runtime.stop()
|
||||
dispatcher.dispose()
|
||||
|
||||
@@ -89,6 +89,7 @@ export class RelayAgentHookRuntime {
|
||||
context.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint)
|
||||
? 'opencode2'
|
||||
: 'opencode'
|
||||
env.ORCA_OPENCODE_AGENT = opencodeAgent
|
||||
if (this.pluginOverlay.hasOpenCodeSource(opencodeAgent)) {
|
||||
const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell)
|
||||
const inheritedRelayOverlay = sourceDir
|
||||
|
||||
@@ -187,11 +187,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mirror the XDG default config root', () => {
|
||||
it('mirrors the XDG default config root when using an overlay', () => {
|
||||
withHome((home) => {
|
||||
// Why: OpenCode APPENDS OPENCODE_CONFIG_DIR to its config-dir list rather than
|
||||
// replacing it, so ~/.config/opencode is read anyway — mirroring it here would
|
||||
// load the user's config and plugins twice.
|
||||
// Why: OPENCODE_CONFIG_DIR replaces the default root, so the overlay must
|
||||
// carry the user's default config and Orca's plugin together.
|
||||
const defaultConfig = join(home, '.config', 'opencode')
|
||||
mkdirSync(defaultConfig, { recursive: true })
|
||||
writeFileSync(join(defaultConfig, 'opencode.json'), '{"model":"default"}')
|
||||
@@ -202,7 +201,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape.
|
||||
const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string
|
||||
|
||||
expect(existsSync(join(dir, 'opencode.json'))).toBe(false)
|
||||
expect(existsSync(join(dir, 'opencode.json'))).toBe(true)
|
||||
expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs'
|
||||
|
||||
import { getRelayOpenCodePluginPath, type PluginOverlayManager } from './plugin-overlay'
|
||||
import { resolveOpenCodeSourceConfigDir } from './plugin-overlay-env'
|
||||
import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory'
|
||||
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
|
||||
import {
|
||||
sanitizeWslHookInstanceKey,
|
||||
@@ -26,11 +27,8 @@ export type InstallPluginsResult = {
|
||||
|
||||
export type InstallPluginsHandler = (params: Record<string, unknown>) => InstallPluginsResult
|
||||
|
||||
// Why NOT to fall back to ~/.config/opencode here: OpenCode APPENDS
|
||||
// OPENCODE_CONFIG_DIR to its config-dir list, it does not replace it — the
|
||||
// XDG default is always read too. Mirroring the default into the overlay would
|
||||
// load the user's config (and plugins) twice. Only an explicitly-set dir is
|
||||
// mirrored, because that one leaves the list when we override the var.
|
||||
// OpenCode replaces its default config root when OPENCODE_CONFIG_DIR is set,
|
||||
// so mirror the default root into the guest overlay as well as explicit paths.
|
||||
export function createInstallPluginsHandler(
|
||||
pluginOverlay: PluginOverlayManager,
|
||||
env: NodeJS.ProcessEnv
|
||||
@@ -73,12 +71,15 @@ export function createInstallPluginsHandler(
|
||||
const incoming = typeof opencode === 'string' ? opencode : null
|
||||
// Explicit-only (see header). Constant in practice for a relay's lifetime, so
|
||||
// keying the cache on it is defensive; the rc scan behind it is memoized.
|
||||
const sourceDir = resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL)
|
||||
const sourceDir =
|
||||
resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL) ??
|
||||
resolveOpenCodeConfigDirectory(env as Record<string, string>, env.HOME)
|
||||
const existingSourceDir = sourceDir && existsSync(sourceDir) ? sourceDir : undefined
|
||||
const cached = materialized
|
||||
if (
|
||||
cached &&
|
||||
(incoming === null || incoming === cached.source) &&
|
||||
sourceDir === cached.sourceDir &&
|
||||
existingSourceDir === cached.sourceDir &&
|
||||
// Why: the dir surviving a failed rebuild proves nothing — the plugin does.
|
||||
existsSync(getRelayOpenCodePluginPath(cached.dir))
|
||||
) {
|
||||
@@ -87,32 +88,35 @@ export function createInstallPluginsHandler(
|
||||
const overlayId =
|
||||
sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode'
|
||||
// Why: null on write failure — caller falls back to the guest's own config (no status), never crossing a Windows overlay into WSL.
|
||||
opencodeDir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) ?? undefined
|
||||
opencodeDir = pluginOverlay.materializeOpenCode(overlayId, existingSourceDir) ?? undefined
|
||||
materialized =
|
||||
opencodeDir && incoming !== null
|
||||
? { source: incoming, sourceDir, dir: opencodeDir }
|
||||
? { source: incoming, sourceDir: existingSourceDir, dir: opencodeDir }
|
||||
: null
|
||||
}
|
||||
}
|
||||
let opencode2Dir: string | undefined
|
||||
if (pluginOverlay.hasOpenCode2Source()) {
|
||||
const incoming = typeof opencode2 === 'string' ? opencode2 : null
|
||||
const sourceDir = resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL)
|
||||
const sourceDir =
|
||||
resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL) ??
|
||||
resolveOpenCodeConfigDirectory(env as Record<string, string>, env.HOME)
|
||||
const existingSourceDir = sourceDir && existsSync(sourceDir) ? sourceDir : undefined
|
||||
const cached = materialized2
|
||||
if (
|
||||
cached &&
|
||||
(incoming === null || incoming === cached.source) &&
|
||||
sourceDir === cached.sourceDir &&
|
||||
existingSourceDir === cached.sourceDir &&
|
||||
existsSync(getRelayOpenCodePluginPath(cached.dir, 'opencode2'))
|
||||
) {
|
||||
opencode2Dir = cached.dir
|
||||
} else {
|
||||
const overlayId =
|
||||
sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode2'
|
||||
opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, sourceDir) ?? undefined
|
||||
opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, existingSourceDir) ?? undefined
|
||||
materialized2 =
|
||||
opencode2Dir && incoming !== null
|
||||
? { source: incoming, sourceDir, dir: opencode2Dir }
|
||||
? { source: incoming, sourceDir: existingSourceDir, dir: opencode2Dir }
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EditorDiffFileSurface } from './EditorDiffFileSurface'
|
||||
import { EditorEditFileSurface } from './EditorEditFileSurface'
|
||||
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
|
||||
import type { FileContent } from './editor-panel-content-types'
|
||||
import { buildPdfScalePreferenceKey } from './pdf-scale-preference-storage'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useEditorConflictNavigation } from './useEditorConflictNavigation'
|
||||
import { useMarkdownDocuments } from './useMarkdownDocuments'
|
||||
@@ -106,6 +107,9 @@ export function EditorContent({
|
||||
viewStateScopeId === activeFile.id
|
||||
? `${activeFile.filePath}:pdf`
|
||||
: `${activeFile.filePath}::${viewStateScopeId}:pdf`
|
||||
// Why: the same absolute path can exist in different worktrees, paired
|
||||
// runtimes, or SSH targets; durable PDF zoom must not cross those owners.
|
||||
const pdfPreferenceKey = buildPdfScalePreferenceKey(activeFile)
|
||||
const monacoLanguage = resolvedLanguage === 'notebook' ? 'json' : resolvedLanguage
|
||||
const reloadOpenCheckRunDetailsTab = useAppStore((state) => state.reloadOpenCheckRunDetailsTab)
|
||||
const markdownDocuments = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
|
||||
@@ -232,6 +236,7 @@ export function EditorContent({
|
||||
editorViewStateKey={editorViewStateKey}
|
||||
diffViewStateKey={diffViewStateKey}
|
||||
pdfViewStateKey={pdfViewStateKey}
|
||||
pdfPreferenceKey={pdfPreferenceKey}
|
||||
fileContent={fileContents[activeFile.id]}
|
||||
diffContent={diffContents[activeFile.id]}
|
||||
editBuffer={editBuffers[activeFile.id]}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function EditorEditFileSurface({
|
||||
editorViewStateKey,
|
||||
diffViewStateKey,
|
||||
pdfViewStateKey,
|
||||
pdfPreferenceKey,
|
||||
fileContent,
|
||||
diffContent,
|
||||
editBuffer,
|
||||
@@ -61,6 +62,7 @@ export function EditorEditFileSurface({
|
||||
editorViewStateKey: string
|
||||
diffViewStateKey: string
|
||||
pdfViewStateKey: string
|
||||
pdfPreferenceKey: string
|
||||
fileContent: FileContent | undefined
|
||||
diffContent: GitDiffResult | undefined
|
||||
editBuffer: string | undefined
|
||||
@@ -113,6 +115,7 @@ export function EditorEditFileSurface({
|
||||
content={fileContent.content}
|
||||
filePath={activeFile.filePath}
|
||||
mimeType={fileContent.mimeType}
|
||||
preferenceKey={pdfPreferenceKey}
|
||||
scrollCacheKey={pdfViewStateKey}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -29,6 +29,9 @@ type ImageViewerProps = {
|
||||
filePath: string
|
||||
mimeType?: string
|
||||
layout?: 'fill' | 'intrinsic'
|
||||
// Why: callers without an owner identity (for example diff and conflict
|
||||
// panes) must not persist a preference under a path-only key.
|
||||
preferenceKey?: string | null
|
||||
// Why: absent means "no PDF scroll memory" — diff and conflict-review callers
|
||||
// mount several viewers on one path, so they deliberately pass nothing.
|
||||
scrollCacheKey?: string | null
|
||||
@@ -39,6 +42,7 @@ export default function ImageViewer({
|
||||
filePath,
|
||||
mimeType = FALLBACK_IMAGE_MIME_TYPE,
|
||||
layout = 'fill',
|
||||
preferenceKey,
|
||||
scrollCacheKey = null
|
||||
}: ImageViewerProps): JSX.Element {
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
@@ -215,7 +219,12 @@ export default function ImageViewer({
|
||||
|
||||
if (isPdf) {
|
||||
return (
|
||||
<PdfViewer content={cleanedContent} filePath={filePath} scrollCacheKey={scrollCacheKey} />
|
||||
<PdfViewer
|
||||
content={cleanedContent}
|
||||
filePath={filePath}
|
||||
preferenceKey={preferenceKey}
|
||||
scrollCacheKey={scrollCacheKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
stepPdfScalePreference,
|
||||
type PdfScalePreference
|
||||
} from './pdf-scale-preference'
|
||||
import { readPdfScalePreference, writePdfScalePreference } from './pdf-scale-preference-storage'
|
||||
import { pdfViewPositionCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import {
|
||||
buildPdfScrollDestination,
|
||||
@@ -44,6 +45,9 @@ const USER_SCROLL_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown
|
||||
type PdfViewerProps = {
|
||||
content: string
|
||||
filePath: string
|
||||
// Why: callers that do not have an owner identity (for example diff and
|
||||
// conflict panes) must not persist a preference under a path-only key.
|
||||
preferenceKey?: string | null
|
||||
// Why: absent means "no scroll memory" — the diff and conflict-review callers
|
||||
// mount several viewers on one path, so a shared key would cross-write.
|
||||
scrollCacheKey?: string | null
|
||||
@@ -52,6 +56,7 @@ type PdfViewerProps = {
|
||||
export default function PdfViewer({
|
||||
content,
|
||||
filePath,
|
||||
preferenceKey = null,
|
||||
scrollCacheKey = null
|
||||
}: PdfViewerProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -65,22 +70,23 @@ export default function PdfViewer({
|
||||
const findControllerRef = useRef<InstanceType<typeof PDFFindController> | null>(null)
|
||||
const pdfViewerRef = useRef<InstanceType<typeof PdfJsViewer> | null>(null)
|
||||
// Why: content reloads rebuild the pdf.js viewer; keep zoom across updates of
|
||||
// the same file, and only reset when the open path changes.
|
||||
// the same file and restore the durable preference after a remount or restart.
|
||||
const scalePreferenceRef = useRef<PdfScalePreference>('page-width')
|
||||
|
||||
const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath])
|
||||
const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content])
|
||||
|
||||
// Why: reset zoom to fit-width when the open path changes. An effect keeps the
|
||||
// reset out of render (refs mutated in render can leak from discarded renders)
|
||||
// and covers same-content/different-path opens the load effect skips.
|
||||
// Why: restore the owner's preference outside render (refs mutated in render
|
||||
// can leak from discarded renders) and cover same-content/different-path opens.
|
||||
useEffect(() => {
|
||||
scalePreferenceRef.current = 'page-width'
|
||||
scalePreferenceRef.current = preferenceKey
|
||||
? (readPdfScalePreference(preferenceKey) ?? 'page-width')
|
||||
: 'page-width'
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
|
||||
applyPdfScalePreference(viewer, scalePreferenceRef.current, SCALE_BOUNDS)
|
||||
}
|
||||
}, [filePath])
|
||||
}, [filePath, preferenceKey])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
@@ -306,15 +312,21 @@ export default function PdfViewer({
|
||||
|
||||
// Why: every zoom entry point (toolbar + keyboard) must record the scale
|
||||
// preference so the next content reload restores it (see scalePreferenceRef).
|
||||
const stepZoom = useCallback((direction: 'in' | 'out') => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (!viewer) {
|
||||
return
|
||||
}
|
||||
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
|
||||
viewer.currentScale = next.scale
|
||||
scalePreferenceRef.current = next.preference
|
||||
}, [])
|
||||
const stepZoom = useCallback(
|
||||
(direction: 'in' | 'out') => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (!viewer) {
|
||||
return
|
||||
}
|
||||
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
|
||||
viewer.currentScale = next.scale
|
||||
scalePreferenceRef.current = next.preference
|
||||
if (preferenceKey) {
|
||||
writePdfScalePreference(preferenceKey, next.preference)
|
||||
}
|
||||
},
|
||||
[preferenceKey]
|
||||
)
|
||||
|
||||
const zoomIn = useCallback(() => stepZoom('in'), [stepZoom])
|
||||
const zoomOut = useCallback(() => stepZoom('out'), [stepZoom])
|
||||
@@ -326,7 +338,10 @@ export default function PdfViewer({
|
||||
}
|
||||
scalePreferenceRef.current = 'page-width'
|
||||
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
|
||||
}, [])
|
||||
if (preferenceKey) {
|
||||
writePdfScalePreference(preferenceKey, 'page-width')
|
||||
}
|
||||
}, [preferenceKey])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildPdfScalePreferenceKey,
|
||||
PDF_SCALE_PREFERENCES_STORAGE_KEY,
|
||||
readPdfScalePreference,
|
||||
writePdfScalePreference
|
||||
} from './pdf-scale-preference-storage'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('PDF scale preference storage', () => {
|
||||
it('keeps identical paths isolated by worktree and remote owner', () => {
|
||||
const localKey = buildPdfScalePreferenceKey({ worktreeId: 'worktree-a', filePath: '/doc.pdf' })
|
||||
const runtimeKey = buildPdfScalePreferenceKey({
|
||||
worktreeId: 'worktree-a',
|
||||
runtimeEnvironmentId: 'runtime-b',
|
||||
filePath: '/doc.pdf'
|
||||
})
|
||||
const sshKey = buildPdfScalePreferenceKey({
|
||||
worktreeId: 'worktree-a',
|
||||
externalSshTargetId: 'ssh-c',
|
||||
filePath: '/doc.pdf'
|
||||
})
|
||||
|
||||
expect(new Set([localKey, runtimeKey, sshKey]).size).toBe(3)
|
||||
})
|
||||
|
||||
it('round-trips a preference by file path', () => {
|
||||
const storage = createMemoryStorage()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
|
||||
writePdfScalePreference('/repo/report.pdf', 1.75)
|
||||
|
||||
expect(readPdfScalePreference('/repo/report.pdf')).toBe(1.75)
|
||||
expect(readPdfScalePreference('/repo/other.pdf')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists fit-to-width resets and keeps files isolated', () => {
|
||||
const storage = createMemoryStorage()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
|
||||
writePdfScalePreference('/repo/report.pdf', 2)
|
||||
writePdfScalePreference('/repo/other.pdf', 'page-width')
|
||||
|
||||
expect(readPdfScalePreference('/repo/report.pdf')).toBe(2)
|
||||
expect(readPdfScalePreference('/repo/other.pdf')).toBe('page-width')
|
||||
})
|
||||
|
||||
it('ignores malformed stored values', () => {
|
||||
const storage = createMemoryStorage()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
storage.setItem(
|
||||
PDF_SCALE_PREFERENCES_STORAGE_KEY,
|
||||
JSON.stringify({ '/repo/report.pdf': { scale: 2 } })
|
||||
)
|
||||
|
||||
expect(readPdfScalePreference('/repo/report.pdf')).toBeNull()
|
||||
})
|
||||
|
||||
it('evicts the oldest entries after reaching the storage bound', () => {
|
||||
const storage = createMemoryStorage()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
writePdfScalePreference(`/repo/report-${index}.pdf`, index)
|
||||
}
|
||||
|
||||
expect(readPdfScalePreference('/repo/report-0.pdf')).toBeNull()
|
||||
expect(readPdfScalePreference('/repo/report-100.pdf')).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores storage write failures', () => {
|
||||
const storage = createMemoryStorage()
|
||||
storage.setItem = () => {
|
||||
throw new Error('storage unavailable')
|
||||
}
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
|
||||
expect(() => writePdfScalePreference('/repo/report.pdf', 1.5)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => {
|
||||
values.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { PdfScalePreference } from './pdf-scale-preference'
|
||||
|
||||
export const PDF_SCALE_PREFERENCES_STORAGE_KEY = 'orca.pdf.scale-preferences.v1'
|
||||
|
||||
const MAX_STORED_PREFERENCES = 100
|
||||
|
||||
export function buildPdfScalePreferenceKey(input: {
|
||||
worktreeId: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
externalSshTargetId?: string | null
|
||||
filePath: string
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
input.worktreeId,
|
||||
input.runtimeEnvironmentId?.trim() || 'local',
|
||||
input.externalSshTargetId?.trim() || null,
|
||||
input.filePath
|
||||
])
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isPdfScalePreference(value: unknown): value is PdfScalePreference {
|
||||
return value === 'page-width' || (typeof value === 'number' && Number.isFinite(value))
|
||||
}
|
||||
|
||||
function readStoredPreferences(storage: Storage): Record<string, unknown> {
|
||||
try {
|
||||
const raw = storage.getItem(PDF_SCALE_PREFERENCES_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return isRecord(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
try {
|
||||
return globalThis.localStorage === undefined ? null : globalThis.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the last zoom choice for a PDF, if one was persisted. */
|
||||
export function readPdfScalePreference(preferenceKey: string): PdfScalePreference | null {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return null
|
||||
}
|
||||
const preference = readStoredPreferences(storage)[preferenceKey]
|
||||
return isPdfScalePreference(preference) ? preference : null
|
||||
}
|
||||
|
||||
/** Persist a PDF zoom choice across viewer remounts and app restarts. */
|
||||
export function writePdfScalePreference(
|
||||
preferenceKey: string,
|
||||
preference: PdfScalePreference
|
||||
): void {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
|
||||
const stored = readStoredPreferences(storage)
|
||||
// Reinsert to keep recently used files at the end of the bounded map.
|
||||
delete stored[preferenceKey]
|
||||
stored[preferenceKey] = preference
|
||||
const keys = Object.keys(stored)
|
||||
while (keys.length > MAX_STORED_PREFERENCES) {
|
||||
const oldestKey = keys.shift()
|
||||
if (oldestKey !== undefined) {
|
||||
delete stored[oldestKey]
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(PDF_SCALE_PREFERENCES_STORAGE_KEY, JSON.stringify(stored))
|
||||
} catch {
|
||||
// The viewer remains usable when browser storage is unavailable or full.
|
||||
}
|
||||
}
|
||||
@@ -167,4 +167,13 @@ describe('AccountsPane', () => {
|
||||
markup.slice(markup.lastIndexOf('<button', addAccountIndex), addAccountIndex)
|
||||
).not.toContain('disabled=""')
|
||||
})
|
||||
|
||||
it('tells users to paste the OpenCode console session cookie, not auth alone', () => {
|
||||
const markup = renderPane(getDefaultSettings('/tmp'))
|
||||
|
||||
expect(markup).toContain('__Host-console_session')
|
||||
expect(markup).toContain('auth=…; __Host-console_session=…')
|
||||
expect(markup).toContain('auth cookie still covers workspace discovery')
|
||||
expect(markup).not.toContain('Fe26.2**… token or auth=Fe26.2**… header')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,10 +101,10 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
|
||||
'OpenCode Go Session Cookie'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AccountsPane.b2b1aa936d',
|
||||
'Paste your opencode.ai session cookie for rate limit fetching.'
|
||||
'auto.components.settings.AccountsPane.0335bd31d5',
|
||||
'Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.'
|
||||
)}
|
||||
keywords={['opencode', 'cookie', 'session', 'rate limit', 'status bar']}
|
||||
keywords={['opencode', 'cookie', 'session', 'console', 'rate limit', 'status bar']}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label>
|
||||
@@ -120,8 +120,8 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
|
||||
onEdit={() => recordOpenCodeSettingEdit('cookie')}
|
||||
commit={(opencodeSessionCookie) => updateSettings({ opencodeSessionCookie })}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.AccountsPane.a7e38affcd',
|
||||
'Fe26.2**… token or auth=Fe26.2**… header'
|
||||
'auto.components.settings.AccountsPane.37b4b4a3f7',
|
||||
'auth=…; __Host-console_session=…'
|
||||
)}
|
||||
spellCheck={false}
|
||||
className="flex-1 text-xs"
|
||||
@@ -142,22 +142,18 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.0023cc336e',
|
||||
'Paste either the raw token value (e.g.'
|
||||
'auto.components.settings.AccountsPane.62ab430f94',
|
||||
"Paste the full Cookie header from your browser's DevTools → Network → any opencode.ai request, including __Host-console_session (e.g."
|
||||
)}{' '}
|
||||
<code className="text-xs">
|
||||
{translate('auto.components.settings.AccountsPane.922b51e02d', 'Fe26.2**…')}
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.37b4b4a3f7',
|
||||
'auth=…; __Host-console_session=…'
|
||||
)}
|
||||
</code>
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.338820326a',
|
||||
') or the full cookie header (e.g.'
|
||||
)}{' '}
|
||||
<code className="text-xs">
|
||||
{translate('auto.components.settings.AccountsPane.8951c5309f', 'auth=Fe26.2**…')}
|
||||
</code>
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.7ce0e1907c',
|
||||
"). Find it in your browser's DevTools → Network → any opencode.ai request → Cookie header. OpenCode Go auth is web-based and shared across Windows and WSL terminals."
|
||||
'auto.components.settings.AccountsPane.d5267cce63',
|
||||
'). The auth cookie still covers workspace discovery; auth alone is not enough for usage. OpenCode Go auth is web-based and shared across Windows and WSL terminals.'
|
||||
)}
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
|
||||
@@ -15,7 +15,11 @@ vi.mock('./settings-search-keywords', () => ({
|
||||
translateSearchKeyword: (_key: string, fallback: string) => [fallback]
|
||||
}))
|
||||
|
||||
import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search'
|
||||
import {
|
||||
getAccountsMiniMaxSearchEntries,
|
||||
getAccountsOpencodeSearchEntries,
|
||||
getAccountsPaneSearchEntries
|
||||
} from './accounts-search'
|
||||
|
||||
describe('getAccountsMiniMaxSearchEntries', () => {
|
||||
it('returns a single entry that targets the MiniMax session cookie flow', () => {
|
||||
@@ -42,3 +46,19 @@ describe('getAccountsMiniMaxSearchEntries', () => {
|
||||
expect(titles).toContain('MiniMax Usage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAccountsOpencodeSearchEntries', () => {
|
||||
it('tells search to paste the full Cookie header including the console session', () => {
|
||||
const cookieEntry = getAccountsOpencodeSearchEntries().find(
|
||||
(entry) => entry.title === 'OpenCode Go Session Cookie'
|
||||
)
|
||||
|
||||
expect(cookieEntry).toBeDefined()
|
||||
expect(cookieEntry?.description).toContain('__Host-console_session')
|
||||
expect(cookieEntry?.description).toContain('Cookie header')
|
||||
expect(cookieEntry?.description).not.toMatch(/Fe26\.2\*\*/)
|
||||
expect(cookieEntry?.keywords).toEqual(
|
||||
expect.arrayContaining(['opencode', 'cookie', 'session', 'console', 'rate limit'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,13 +134,14 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [
|
||||
'OpenCode Go Session Cookie'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.accounts.search.d1d2ae383c',
|
||||
'Paste your opencode.ai session cookie for rate limit fetching.'
|
||||
'auto.components.settings.accounts.search.25591bf95b',
|
||||
'Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.accounts.search.8dcbef1856', 'opencode'),
|
||||
...translateSearchKeyword('auto.components.settings.accounts.search.61f7d1fcbe', 'cookie'),
|
||||
...translateSearchKeyword('auto.components.settings.accounts.search.9c4e40cf6b', 'session'),
|
||||
...translateSearchKeyword('auto.components.settings.accounts.search.37020a02c2', 'console'),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.accounts.search.e949b08ffb',
|
||||
'rate limit'
|
||||
|
||||
+12
@@ -1074,17 +1074,24 @@
|
||||
},
|
||||
"settings": {
|
||||
"AccountsPane": {
|
||||
"0023cc336e": "Paste either the raw token value (e.g.",
|
||||
"15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.",
|
||||
"1fd1b1b6b4": "Cookie not set",
|
||||
"338820326a": ") or the full cookie header (e.g.",
|
||||
"3455cf43fa": "Claude login.",
|
||||
"350b2a1aa7": "Use your current",
|
||||
"4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes.",
|
||||
"566d9a99ab": "_token=…; minimax_group_id_v2=…",
|
||||
"5e08b0fe57": "Stored locally and sent only to platform.minimax.io for usage refreshes.",
|
||||
"79418c782a": "Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).",
|
||||
"7ce0e1907c": "). Find it in your browser's DevTools → Network → any opencode.ai request → Cookie header. OpenCode Go auth is web-based and shared across Windows and WSL terminals.",
|
||||
"8951c5309f": "auth=Fe26.2**…",
|
||||
"9107406589": "Could not load Claude accounts.",
|
||||
"922b51e02d": "Fe26.2**…",
|
||||
"a7e38affcd": "Fe26.2**… token or auth=Fe26.2**… header",
|
||||
"b10cb4f696": "adding",
|
||||
"b11078a9c2": "wsl",
|
||||
"b2b1aa936d": "Paste your opencode.ai session cookie for rate limit fetching.",
|
||||
"b43e761fe5": "MiniMax cookie update failed.",
|
||||
"b8c2905c2b": "Could not load Codex accounts.",
|
||||
"f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in."
|
||||
@@ -1557,6 +1564,11 @@
|
||||
"7c3bb36706": "remove",
|
||||
"e2b0ee267f": "stale"
|
||||
},
|
||||
"accounts": {
|
||||
"search": {
|
||||
"d1d2ae383c": "Paste your opencode.ai session cookie for rate limit fetching."
|
||||
}
|
||||
},
|
||||
"agent-awake-copy": {
|
||||
"95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.",
|
||||
"a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.",
|
||||
|
||||
@@ -6452,7 +6452,11 @@
|
||||
"922b51e02d": "Fe26.2**…",
|
||||
"0023cc336e": "Paste either the raw token value (e.g.",
|
||||
"a7e38affcd": "Fe26.2**… token or auth=Fe26.2**… header",
|
||||
"d5267cce63": "). The auth cookie still covers workspace discovery; auth alone is not enough for usage. OpenCode Go auth is web-based and shared across Windows and WSL terminals.",
|
||||
"62ab430f94": "Paste the full Cookie header from your browser's DevTools → Network → any opencode.ai request, including __Host-console_session (e.g.",
|
||||
"37b4b4a3f7": "auth=…; __Host-console_session=…",
|
||||
"67e3c33670": "OpenCode Go session cookie",
|
||||
"0335bd31d5": "Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.",
|
||||
"b2b1aa936d": "Paste your opencode.ai session cookie for rate limit fetching.",
|
||||
"36223200ac": "OpenCode Go Session Cookie",
|
||||
"ea631977b5": "Configure OpenCode Go provider settings.",
|
||||
@@ -8927,6 +8931,8 @@
|
||||
"4ee2029e9c": "OpenCode Go Workspace ID",
|
||||
"9c4e40cf6b": "session",
|
||||
"61f7d1fcbe": "cookie",
|
||||
"37020a02c2": "console",
|
||||
"25591bf95b": "Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.",
|
||||
"d1d2ae383c": "Paste your opencode.ai session cookie for rate limit fetching.",
|
||||
"6ed1401020": "OpenCode Go Session Cookie",
|
||||
"b7c2cee442": "experimental",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeHookPayload } from './agent-hook-listener'
|
||||
import { createHookListenerState } from './agent-hook-listener/listener-state'
|
||||
import { bindOpenCodeSession } from './agent-hook-listener/opencode-session-registry'
|
||||
import { makePaneKey } from './stable-pane-id'
|
||||
|
||||
import type { HookListenerState } from './agent-hook-listener/listener-state'
|
||||
|
||||
const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const PANE_A = makePaneKey('tab-a', LEAF_A)
|
||||
const PANE_B = makePaneKey('tab-b', LEAF_B)
|
||||
|
||||
function opencodeBusy(
|
||||
state: HookListenerState,
|
||||
paneKey: string,
|
||||
sessionId: string,
|
||||
launchToken = ''
|
||||
): ReturnType<typeof normalizeHookPayload> {
|
||||
return normalizeHookPayload(
|
||||
state,
|
||||
'opencode',
|
||||
{ paneKey, launchToken, payload: { hook_event_name: 'SessionBusy', sessionID: sessionId } },
|
||||
'production'
|
||||
)
|
||||
}
|
||||
|
||||
describe('opencode shared-server reattribution (#21359)', () => {
|
||||
it('reattributes a bound session to its real pane', () => {
|
||||
const state = createHookListenerState()
|
||||
bindOpenCodeSession(state, 'ses_1', {
|
||||
paneKey: PANE_B,
|
||||
boundAt: 1,
|
||||
basis: 'creation-correlation'
|
||||
})
|
||||
const result = opencodeBusy(state, PANE_A, 'ses_1')
|
||||
expect(result?.paneKey).toBe(PANE_B)
|
||||
expect(result?.tabId).toBe('tab-b')
|
||||
expect(result?.payload.state).toBe('working')
|
||||
})
|
||||
|
||||
it('keeps the stamped pane for unbound sessions', () => {
|
||||
const state = createHookListenerState()
|
||||
const result = opencodeBusy(state, PANE_A, 'ses_unknown')
|
||||
expect(result?.paneKey).toBe(PANE_A)
|
||||
})
|
||||
|
||||
it('substitutes the bound pane live token so its fence passes', () => {
|
||||
const state = createHookListenerState()
|
||||
// A tokened post teaches the listener pane B's live token.
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{ paneKey: PANE_B, launchToken: 'token-b-live', payload: { hook_event_name: 'Stop' } },
|
||||
'production'
|
||||
)
|
||||
bindOpenCodeSession(state, 'ses_1', {
|
||||
paneKey: PANE_B,
|
||||
boundAt: 1,
|
||||
basis: 'argv'
|
||||
})
|
||||
const result = opencodeBusy(state, PANE_A, 'ses_1')
|
||||
expect(result?.paneKey).toBe(PANE_B)
|
||||
expect(result?.launchToken).toBe('token-b-live')
|
||||
})
|
||||
|
||||
it('leaves other sources untouched', () => {
|
||||
const state = createHookListenerState()
|
||||
bindOpenCodeSession(state, 'ses_1', {
|
||||
paneKey: PANE_B,
|
||||
boundAt: 1,
|
||||
basis: 'argv'
|
||||
})
|
||||
const result = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{ paneKey: PANE_A, payload: { hook_event_name: 'Stop', session_id: 'ses_1' } },
|
||||
'production'
|
||||
)
|
||||
expect(result?.paneKey).toBe(PANE_A)
|
||||
})
|
||||
|
||||
it('never lets a stale same-pane stamp overwrite the live token', () => {
|
||||
const state = createHookListenerState()
|
||||
// A tokened post teaches the listener pane B's live token.
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{ paneKey: PANE_B, launchToken: 'token-b-live', payload: { hook_event_name: 'Stop' } },
|
||||
'production'
|
||||
)
|
||||
bindOpenCodeSession(state, 'ses_1', {
|
||||
paneKey: PANE_B,
|
||||
boundAt: 1,
|
||||
basis: 'argv'
|
||||
})
|
||||
// The shared server's frozen stamp carries a stale token for the same pane.
|
||||
const result = opencodeBusy(state, PANE_B, 'ses_1', 'token-b-stale')
|
||||
expect(result?.paneKey).toBe(PANE_B)
|
||||
expect(result?.launchToken).toBe('token-b-live')
|
||||
// And the stale stamp must not have poisoned the cache: a later lookup
|
||||
// still returns the live token.
|
||||
const again = opencodeBusy(state, PANE_B, 'ses_1', 'token-b-stale')
|
||||
expect(again?.launchToken).toBe('token-b-live')
|
||||
})
|
||||
|
||||
it('drops the stamped worktree when the binding has none', () => {
|
||||
const state = createHookListenerState()
|
||||
bindOpenCodeSession(state, 'ses_1', {
|
||||
paneKey: PANE_B,
|
||||
boundAt: 1,
|
||||
basis: 'argv'
|
||||
})
|
||||
const result = normalizeHookPayload(
|
||||
state,
|
||||
'opencode',
|
||||
{
|
||||
paneKey: PANE_A,
|
||||
worktreeId: 'repo::/stamped-worktree',
|
||||
payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_1' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(result?.paneKey).toBe(PANE_B)
|
||||
expect(result?.worktreeId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user