Files
orca/tests/e2e/browser-tab.spec.ts
T
OrcaWinandOrcaWin fdcf887bf0 chore(lint): fix suppressed lint violations instead of ignoring them (#33294)
* chore(lint): remove disable directives that suppress nothing

Each was reported unused by the oxlint config that owns its rule (root,
casting, react-doctor, anti-slop, native-plugins), including 17 file-level
react-doctor blanket disables whose rule no longer fires. An unused
react-hooks disable was masking a react/purity finding in
useAgentBucketCounts (compiler rules skip functions with hook
suppressions); suppress that one explicitly where the code explains it.

* test(tab-bar): use typed tab/file fixtures instead of @ts-expect-error partials

* chore(lint): fix misc suppressed lint violations instead of disabling

- consistent-type-imports: the "vi.importActual requires inline import()"
  rationale was wrong; use typed vi.mock(import(...), importOriginal) or a
  type-only namespace import.
- no-control-regex: source-control commit/push failure summaries, shell PATH
  hydration, and ipynb ANSI parsing reuse the shared ansi-escape-sequences
  stripper and control-character pattern instead of private copies.
- no-explicit-any / no-useless-spread / no-this-alias: typed `this` in
  bridge test mocks, drop blanket file-level disables, drop a this-alias.

* refactor(main): replace avoidable type assertions with checked narrowing

17 production cast suppressions in main/wsl-guest removed: untrusted IPC
and RPC payloads (Jira issue update, WSL guest plugin install, handshake
type) now narrow through isRecord/type guards and build typed results;
redundant casts dropped; hand-rolled object checks and errno casts use the
existing isRecord / hasErrorCode helpers.

* refactor(renderer,mobile): replace avoidable type assertions with checked types

13 production cast suppressions removed: RPC failures read through the
`ok` discriminant instead of casting to RpcFailure; react-native-web host
refs narrowed with instanceof HTMLElement; redundant reply/diff casts
become checked annotations; the TUI agent param uses z.custom(isTuiAgent)
like its sibling schemas; the agent-status slice cast that hid missing
actions is gone so the creator's return type checks the whole slice.

* chore(lint): drop dead tiptap wrapper; scope anti-slop mocking rule to test support

- safe-react-node-view-renderer patched handleSelectionUpdate, which
  @tiptap/react 3.31.3 no longer has, so it always returned early. Use the
  stock ReactNodeViewRenderer.
- The anti-slop test override only matched *.test.{ts,tsx}, forcing
  file-level no-module-mocking disables in test harnesses and fixtures.
  Match *-test-harness, *-test-fixture(s), .test-fixture and *.test.mjs,
  then drop those disables and the now-redundant per-file exemptions.

* test: replace repeated cast suppressions with typed fixtures (main/shared/e2e)

Net -119 cast directives. Typed fixtures replace recurring casts:
unusedSshConnection/unusedOrcadHostTransport placeholders, a typed
readPersistedState harness read (exposed a test treating optional
httpProxyUrl as always set), real Response objects in fetch spies, an
updater main-window fixture, a CDP webContents adapter, one shared
releaseModuleExport for cross-version tests, and requireOverlayDir in
place of `as string` on possibly-undefined overlay paths.

* fix(runtime): validate loaded device rows and host scope instead of casting

Two cast suppressions claimed checks that did not happen:
- device-registry-file-read: "normalizeLoadedDeviceRow re-validates every
  field" was false; deviceId, token, name and timestamps were never
  checked. Rows without an identity are now dropped and the file is set
  aside through the existing corrupt-file path (registry reads as
  doubtful, so no revocation verdicts); a non-string token is stripped
  so the row stays listed but cannot authenticate.
- client-ui: "only optionality differs" was false; any string reached
  persisted workspaceHostScope. Narrow it with isWorkspaceHostScope and
  ignore unknown scopes (wire schema unchanged for mixed versions). The
  remaining cast now states what it actually covers.

* test(runtime,ipc): drop redundant cast suppressions; typed runtime store fixture

-98 cast directives across runtime/ipc tests: 61 casts that typecheck
without the assertion; a typed vi.fn for scoped invalidation; a
partialRuntimeStore fixture in place of `store as never` for
OrcaRuntimeService; typed worker terminal/authority builders in the
incarnation-fallback suite (the old stub lacked runtimeId, ptyId,
worktreeId and launchTokenHash) plus removal of stacked duplicate
directives there.

* chore(lint): typed vi.mock imports and explicit listener snapshot in shared-bucket tests

Remaining files from the misc-rule pass that overlapped other buckets:
codex-accounts mocks use the typed vi.mock(import(...)) form instead of
a consistent-type-imports disable; the daemon late-exit fixture copies
its listener set with Array.from instead of a no-useless-spread disable.

* refactor(github,mobile): narrow routing settings type; drop remaining avoidable casts

- work-item-routing cast a one-field object to AppState['settings'] under
  a claim that readers only consume activeRuntimeEnvironmentId. Return the
  existing RuntimeFocusSettings pick instead and narrow the cache-key
  chain's parameters, so the compiler enforces that claim.
- mobile file preview reads refusals through the RpcResponse `ok`
  discriminant rather than a cast.
- terminal document host-notify accepts `unknown` errors, removing the
  message-bridge cast.
- native chat readability drops a redundant repo-list cast.

* test(renderer): replace repeated cast suppressions with typed fixtures

-140 cast directives (and ~150 unsuppressed casts of the same shapes):
- mockPanePtyArgs owns the connectPanePty fixture casts, so a wrong
  argument order or fixture is now a type error; drop binding casts for
  members PanePtyBinding already declares.
- Seed AppState tab rows with the typed makeTerminalTab; this exposed a
  split direction 'row' that is not a TerminalPaneSplitDirection.
- Partial GlobalSettings use getDefaultSettings overrides; Jira-only
  stores use createTestStore; runtime environment rows/statuses come from
  a typed paired-runtime-environment fixture.

* chore(lint): drop no-module-mocking disable now covered by the test-fixture glob

* test(e2e): fix all 221 grandfathered e2e type errors; baseline to zero

The e2e typecheck baseline grandfathered 221 errors in 119 files. About a
third were tests drifted from the current API, several of which would
fail or mislead at runtime:
- terminal-shortcuts called a nonexistent xterm buffer method;
- the daemon-lifecycle fake lacked terminateOwnedTree, hidden by importing
  its type from a module that does not export it;
- arguments in the wrong position (listProcesses(hostId, opts), a dropped
  pty.write inputKind, an extra toWebTerminalSurfaceTabId argument);
- removed enum values ('drawer', 'idle', 'editor',
  'session-insecure-origin') and a launch_source the telemetry schema
  rejects;
- missing required fields (turnScope, childWorkOpen, providerIdentity,
  reviewLookupOutcome, runtimeServiceAccount behind an `as RelayConfig`);
- dead reads (tab.customTitle, bucket.targets, changedPixels).
Also lifts variables that narrowed to `never` after poll-callback
assignment, and adds a typed xterm render-dimensions accessor. Includes
the cross-version suites' shared releaseModuleExport adoption.

* chore(typecheck): gate the now-clean e2e typecheck in local lint

The e2e baseline is empty, so the ratchet now rejects any tests/ type
error. CI already runs it via run-ratchets; add it to pnpm lint's
explicit ratchet list and replace the stale 'not yet clean' note.

* test(mobile): typecheck every mobile test; baseline 105 files -> 0

mobile/tests-typecheck-baseline.txt grandfathered 105 test files (531
errors) whose types CI never checked. Contract changes had landed without
these tests following, and type pins in them silently checked nothing.

- Root cause: salvagedOptional (src/shared/zod-salvage.ts) cast away
  zod's optional marker, so every reply type built on it claimed
  salvaged fields were always present. Return a real ZodOptional;
  runtime is unchanged (the outer optional short-circuits undefined).
- Production now takes the port it uses: RpcOperationSender instead of
  RpcClient for send-only callers, a narrow fetch signature, and only the
  request fields push-receive reads.
- findAllByHostType/findByHostType replace `'Text' as never` lookups;
  stubRpcClient gives tests a typed RpcClient.
- Real test bugs: settings-host-client-lifecycle "relay" hosts were
  direct hosts (relayHostId no longer exists); two toEqualTypeOf pins
  could never hold; a recovery hook ran with an undefined scope key;
  a create-PR test cast to a never-imported type; "no URL" assertions
  passed on null; plus stale required props, args and enum values.

* test(e2e): parse workerStart result instead of casting it

* test(runtime): typed partial runtime stores with production-shaped fixture data

Route every partial store passed to OrcaRuntimeService through
partialRuntimeStore with typed row builders, removing 60 cast directives
that hid fixture data production never produces:
- paired-client-navigation-test-harness keyed setWorktreeMeta writes by
  `[object Object]` (it took an id where the store takes a target), so
  reads never saw them;
- invalid worktree lineage origin/capture and incomplete workspace lineage;
- getWorkspaceSession returning null where the store returns the default
  session; getGitHubCache returning undefined;
- orcad migration states missing migrationId/manifest/receipt, with a
  toEqual pinned to that incomplete shape;
- partial repos, metadata, folders, project groups and notification rows.

* refactor(renderer): resolve suppressed React hook lint instead of disabling it

109 of 187 react-hooks/react-doctor suppressions fixed:
- ~95 exhaustive-deps disables only hid stable refs and setters passed
  through controller objects; list them (identity never changes, so
  nothing re-runs), plus a few real stale closures (tabId/worktreeId).
- Effects keyed on one value that read the latest props now use
  useEffectEvent (WebConnect, skill freshness, Bitbucket dialog seeding,
  Landing preflight, automation catalog, structured-session recovery,
  file explorer).
- The conditional layout-owner hook is selected once at module load from
  the build constant instead of a rules-of-hooks disable that also hid
  the whole controller from compiler and react-doctor rules.
- A hook disable was masking a react/purity Date.now() in the palette's
  recent-tabs memo; sample it when the snapshot is captured.
- Three identical debounced-search effects share
  useTaskSearchDebouncedApply; layout snapshot merge helpers move out to
  stay under max-lines.
Remaining suppressions are mount-once resources, invalidation epochs,
deliberately keyed fields, and test harnesses that mock the dispatcher.

* chore(ratchet): exempt test harnesses and fixtures from the focus-read ratchet

The ratchet skipped *.test-fixture.ts but scanned *-test-harness,
*-test-fixture(s) and *.test-support files. Those seed settings with
getDefaultSettings (which the ratchet discovers as a focus reader) and
never route host work, so 17 baseline entries were false positives, and
replacing a test's `settings: {...} as never` with typed defaults tripped
it. Match the repo's test-support names and prune (which also drops one
stale entry), and rename the frame-ordering harness to *-test-harness like
its siblings. A blanket "-harness" match would also exempt
e2e-terminal-pty-harness, which production imports, so the names stay
explicit.

