mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* 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>
725 lines
25 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|