Files
orca/src/shared/native-chat-visual-shell.test.ts
T
Brennan Benson 7b91405547 Let in-page links switch sections inside a chat visual (#26729)
* Let in-page links switch sections inside a chat visual

* Make in-page links work in chat visuals on mobile and in WebKit

The first fix (an about:srcdoc base in the visual) only reached desktop
Chromium. On mobile, the page hosting the visual carried a policy with
base-uri 'none', and a srcdoc visual inherits its host's policy before
its own, so the base was refused and an in-page link still took the
visual off its page and showed it as failed. The mobile web shell sends
the same rule as a header. Separately, WebKit (iOS, and the web client
in Safari) fires the frame's load event for an in-page link inside a
sandboxed frame, and both hosts treated any second load as the visual
navigating away and retired it.

- The mobile host page and the mobile web shell header now allow only an
  about: base, so the shell's own base applies; the visual's own policy
  still refuses any base it declares.
- Instead of retiring a visual on its second load, the host now asks the
  visual's shell after every load. A later load must be answered by the
  same run of the shell within the plugin panel watchdog's 5 s deadline;
  no answer, or an answer from a new run (a reload), still retires it.
- Tests: unit tests for the policy, the answer reader and the frame's
  keep/retire rules, and a Chromium + WebKit test of both mobile surfaces
  that runs in the mobile web app CI job.

* Keep any answered later load, and say plainly what the check is for

The previous commit tagged each run of a visual's wrapper with a random
id so a reload would still retire it. The visual shares a window with
its wrapper, so it could read or set that id itself, and a reload only
reruns the same Orca-written document, which is not an escape. Drop it:
after any load past the first, the host asks once, and any answer on
this visual's channel within the wait keeps it; silence retires it.

- Comments now say the check is a tripwire for a frame gone blank or to
  another page; the frame guard (desktop), the host page's policy plus
  the app's load filter (mobile) and the sandbox are the boundaries.
- Visuals get their own 5 s wait instead of borrowing the plugin panel
  watchdog's, because a 1 s wait retired a busy visual in WebKit.
- Tests: a reload is now kept; going blank, or dropping the base and
  following a link, still retires. The CI routing for the shared wrapper
  is now pinned in the path-classification test.

* Load the visual links check's mobile modules only where they can build

The unit shards run every config/scripts test with only the root
dependencies installed. This check imported a mobile/ module at load,
and transforming it reads mobile/tsconfig.json, which extends a package
those shards do not have. The transform error could not be sent back to
the test worker under Bun, so the whole shard stopped with
"DataCloneError: The object can not be cloned".

Import the visual shell and the mobile host page inside the dependency
gate, as the session-dictation check already does, so the shards skip
the file without loading them. The mobile web app job, which installs
mobile dependencies, still runs it in full.
2026-10-09 10:07:59 -07:00

216 lines
7.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
NATIVE_CHAT_VISUAL_CSP,
NATIVE_CHAT_VISUAL_HOST_CSP,
NATIVE_CHAT_VISUAL_MAX_HEIGHT,
NATIVE_CHAT_VISUAL_MIN_HEIGHT,
NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
NATIVE_CHAT_VISUAL_PING_TYPE,
NATIVE_CHAT_VISUAL_PONG_TYPE,
NATIVE_CHAT_VISUAL_SIZE_TYPE,
NATIVE_CHAT_VISUAL_THEME_TYPE,
buildNativeChatVisualDocument,
clampNativeChatVisualHeight,
nativeChatVisualPingMessage,
nativeChatVisualThemeCss,
nativeChatVisualThemeMessage,
readNativeChatVisualFrameMessage,
readNativeChatVisualPong
} from './native-chat-visual-shell'
const theme = {
colorScheme: 'dark' as const,
tokens: { '--background': 'oklch(0.2 0 0)', '--chart-1': '#123456' }
}
function directives(policy = NATIVE_CHAT_VISUAL_CSP): Map<string, string> {
return new Map(
policy.split(';').map((part) => {
const [name, ...values] = part.trim().split(/\s+/)
return [name, values.join(' ')]
})
)
}
describe('NATIVE_CHAT_VISUAL_CSP', () => {
it('denies by default and closes every channel a chart does not need', () => {
const policy = directives()
expect(policy.get('default-src')).toBe("'none'")
for (const name of [
'connect-src',
'frame-src',
'child-src',
'worker-src',
'object-src',
'media-src',
'manifest-src',
'form-action',
'base-uri'
]) {
expect(policy.get(name)).toBe("'none'")
}
})
it('allows assets only inline, from data/blob, or from the pinned CDNs, and never eval', () => {
const policy = directives()
for (const name of ['script-src', 'style-src']) {
expect(policy.get(name)).toContain("'unsafe-inline'")
expect(policy.get(name)).not.toContain("'unsafe-eval'")
expect(policy.get(name)).not.toContain('*')
}
expect(policy.get('img-src')).toBe(
'data: blob: https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com https://esm.sh https://fonts.googleapis.com https://fonts.gstatic.com'
)
})
})
describe('NATIVE_CHAT_VISUAL_HOST_CSP', () => {
it('is the visual policy except that it admits only an about: base, for the shell', () => {
const host = directives(NATIVE_CHAT_VISUAL_HOST_CSP)
const visual = directives()
expect(host.get('base-uri')).toBe('about:')
expect(visual.get('base-uri')).toBe("'none'")
host.delete('base-uri')
visual.delete('base-uri')
expect(host).toEqual(visual)
})
})
describe('buildNativeChatVisualDocument', () => {
const html = '<!doctype html><html><head><title>x</title></head><body><p>hi</p></body></html>'
const document = buildNativeChatVisualDocument({ html, channel: 'abc123', theme })
it('puts the CSP before anything the visual wrote', () => {
const cspAt = document.indexOf('http-equiv="Content-Security-Policy"')
expect(cspAt).toBeGreaterThan(0)
expect(cspAt).toBeLessThan(document.indexOf('<title>x</title>'))
expect(cspAt).toBeLessThan(document.indexOf('<script>'))
expect(document.endsWith(html)).toBe(true)
})
it('paints the theme before the visual and carries the channel in the bootstrap', () => {
expect(document).toContain('<html class="dark">')
expect(document).toContain('--background:oklch(0.2 0 0)')
expect(document).toContain('"channel":"abc123"')
})
it('resolves in-page links against the visual itself, ahead of the policy that bars any other base', () => {
const baseAt = document.indexOf('<base href="about:srcdoc">')
expect(baseAt).toBeGreaterThan(0)
expect(baseAt).toBeLessThan(document.indexOf('http-equiv="Content-Security-Policy"'))
// A srcdoc page otherwise resolves '#x' against Orca's own URL and navigates away.
expect(new URL('#overview', 'about:srcdoc').href).toBe('about:srcdoc#overview')
expect(() => new URL('page-2.html', 'about:srcdoc')).toThrow()
})
it('checks the theme message comes from the host window', () => {
expect(document).toContain('event.source !== host')
})
it('answers a ping from the host with the ping id', () => {
expect(document).toContain(`"ping":"${NATIVE_CHAT_VISUAL_PING_TYPE}"`)
expect(document).toContain('send({ type: C.pong, id: data.id })')
})
})
describe('nativeChatVisualThemeCss', () => {
it('drops structural characters and invalid names so a value cannot escape the rule', () => {
const css = nativeChatVisualThemeCss({
colorScheme: 'light',
tokens: {
'--background': 'red;}</style><script>alert(1)</script>',
'not-a-token': 'blue',
'--Upper': 'green'
}
})
expect(css).not.toContain('</style>')
expect(css).not.toContain('}<')
expect(css).not.toContain('not-a-token')
expect(css).not.toContain('--Upper')
expect(css.startsWith(':root{color-scheme:light')).toBe(true)
})
it('builds the live theme message on the same channel', () => {
expect(nativeChatVisualThemeMessage(theme, 'abc')).toMatchObject({
type: NATIVE_CHAT_VISUAL_THEME_TYPE,
channel: 'abc',
colorScheme: 'dark'
})
})
})
describe('readNativeChatVisualFrameMessage', () => {
it('accepts a finite size and an http(s) link on its own channel', () => {
expect(
readNativeChatVisualFrameMessage(
{ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: 321.5 },
'c'
)
).toEqual({ kind: 'size', height: 321.5 })
expect(
readNativeChatVisualFrameMessage(
{ type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'https://example.com/a b' },
'c'
)
).toEqual({ kind: 'open-link', url: 'https://example.com/a%20b' })
})
it.each([
['another channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'x', height: 10 }],
['no channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, height: 10 }],
['NaN height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: Number.NaN }],
['infinite height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: Infinity }],
['negative height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: -5 }],
['string height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: '50' }],
[
'javascript link',
{ type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'javascript:alert(1)' }
],
['file link', { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'file:///etc' }],
[
'overlong link',
{
type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
channel: 'c',
url: `https://example.com/${'a'.repeat(3000)}`
}
],
['unknown type', { type: 'orca-panel-action', channel: 'c' }],
['a string', 'orca-visual-size'],
['null', null]
])('refuses %s', (_name, data) => {
expect(readNativeChatVisualFrameMessage(data, 'c')).toBeNull()
})
})
describe('readNativeChatVisualPong', () => {
it('reads the ping id on its own channel', () => {
const pong = { type: NATIVE_CHAT_VISUAL_PONG_TYPE, channel: 'c', id: 2 }
expect(readNativeChatVisualPong(pong, 'c')).toBe(2)
expect(nativeChatVisualPingMessage(2, 'c')).toEqual({
type: NATIVE_CHAT_VISUAL_PING_TYPE,
channel: 'c',
id: 2
})
})
it.each([
['another channel', { type: NATIVE_CHAT_VISUAL_PONG_TYPE, channel: 'x', id: 2 }],
['a ping', { type: NATIVE_CHAT_VISUAL_PING_TYPE, channel: 'c', id: 2 }],
['no id', { type: NATIVE_CHAT_VISUAL_PONG_TYPE, channel: 'c' }],
['a fractional id', { type: NATIVE_CHAT_VISUAL_PONG_TYPE, channel: 'c', id: 1.5 }],
['a string id', { type: NATIVE_CHAT_VISUAL_PONG_TYPE, channel: 'c', id: '2' }],
['null', null]
])('refuses %s', (_name, data) => {
expect(readNativeChatVisualPong(data, 'c')).toBeNull()
})
})
describe('clampNativeChatVisualHeight', () => {
it('keeps heights in range', () => {
expect(clampNativeChatVisualHeight(1)).toBe(NATIVE_CHAT_VISUAL_MIN_HEIGHT)
expect(clampNativeChatVisualHeight(99_999)).toBe(NATIVE_CHAT_VISUAL_MAX_HEIGHT)
expect(clampNativeChatVisualHeight(300.4)).toBe(300)
})
})