* test: merge duplicate shared/constants imports

* revert(file-explorer): restore original tree load effects pending SSH e2e investigation

* test(runtime): drop RuntimeStore cast now that the fixture is typed

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-24 19:37:19 -07:00

725 lines
25 KiB
TypeScript

/**
* E2E tests for the browser tab: creating browser tabs and state retention.
*
* User Prompt:
* - Browser works and also retains state when switching tabs etc.
*/
import { test, expect } from './helpers/orca-app'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import {
waitForSessionReady,
waitForActiveWorktree,
getActiveWorktreeId,
getActiveTabType,
getBrowserTabs,
ensureTerminalVisible
} from './helpers/store'
import { startBrowserLinkServer } from './helpers/browser-link-server'
type CreatedBrowserTab = {
id: string
pageId: string | null
}
async function createBrowserTab(
page: Parameters<typeof getActiveWorktreeId>[0],
worktreeId: string,
url?: string,
title = 'New Browser Tab'
): Promise<CreatedBrowserTab | null> {
return page.evaluate(
({ targetWorktreeId, targetUrl, targetTitle }) => {
const store = window.__store
if (!store) {
return null
}
const state = store.getState()
const tab = state.createBrowserTab(
targetWorktreeId,
targetUrl ?? state.browserDefaultUrl ?? 'about:blank',
{
title: targetTitle,
activate: true
}
)
return { id: tab.id, pageId: tab.activePageId ?? null }
},
{ targetWorktreeId: worktreeId, targetUrl: url, targetTitle: title }
)
}
async function switchToBrowserTab(
page: Parameters<typeof getActiveWorktreeId>[0],
worktreeId: string,
browserTabId: string
): Promise<void> {
await page.evaluate(
({ targetWorktreeId, targetBrowserTabId }) => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
if (
(state.browserTabsByWorktree[targetWorktreeId] ?? []).some(
(tab) => tab.id === targetBrowserTabId
)
) {
state.setActiveBrowserTab(targetBrowserTabId)
}
},
{ targetWorktreeId: worktreeId, targetBrowserTabId: browserTabId }
)
}
async function startBrowserFormServer(host = '127.0.0.1'): Promise<{
url: (label: string) => string
close: () => Promise<void>
}> {
const server = createServer((request, response) => {
const label = new URL(request.url ?? '/', 'http://127.0.0.1').pathname.slice(1)
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(`
<!doctype html>
<html>
<body>
<label>${label}<input id="q" /></label>
</body>
</html>
`)
})
await new Promise<void>((resolve) => server.listen(0, host, resolve))
const port = (server.address() as AddressInfo).port
return {
url: (label: string) => `http://${host}:${port}/${encodeURIComponent(label)}`,
close: () => closeServer(server)
}
}
async function startBrowserWindowCloseServer(): Promise<{
url: string
sourceUrl: string
close: () => Promise<void>
}> {
const server = createServer((request, response) => {
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
const pathname = new URL(request.url ?? '/', origin).pathname
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
if (pathname === '/source') {
response.end(`
<!doctype html>
<html>
<head><title>Close link source</title></head>
<body><a id="window-close-link" href="${origin}/window-close" target="_blank">Open close page</a></body>
</html>
`)
return
}
response.end(`
<!doctype html>
<html>
<head><title>Window close repro</title></head>
<body>
<p id="s">Attempting close…</p>
<script>
window.close()
setTimeout(() => {
document.getElementById('s').textContent =
'window.close() was blocked (expected).'
}, 200)
</script>
</body>
</html>
`)
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = (server.address() as AddressInfo).port
return {
url: `http://127.0.0.1:${port}/window-close`,
sourceUrl: `http://127.0.0.1:${port}/source`,
close: () => closeServer(server)
}
}
async function readBrowserWindowCloseStatus(
page: Parameters<typeof getActiveWorktreeId>[0],
browserTabId: string
): Promise<string> {
return page.evaluate(async (targetBrowserTabId) => {
const slot = document.querySelector(`[data-browser-overlay-tab-id="${targetBrowserTabId}"]`)
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
if (!webview) {
return 'webview missing'
}
try {
return (await webview.executeJavaScript(
'document.querySelector("#s")?.textContent ?? "status missing"'
)) as string
} catch {
return 'guest lost'
}
}, browserTabId)
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) =>
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
)
}
async function clickBrowserLink(
page: Parameters<typeof getActiveWorktreeId>[0],
browserTabId: string,
selector: string,
options: {
modifiers?: ('meta' | 'control' | 'shift')[]
button?: 'left' | 'middle' | 'right'
frameSelector?: string
} = {}
): Promise<void> {
await page.evaluate(
async ({ targetBrowserTabId, targetSelector, inputModifiers, button, frameSelector }) => {
const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find(
(candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId
)
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
if (!webview) {
throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`)
}
const point = (await webview.executeJavaScript(`(async () => {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
const frame = ${JSON.stringify(frameSelector)}
? document.querySelector(${JSON.stringify(frameSelector)})
: null
const root = frame?.contentDocument ?? document
const linkRect = root.querySelector(${JSON.stringify(targetSelector)})?.getBoundingClientRect()
if (linkRect) {
const frameRect = frame?.getBoundingClientRect()
return {
x: (frameRect?.left ?? 0) + linkRect.left + linkRect.width / 2,
y: (frameRect?.top ?? 0) + linkRect.top + linkRect.height / 2
}
}
await new Promise((resolve) => setTimeout(resolve, 25))
}
return null
})()`)) as { x: number; y: number } | null
if (!point) {
throw new Error(`Missing browser link ${targetSelector}`)
}
const holdShift = inputModifiers.includes('shift')
if (holdShift) {
await webview.sendInputEvent({ type: 'keyDown', keyCode: 'Shift', modifiers: ['shift'] })
}
try {
await webview.sendInputEvent({ type: 'mouseMove', modifiers: inputModifiers, ...point })
await webview.sendInputEvent({
type: 'mouseDown',
button,
clickCount: 1,
modifiers: inputModifiers,
...point
})
await webview.sendInputEvent({
type: 'mouseUp',
button,
clickCount: 1,
modifiers: inputModifiers,
...point
})
} finally {
if (holdShift) {
await webview.sendInputEvent({ type: 'keyUp', keyCode: 'Shift' })
}
}
},
{
targetBrowserTabId: browserTabId,
targetSelector: selector,
inputModifiers: options.modifiers ?? [],
button: options.button ?? 'left',
frameSelector: options.frameSelector ?? null
}
)
}
async function waitForTabIdByExactTitle(
page: Parameters<typeof getActiveWorktreeId>[0],
title: string
): Promise<string> {
const resolveTabId = (): Promise<string | null> =>
page.locator('[data-tab-id]').evaluateAll((tabs, exactTitle) => {
const tab = tabs.find((candidate) => candidate.textContent?.trim() === exactTitle)
return tab?.getAttribute('data-tab-id') ?? null
}, title)
await expect.poll(resolveTabId, { timeout: 10_000 }).not.toBeNull()
return (await resolveTabId()) as string
}
async function expectBrowserTabActive(
page: Parameters<typeof getActiveWorktreeId>[0],
title: string
): Promise<void> {
const tabId = await waitForTabIdByExactTitle(page, title)
await expect(page.locator(`[data-browser-overlay-tab-id="${tabId}"]`)).toHaveCSS('opacity', '1')
}
async function expectBrowserTabOpenedInBackground(
page: Parameters<typeof getActiveWorktreeId>[0],
sourceTabId: string,
title: string
): Promise<void> {
const openedTabId = await waitForTabIdByExactTitle(page, title)
await expect(page.locator(`[data-browser-overlay-tab-id="${sourceTabId}"]`)).toHaveCSS(
'opacity',
'1'
)
await expect(page.locator(`[data-browser-overlay-tab-id="${openedTabId}"]`)).toHaveCSS(
'opacity',
'0'
)
}
async function readBrowserInputValue(
page: Parameters<typeof getActiveWorktreeId>[0],
browserTabId: string
): Promise<string | null> {
return page.evaluate(async (targetBrowserTabId) => {
const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find(
(candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId
)
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
if (!webview) {
return null
}
try {
return await webview.executeJavaScript('document.querySelector("#q")?.value ?? null')
} catch {
return null
}
}, browserTabId)
}
async function writeBrowserInputValue(
page: Parameters<typeof getActiveWorktreeId>[0],
browserTabId: string,
value: string
): Promise<void> {
await expect
.poll(async () => readBrowserInputValue(page, browserTabId), { timeout: 5_000 })
.not.toBeNull()
await page.evaluate(
async ({ targetBrowserTabId, nextValue }) => {
const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find(
(candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId
)
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
if (!webview) {
throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`)
}
await webview.executeJavaScript(
`document.querySelector("#q").value = ${JSON.stringify(nextValue)}`
)
},
{ targetBrowserTabId: browserTabId, nextValue: value }
)
await expect
.poll(async () => readBrowserInputValue(page, browserTabId), { timeout: 5_000 })
.toBe(value)
}
test.describe('Browser Tab', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
})
test('browser webview form state survives switching between browser tabs', async ({
orcaPage
}) => {
const formServer = await startBrowserFormServer()
try {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
const firstTab = await createBrowserTab(
orcaPage,
worktreeId,
formServer.url('First search'),
'First Form'
)
expect(firstTab?.id).toBeTruthy()
await writeBrowserInputValue(orcaPage, firstTab!.id, 'first typed value')
const secondTab = await createBrowserTab(
orcaPage,
worktreeId,
formServer.url('Second search'),
'Second Form'
)
expect(secondTab?.id).toBeTruthy()
await writeBrowserInputValue(orcaPage, secondTab!.id, 'second typed value')
// Why: switching browser tabs used to unmount and reparent the inactive
// Electron webview, which recreated the guest document and erased form DOM.
await switchToBrowserTab(orcaPage, worktreeId, firstTab!.id)
await expect
.poll(async () => readBrowserInputValue(orcaPage, firstTab!.id), { timeout: 5_000 })
.toBe('first typed value')
await switchToBrowserTab(orcaPage, worktreeId, secondTab!.id)
await expect
.poll(async () => readBrowserInputValue(orcaPage, secondTab!.id), { timeout: 5_000 })
.toBe('second typed value')
} finally {
await formServer.close()
}
})
test('browser page reload restores the configured 100% zoom', async ({ orcaPage }) => {
const formServer = await startBrowserFormServer()
try {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
const browserTab = await createBrowserTab(
orcaPage,
worktreeId,
formServer.url('Zoom reload'),
'Zoom Reload'
)
expect(browserTab?.id).toBeTruthy()
await expect
.poll(async () => readBrowserInputValue(orcaPage, browserTab!.id), { timeout: 5_000 })
.not.toBeNull()
const zoomLevels = await orcaPage.evaluate(async (browserTabId) => {
const slot = document.querySelector(`[data-browser-overlay-tab-id="${browserTabId}"]`)
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
if (!webview) {
throw new Error(`Missing webview for browser tab ${browserTabId}`)
}
const levels = [webview.getZoomLevel()]
webview.setZoomLevel(0.5)
for (let reload = 0; reload < 3; reload += 1) {
await new Promise<void>((resolve) => {
const onReady = (): void => {
webview.removeEventListener('dom-ready', onReady)
resolve()
}
webview.addEventListener('dom-ready', onReady)
if (reload === 1) {
webview.reloadIgnoringCache()
} else {
webview.reload()
}
})
levels.push(webview.getZoomLevel())
}
return levels
}, browserTab!.id)
expect(zoomLevels).toEqual([0, 0, 0, 0])
} finally {
await formServer.close()
}
})
test('Cmd/Ctrl+0 resets a zoomed browser page to 100%', async ({ orcaPage }) => {
const formServer = await startBrowserFormServer()
try {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
const browserTab = await createBrowserTab(
orcaPage,
worktreeId,
formServer.url('Zoom reset'),
'Zoom Reset'
)
expect(browserTab?.id).toBeTruthy()
await expect
.poll(async () => readBrowserInputValue(orcaPage, browserTab!.id), { timeout: 5_000 })
.not.toBeNull()
const zoomModifier: 'meta' | 'control' = process.platform === 'darwin' ? 'meta' : 'control'
await orcaPage.evaluate(
async ({ browserTabId, browserPageId, modifier }) => {
const slot = document.querySelector(`[data-browser-overlay-tab-id="${browserTabId}"]`)
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
if (!webview) {
throw new Error(`Missing webview for browser tab ${browserTabId}`)
}
window.dispatchEvent(
new CustomEvent('orca:browser-page-zoom', {
detail: { browserPageId, direction: 'in' }
})
)
await webview.sendInputEvent({ type: 'keyDown', keyCode: '0', modifiers: [modifier] })
await webview.sendInputEvent({ type: 'keyUp', keyCode: '0', modifiers: [modifier] })
},
{
browserTabId: browserTab!.id,
browserPageId: browserTab!.pageId ?? browserTab!.id,
modifier: zoomModifier
}
)
await expect
.poll(() =>
orcaPage.evaluate((browserTabId) => {
const slot = document.querySelector(`[data-browser-overlay-tab-id="${browserTabId}"]`)
return (slot?.querySelector('webview') as Electron.WebviewTag | null)?.getZoomLevel()
}, browserTab!.id)
)
.toBe(0)
} finally {
await formServer.close()
}
})
test('reloading one browser tab does not adopt another tab zoom', async ({ orcaPage }) => {
const [formServerA, formServerB] = await Promise.all([
startBrowserFormServer(),
startBrowserFormServer('localhost')
])
try {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
const tabA = await createBrowserTab(orcaPage, worktreeId, formServerA.url('Zoom A'), 'Zoom A')
const tabB = await createBrowserTab(orcaPage, worktreeId, formServerB.url('Zoom B'), 'Zoom B')
expect(tabA?.id).toBeTruthy()
expect(tabB?.id).toBeTruthy()
for (const tab of [tabA, tabB]) {
await expect
.poll(async () => readBrowserInputValue(orcaPage, tab!.id), { timeout: 5_000 })
.not.toBeNull()
}
const levels = await orcaPage.evaluate(
async ({ tabAId, tabBId, pageBId }) => {
const webviewFor = (id: string): Electron.WebviewTag => {
const slot = document.querySelector(`[data-browser-overlay-tab-id="${id}"]`)
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
if (!webview) {
throw new Error(`Missing webview for browser tab ${id}`)
}
return webview
}
const webviewA = webviewFor(tabAId)
const webviewB = webviewFor(tabBId)
// Zoom only tab B through the real renderer zoom path (also writes the shared setting).
for (let step = 0; step < 2; step += 1) {
window.dispatchEvent(
new CustomEvent('orca:browser-page-zoom', {
detail: { browserPageId: pageBId, direction: 'in' }
})
)
await new Promise((resolve) => setTimeout(resolve, 100))
}
const zoomedB = webviewB.getZoomLevel()
const untouchedA = webviewA.getZoomLevel()
await new Promise<void>((resolve) => {
const onReady = (): void => {
webviewA.removeEventListener('dom-ready', onReady)
resolve()
}
webviewA.addEventListener('dom-ready', onReady)
webviewA.reload()
})
return { zoomedB, untouchedA, reloadedA: webviewA.getZoomLevel() }
},
{ tabAId: tabA!.id, tabBId: tabB!.id, pageBId: tabB!.pageId ?? tabB!.id }
)
expect(levels.zoomedB).toBeGreaterThan(0)
expect(levels.untouchedA).toBe(0)
// Regression: reasserting the shared default would drag tab A to tab B's zoom.
expect(levels.reloadedA).toBe(0)
} finally {
await Promise.all([formServerA.close(), formServerB.close()])
}
})
test('new-tab link gestures follow Chrome foreground and background behavior', async ({
electronApp,
orcaPage
}) => {
const linkServer = await startBrowserLinkServer()
try {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
const sourceTab = await createBrowserTab(
orcaPage,
worktreeId,
linkServer.sourceUrl,
'Source page'
)
expect(sourceTab?.id).toBeTruthy()
const baseWindowCount = await electronApp.evaluate(
({ BaseWindow }) => BaseWindow.getAllWindows().length
)
// A plain main-frame target=_blank click must not navigate the source tab away.
const sourceTabLocator = orcaPage.locator(`[data-tab-id="${sourceTab!.id}"]`)
await clickBrowserLink(orcaPage, sourceTab!.id, '#blank-link')
await expectBrowserTabActive(orcaPage, 'Blank target destination')
await expect(sourceTabLocator).toContainText('Source page')
await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id)
// Context-menu links keep the source visible until the new tab is selected.
await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link', { button: 'right' })
await orcaPage
.getByRole('menuitem', { name: 'Open Link In Orca Browser', exact: true })
.click()
await expectBrowserTabOpenedInBackground(orcaPage, sourceTab!.id, 'Linked destination')
await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-link', {
frameSelector: '#link-frame'
})
await expectBrowserTabActive(orcaPage, 'Frame destination')
await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id)
await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-modifier-link', {
frameSelector: '#link-frame',
modifiers: process.platform === 'darwin' ? ['meta'] : ['control']
})
await expectBrowserTabOpenedInBackground(
orcaPage,
sourceTab!.id,
'Frame modifier destination'
)
await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-middle-link', {
button: 'middle',
frameSelector: '#link-frame'
})
await expectBrowserTabOpenedInBackground(orcaPage, sourceTab!.id, 'Frame middle destination')
await clickBrowserLink(orcaPage, sourceTab!.id, '#modifier-link', {
modifiers: process.platform === 'darwin' ? ['meta'] : ['control']
})
await expectBrowserTabOpenedInBackground(orcaPage, sourceTab!.id, 'Modifier destination')
await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-shift-middle-link', {
button: 'middle',
modifiers: ['shift'],
frameSelector: '#link-frame'
})
await expectBrowserTabActive(orcaPage, 'Frame shift middle destination')
await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id)
const tabCountBeforeCancelledClick = await orcaPage.locator('[data-tab-id]').count()
await clickBrowserLink(orcaPage, sourceTab!.id, '#cancelled-link')
await expect(
orcaPage.locator('[data-tab-id]').filter({ hasText: 'Click handled in page' })
).toBeVisible({ timeout: 10_000 })
await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(tabCountBeforeCancelledClick)
await clickBrowserLink(orcaPage, sourceTab!.id, '#middle-link', { button: 'middle' })
await expectBrowserTabOpenedInBackground(orcaPage, sourceTab!.id, 'Middle-click destination')
await expect
.poll(() => electronApp.evaluate(({ BaseWindow }) => BaseWindow.getAllWindows().length), {
timeout: 5_000
})
.toBe(baseWindowCount)
} finally {
await linkServer.close()
}
})
test('blocked window.close in a link-created tab does not break tab switching', async ({
orcaPage
}) => {
const closeServer = await startBrowserWindowCloseServer()
try {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
const neighboringTab = await createBrowserTab(
orcaPage,
worktreeId,
'about:blank',
'Neighboring tab'
)
const sourceTab = await createBrowserTab(
orcaPage,
worktreeId,
closeServer.sourceUrl,
'Close link source'
)
expect(neighboringTab?.id).toBeTruthy()
expect(sourceTab?.id).toBeTruthy()
await clickBrowserLink(orcaPage, sourceTab!.id, '#window-close-link')
let closeTabId: string | null = null
await expect
.poll(async () => {
const tabs = await getBrowserTabs(orcaPage, worktreeId)
closeTabId = tabs.find((tab) => tab.url === closeServer.url)?.id ?? null
return closeTabId
})
.not.toBeNull()
await orcaPage.locator(`[data-tab-id="${neighboringTab!.id}"]`).click()
await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 5_000 }).toBe('browser')
await expect
.poll(() => readBrowserWindowCloseStatus(orcaPage, closeTabId!), { timeout: 5_000 })
.toContain('window.close() was blocked')
} finally {
await closeServer.close()
}
})
test('directly created browser tabs block window.close and remain usable', async ({
orcaPage
}) => {
const closeServer = await startBrowserWindowCloseServer()
try {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
const directTab = await createBrowserTab(
orcaPage,
worktreeId,
closeServer.url,
'Direct close tab'
)
expect(directTab?.id).toBeTruthy()
await expect
.poll(() => readBrowserWindowCloseStatus(orcaPage, directTab!.id), { timeout: 5_000 })
.toContain('window.close() was blocked')
await expect
.poll(
() =>
orcaPage.evaluate(async (targetBrowserTabId) => {
const slot = document.querySelector(
`[data-browser-overlay-tab-id="${targetBrowserTabId}"]`
)
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
if (!webview) {
return 'webview missing'
}
try {
return (await webview.executeJavaScript(`(() => {
window.close = () => 'replacement-called'
return window.close() === 'replacement-called' ? 'replacement-called' : 'blocked'
})()`)) as string
} catch {
return 'guest unavailable'
}
}, directTab!.id),
{ timeout: 5_000 }
)
.toBe('blocked')
} finally {
await closeServer.close()
}
})
})