mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
test: remove low-value browser, hook and SSH cases (#40423)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -15,7 +15,6 @@ src/main/managed-server/migration/cutover/orcad-migration-delta-move.ts
|
||||
src/main/managed-server/migration/linking/orcad-migration-repository-link.test.ts
|
||||
src/main/ssh/migration-source/orcad-migration-terminal-gate.test.ts
|
||||
src/main/ssh/migration-source/orcad-migration-terminal-gate.ts
|
||||
src/main/ssh/host-server/ssh-host-server-on-connect.test.ts
|
||||
src/main/ssh/host-server/ssh-host-server-on-connect.ts
|
||||
src/main/ssh/targets/ssh-target-orcad-preflight.ts
|
||||
src/preload/api-types.ts
|
||||
|
||||
@@ -927,12 +927,10 @@
|
||||
"src/main/browser/identity/mode/browser-identity-mode-store.test.ts": 95,
|
||||
"src/main/browser/guest-lifecycle/prompts/browser-javascript-dialogs.test.ts": 54,
|
||||
"src/main/browser/guest-lifecycle/manager/viewport/browser-manager-annotation-bridge.test.ts": 603,
|
||||
"src/main/browser/guest-lifecycle/manager/navigation/browser-manager-auth-user-agent.test.ts": 555,
|
||||
"src/main/browser/guest-lifecycle/manager/downloads/browser-manager-client-hosted-downloads.test.ts": 670,
|
||||
"src/main/browser/guest-lifecycle/manager/registration/browser-manager-destroyed-guest.test.ts": 633,
|
||||
"src/main/browser/guest-lifecycle/manager/devtools/browser-manager-devtools-retirement.test.ts": 551,
|
||||
"src/main/browser/guest-lifecycle/manager/downloads/browser-manager-downloads.test.ts": 592,
|
||||
"src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-capture.test.ts": 516,
|
||||
"src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-mode.test.ts": 567,
|
||||
"src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-selection.test.ts": 584,
|
||||
"src/main/browser/guest-lifecycle/manager/browser-manager-guest-visibility.test.ts": 543,
|
||||
@@ -942,7 +940,6 @@
|
||||
"src/main/browser/guest-lifecycle/manager/popups/browser-manager-popup-routing.test.ts": 575,
|
||||
"src/main/browser/guest-lifecycle/manager/viewport/browser-manager-tab-identity.test.ts": 619,
|
||||
"src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-override.test.ts": 597,
|
||||
"src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-ownership.test.ts": 550,
|
||||
"src/main/browser/network/execution-routes/browser-network-deferred-socket.test.ts": 101,
|
||||
"src/main/browser/network/execution-routes/browser-network-execution-route.test.ts": 272,
|
||||
"src/main/browser/network/tunnels/browser-network-tunnel-client.test.ts": 127,
|
||||
|
||||
-33
@@ -9,7 +9,6 @@ import { AgentHookServer } from '../../agent-hook-server'
|
||||
import { buildBody, PANE, postHookEvent } from '../server.test-fixtures'
|
||||
import { ClaudeHelperKillPoller } from '../../../../../shared/agent-hook-listener/providers/claude-helper-kill-poller'
|
||||
import '../../../../../shared/agent-hook-listener/providers/claude-transcript-decoder'
|
||||
import * as cursor from '../../../../../shared/agent-hook-listener/providers/claude-transcript-cursor'
|
||||
import '../../../../../shared/agent-hook-listener/transcripts/agent-transcript-poll-scheduler'
|
||||
|
||||
// Complete captured lifecycle records, with runtime replay seeded by real persisted HTTP state.
|
||||
@@ -369,38 +368,6 @@ it('a real failed bind parks without resources and explicit retry delivers its f
|
||||
expect(p.row()?.workingMode).toBe('monitoring')
|
||||
})
|
||||
|
||||
it.each(['relay-spool', 'desktop-spool'])(
|
||||
'stop during %s recovery invalidates its parked owner',
|
||||
async (kind) => {
|
||||
const read = vi.spyOn(cursor, 'readClaudeTranscriptChunk')
|
||||
const p = await setup(kind, 'accepted-before-bind', {
|
||||
stopBeforeBind: true,
|
||||
beforeStart: () => read.mockClear()
|
||||
})
|
||||
expect(p.entries.size).toBe(0)
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(p.poller['scheduler']['entries'].size).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['desktop-http', 'relay-http', 'desktop-spool', 'relay-spool'])(
|
||||
'refused %s admission creates no observation or wake',
|
||||
async (kind) => {
|
||||
const capture = vi.spyOn(ClaudeHelperKillPoller.prototype, 'schedule')
|
||||
const p = await setup(kind, 'before-start', {
|
||||
retired: true,
|
||||
beforeStart: () => capture.mockClear()
|
||||
})
|
||||
expect(p.entries.size).toBe(0)
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
if (kind === 'desktop-spool') {
|
||||
expect(p.row()?.restoredUnconfirmed).toBe(true)
|
||||
} else {
|
||||
expect(p.row()).toBeUndefined()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('a held local child permission cannot reset the admitted owner', async () => {
|
||||
const p = await setup('desktop-http', 'after-start')
|
||||
await p.post({
|
||||
|
||||
-222
@@ -27,105 +27,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('does not infer an interrupt while a subagent child is still working', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'review loop',
|
||||
agentType: 'claude',
|
||||
// Child-driven working status cannot prove Ctrl+C ended the lead's turn.
|
||||
subagents: [{ id: 'a1', state: 'working', startedAt: 900 }]
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'review loop',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
|
||||
expect(applied).toBe(false)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not infer an interrupt while Claude reports a background shell and the row carries no main agent fact', () => {
|
||||
// Legacy hosts omit mainAgent, so working may mean an idle lead held open by live shell work.
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
claudeRunningNonAgentTask: true,
|
||||
payload: { state: 'working', prompt: 'run in background', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
let baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'run in background',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
|
||||
|
||||
vi.setSystemTime(2_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
claudeRunningNonAgentTask: false,
|
||||
payload: { state: 'working', prompt: 'run in background', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(2_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'run in background',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('infers a cancel of a live Claude main agent turn and keeps the shell it leaves running as monitoring', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
@@ -443,43 +344,6 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not apply Claude background metadata from a rejected remote status', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
hookEventName: 'PermissionRequest',
|
||||
claudeRunningNonAgentTask: true,
|
||||
payload: {
|
||||
state: 'waiting',
|
||||
prompt: 'approve shell',
|
||||
agentType: 'claude',
|
||||
toolName: 'Bash'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const waiting = server.getStatusSnapshot()[0]
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
hookEventName: 'PreToolUse',
|
||||
claudeRunningNonAgentTask: false,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'approve shell',
|
||||
agentType: 'claude',
|
||||
toolName: 'OtherTool'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toEqual(waiting)
|
||||
expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
@@ -543,90 +407,4 @@ describe('AgentHookServer listener replay', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['opencode', 'opencode2', 'copilot'] as const)(
|
||||
'rejects single plain Escape inference for %s',
|
||||
(agentType) => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'long task', agentType }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: agentType,
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
|
||||
expect(applied).toBe(false)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'long task',
|
||||
agentType
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['opencode', 'opencode2', 'copilot'] as const)(
|
||||
'accepts double plain Escape inference for %s',
|
||||
(agentType) => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'long task', agentType }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: agentType,
|
||||
intent: 'plain-escape',
|
||||
inputCount: 2
|
||||
})
|
||||
|
||||
expect(applied).toBe(true)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'long task',
|
||||
agentType,
|
||||
interrupted: true
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -85,20 +85,6 @@ describe('a pane without a proven owner', () => {
|
||||
expect(attempts()).toBe(1)
|
||||
})
|
||||
|
||||
it('acquires a Claude started through a shell script named claude', async () => {
|
||||
const server = await createServer()
|
||||
runningPane(
|
||||
49960,
|
||||
[49960, 101, 49960, 'bash /Users/u/bin/claude', 'S+'],
|
||||
[49969, 49960, 49960, 'node /opt/homebrew/bin/claude', 'S+']
|
||||
)
|
||||
observe(server, 'waiting')
|
||||
await hook(server, 'SessionStart', { session: 'session-a' })
|
||||
await hook(server, 'UserPromptSubmit', { session: 'session-a', prompt: 'Wrapped task' })
|
||||
expect(server.row()).toMatchObject({ state: 'working', prompt: 'Wrapped task' })
|
||||
expect(server.presence()).toMatchObject({ process: capture(49969), session: 'session-a' })
|
||||
})
|
||||
|
||||
it.each([['parent'], [undefined]])(
|
||||
'keeps a detached nested run out of an unproven owner in session %s, through its exit',
|
||||
async (session) => {
|
||||
@@ -259,25 +245,6 @@ describe('terminal reports and Orca restatements', () => {
|
||||
expect(server.row()).toMatchObject({ agentType: 'codex', prompt: 'Next task' })
|
||||
expect(server.presence()).toEqual({ agent: 'codex', session: 'cx' })
|
||||
})
|
||||
|
||||
it('keeps a suspended owner through a terminal report', async () => {
|
||||
const server = await createServer()
|
||||
runningPane(49969, CLAUDE)
|
||||
await hook(server, 'SessionStart', { session: 'session-a' })
|
||||
probe.mockImplementationOnce(async (_identity, _read, _platform, onSuspension) => {
|
||||
onSuspension?.(true)
|
||||
return 'unverifiable'
|
||||
})
|
||||
await server.checkAgentPresence(PANE)
|
||||
observe(server, 'working')
|
||||
expect(server.presence()).toEqual({
|
||||
agent: 'claude',
|
||||
process: capture(49969),
|
||||
session: 'session-a',
|
||||
sessionOwner: true,
|
||||
suspended: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('a retired owner', () => {
|
||||
|
||||
@@ -249,30 +249,6 @@ describe('startup spool replay of panes without a proven owner', () => {
|
||||
release()
|
||||
}
|
||||
)
|
||||
|
||||
it('answers past 64 unfinished reads as unknown, and reads that pane on its next turn', async () => {
|
||||
const panes = [...Array.from({ length: 69 }, (_, index) => paneOf(index)), PANE]
|
||||
const userDataPath = spoolDir(
|
||||
panes.map((paneKey) =>
|
||||
spooled(paneKey, 49969, { hook_event_name: 'UserPromptSubmit', prompt: 'task' })
|
||||
)
|
||||
)
|
||||
runningPane(49969, CLAUDE)
|
||||
const { release, requested } = rootAnswers(() => true)
|
||||
const { server, started } = launchServer(userDataPath)
|
||||
await requested
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await started
|
||||
expect(attempts()).toBe(64)
|
||||
vi.useRealTimers()
|
||||
release()
|
||||
await settled(64)
|
||||
expect(server.presence(panes[0])?.fallback).toBeUndefined()
|
||||
expect(server.presence()).toMatchObject({ process: capture(49969), fallback: true })
|
||||
await hook(server, 'UserPromptSubmit', { prompt: 'next' })
|
||||
expect(attempts()).toBe(65)
|
||||
expect(server.presence()?.fallback).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL relay startup spool replay', () => {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GRAB_BUDGET } from '../../../../../shared/browser/element-grab/browser-grab-types'
|
||||
|
||||
const grabMocks = vi.hoisted(() => ({
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestExecuteJavaScriptMock: vi.fn(),
|
||||
guestExecuteJavaScriptInIsolatedWorldMock: vi.fn(),
|
||||
guestIsDestroyedMock: vi.fn(() => false),
|
||||
guestGetZoomFactorMock: vi.fn(() => 1),
|
||||
guestCapturePageMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
rendererSendMock: vi.fn(),
|
||||
rendererIsDestroyedMock: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
Menu: { buildFromTemplate: grabMocks.menuBuildFromTemplateMock },
|
||||
webContents: { fromId: grabMocks.webContentsFromIdMock }
|
||||
}))
|
||||
|
||||
import { browserManager } from '../browser-manager'
|
||||
import { resetGrabTestEnvironment } from './browser-manager-grab-test-harness'
|
||||
|
||||
const { guestCapturePageMock, guestExecuteJavaScriptMock } = grabMocks
|
||||
|
||||
describe('browserManager grab operations', () => {
|
||||
let guest: Electron.WebContents
|
||||
|
||||
beforeEach(() => {
|
||||
guest = resetGrabTestEnvironment(grabMocks)
|
||||
})
|
||||
|
||||
describe('captureSelectionScreenshot', () => {
|
||||
it('captures, crops, and converts screenshot dimensions back to CSS pixels', async () => {
|
||||
const cropMock = vi.fn(() => ({
|
||||
toPNG: vi.fn(() => Buffer.from('png-data'))
|
||||
}))
|
||||
guestCapturePageMock.mockResolvedValue({
|
||||
isEmpty: vi.fn(() => false),
|
||||
getSize: vi.fn(() => ({ width: 2000, height: 1000 })),
|
||||
crop: cropMock
|
||||
})
|
||||
guestExecuteJavaScriptMock.mockImplementation(async (script: string) =>
|
||||
script === 'window.innerWidth' ? 1000 : undefined
|
||||
)
|
||||
|
||||
const screenshot = await browserManager.captureSelectionScreenshot(
|
||||
'tab-1',
|
||||
{ x: 10, y: 20, width: 100, height: 50 },
|
||||
guest
|
||||
)
|
||||
|
||||
expect(cropMock).toHaveBeenCalledWith({ x: 20, y: 40, width: 200, height: 100 })
|
||||
expect(screenshot).toEqual({
|
||||
mimeType: 'image/png',
|
||||
dataUrl: `data:image/png;base64,${Buffer.from('png-data').toString('base64')}`,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('__orcaGrab')
|
||||
)
|
||||
expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('__orcaGrab')
|
||||
)
|
||||
expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith(3, 'window.innerWidth')
|
||||
})
|
||||
|
||||
it('omits screenshots that exceed the byte budget', async () => {
|
||||
const oversizedBuffer = Buffer.alloc(GRAB_BUDGET.screenshotMaxBytes + 1)
|
||||
guestCapturePageMock.mockResolvedValue({
|
||||
isEmpty: vi.fn(() => false),
|
||||
getSize: vi.fn(() => ({ width: 1000, height: 500 })),
|
||||
crop: vi.fn(() => ({
|
||||
toPNG: vi.fn(() => oversizedBuffer)
|
||||
}))
|
||||
})
|
||||
guestExecuteJavaScriptMock.mockImplementation(async (script: string) =>
|
||||
script === 'window.innerWidth' ? 1000 : undefined
|
||||
)
|
||||
|
||||
const screenshot = await browserManager.captureSelectionScreenshot(
|
||||
'tab-1',
|
||||
{ x: 0, y: 0, width: 100, height: 50 },
|
||||
guest
|
||||
)
|
||||
|
||||
expect(screenshot).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserGrabPayload } from '../../../../../shared/browser/element-grab/browser-grab-types'
|
||||
|
||||
const grabMocks = vi.hoisted(() => ({
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
@@ -35,57 +34,6 @@ import {
|
||||
|
||||
const { guestExecuteJavaScriptMock } = grabMocks
|
||||
|
||||
function makeValidGrabPayload(): BrowserGrabPayload {
|
||||
return {
|
||||
page: {
|
||||
sanitizedUrl: 'https://example.com/',
|
||||
title: 'Example',
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
devicePixelRatio: 2,
|
||||
capturedAt: '2026-04-10T00:00:00.000Z'
|
||||
},
|
||||
target: {
|
||||
tagName: 'button',
|
||||
selector: 'button',
|
||||
textSnippet: 'Click me',
|
||||
htmlSnippet: '<button>Click me</button>',
|
||||
attributes: {},
|
||||
accessibility: {
|
||||
role: 'button',
|
||||
accessibleName: 'Click me',
|
||||
ariaLabel: null,
|
||||
ariaLabelledBy: null
|
||||
},
|
||||
rectViewport: { x: 0, y: 0, width: 100, height: 40 },
|
||||
rectPage: { x: 0, y: 0, width: 100, height: 40 },
|
||||
computedStyles: {
|
||||
display: 'block',
|
||||
position: 'static',
|
||||
width: '100px',
|
||||
height: '40px',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
color: '#000',
|
||||
backgroundColor: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
lineHeight: '20px',
|
||||
textAlign: 'left',
|
||||
zIndex: 'auto'
|
||||
}
|
||||
},
|
||||
nearbyText: [],
|
||||
ancestorPath: ['div', 'body'],
|
||||
screenshot: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('browserManager grab operations', () => {
|
||||
let guest: Electron.WebContents
|
||||
|
||||
@@ -94,19 +42,6 @@ describe('browserManager grab operations', () => {
|
||||
})
|
||||
|
||||
describe('awaitGrabSelection', () => {
|
||||
it('does not treat a valid payload message field as cancellation', async () => {
|
||||
guestExecuteJavaScriptMock.mockResolvedValueOnce({
|
||||
...makeValidGrabPayload(),
|
||||
message: 'cancelled'
|
||||
})
|
||||
|
||||
const result = await browserManager.awaitGrabSelection('tab-1', 'op-1', guest)
|
||||
expect(result.kind).toBe('selected')
|
||||
if (result.kind === 'selected') {
|
||||
expect(result.payload.target.tagName).toBe('button')
|
||||
}
|
||||
})
|
||||
|
||||
it('main-side clamp redacts secret-bearing attribute values', async () => {
|
||||
const mockPayload = {
|
||||
page: {
|
||||
|
||||
-254
@@ -1,254 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn(),
|
||||
processUserAgentMode: 'clean',
|
||||
processUserAgent: 'Mozilla/5.0 (Test) Chrome/140.0.0.0'
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: browserMocks.appGetPathMock
|
||||
},
|
||||
BrowserWindow: {
|
||||
fromWebContents: browserMocks.browserWindowFromWebContentsMock
|
||||
},
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: {
|
||||
buildFromTemplate: browserMocks.menuBuildFromTemplateMock
|
||||
},
|
||||
screen: {
|
||||
getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock
|
||||
},
|
||||
webContents: {
|
||||
fromId: browserMocks.webContentsFromIdMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../identity/browser-process-user-agent', () => ({
|
||||
getBrowserProcessUserAgentIdentity: () => ({
|
||||
mode: browserMocks.processUserAgentMode,
|
||||
userAgent: browserMocks.processUserAgent,
|
||||
electronUserAgent:
|
||||
browserMocks.processUserAgentMode === 'native'
|
||||
? browserMocks.processUserAgent
|
||||
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../../identity/browser-tab-identity', async (importOriginal) =>
|
||||
(
|
||||
await import('../viewport/browser-manager-desktop-ua-metadata-test-fixture')
|
||||
).withPinnedDesktopUserAgentMetadata(await importOriginal<object>())
|
||||
)
|
||||
|
||||
import { browserManager } from '../browser-manager'
|
||||
import { googleAuthUserAgent } from '../../../identity/browser-google-auth-ua'
|
||||
import {
|
||||
guestBaseUserAgent,
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from '../browser-manager-test-harness'
|
||||
import { pinnedDesktopUserAgentMetadata as desktopMetadata } from '../viewport/browser-manager-desktop-ua-metadata-test-fixture'
|
||||
|
||||
const {
|
||||
guestOffMock,
|
||||
guestOnMock,
|
||||
guestSetBackgroundThrottlingMock,
|
||||
guestSetWindowOpenHandlerMock,
|
||||
guestOpenDevToolsMock,
|
||||
webContentsFromIdMock
|
||||
} = browserMocks
|
||||
|
||||
describe('browserManager', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
browserMocks.processUserAgentMode = 'clean'
|
||||
browserMocks.processUserAgent = guestBaseUserAgent
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// setUserAgent during will-redirect can abort and replay the request, breaking sign-in POST flows.
|
||||
it('retargets the auth-host UA over CDP during a redirect instead of cancelling it', async () => {
|
||||
const debuggerHandlers = new Map<string, () => void>()
|
||||
let holdNextUserAgentOverride = false
|
||||
let releaseHeldUserAgentOverride = (): void => {}
|
||||
let rejectNextUserAgentOverride = false
|
||||
const sendCommand = vi.fn((method: string) => {
|
||||
if (method === 'Emulation.setUserAgentOverride' && holdNextUserAgentOverride) {
|
||||
holdNextUserAgentOverride = false
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseHeldUserAgentOverride = resolve
|
||||
})
|
||||
}
|
||||
if (method === 'Emulation.setUserAgentOverride' && rejectNextUserAgentOverride) {
|
||||
rejectNextUserAgentOverride = false
|
||||
return Promise.reject(new Error('debugger detached'))
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
const guest = {
|
||||
id: 418,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
send: vi.fn(),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://mail.google.com/'),
|
||||
getUserAgent: vi.fn(() => guestBaseUserAgent),
|
||||
setUserAgent: vi.fn(),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => true),
|
||||
sendCommand,
|
||||
on: vi.fn((event: string, handler: () => void) => debuggerHandlers.set(event, handler)),
|
||||
off: vi.fn((event: string) => debuggerHandlers.delete(event))
|
||||
},
|
||||
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-redirect-ua',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const willRedirect = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'will-redirect'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
sendCommand.mockClear()
|
||||
|
||||
rejectNextUserAgentOverride = true
|
||||
willRedirect(
|
||||
{ preventDefault: vi.fn() },
|
||||
'https://accounts.google.com/v3/signin/identifier',
|
||||
false,
|
||||
true
|
||||
)
|
||||
await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached')
|
||||
|
||||
// Failed writes must remain retryable.
|
||||
willRedirect(
|
||||
{ preventDefault: vi.fn() },
|
||||
'https://accounts.google.com/v3/signin/identifier',
|
||||
false,
|
||||
true
|
||||
)
|
||||
await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined()
|
||||
|
||||
expect(guest.setUserAgent).not.toHaveBeenCalled()
|
||||
expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: googleAuthUserAgent(),
|
||||
userAgentMetadata: desktopMetadata
|
||||
})
|
||||
|
||||
// Detaching clears Chromium's CDP overrides and invalidates the confirmed identity.
|
||||
sendCommand.mockClear()
|
||||
debuggerHandlers.get('detach')?.()
|
||||
willRedirect(
|
||||
{ preventDefault: vi.fn() },
|
||||
'https://accounts.google.com/v3/signin/identifier',
|
||||
false,
|
||||
true
|
||||
)
|
||||
expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: googleAuthUserAgent(),
|
||||
userAgentMetadata: desktopMetadata
|
||||
})
|
||||
|
||||
// getUserAgent omits the CDP override; clear it on exit to restore UA and client hints.
|
||||
sendCommand.mockClear()
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
rejectNextUserAgentOverride = true
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached')
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
|
||||
expect(guest.setUserAgent).not.toHaveBeenCalled()
|
||||
expect(sendCommand.mock.calls).toEqual([
|
||||
['Emulation.setUserAgentOverride', { userAgent: '' }],
|
||||
['Emulation.setUserAgentOverride', { userAgent: '' }]
|
||||
])
|
||||
|
||||
// Newer confirmed writes outrank older pending writes.
|
||||
debuggerHandlers.get('detach')?.()
|
||||
holdNextUserAgentOverride = true
|
||||
willRedirect(
|
||||
{ preventDefault: vi.fn() },
|
||||
'https://accounts.google.com/v3/signin/identifier',
|
||||
false,
|
||||
true
|
||||
)
|
||||
const olderHeldWrite = sendCommand.mock.results.at(-1)?.value as Promise<void>
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined()
|
||||
|
||||
sendCommand.mockClear()
|
||||
willRedirect(
|
||||
{ preventDefault: vi.fn() },
|
||||
'https://accounts.google.com/v3/signin/identifier',
|
||||
false,
|
||||
true
|
||||
)
|
||||
expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: googleAuthUserAgent(),
|
||||
userAgentMetadata: desktopMetadata
|
||||
})
|
||||
releaseHeldUserAgentOverride()
|
||||
await olderHeldWrite
|
||||
|
||||
// A newer failure must preserve older pending writes.
|
||||
debuggerHandlers.get('detach')?.()
|
||||
holdNextUserAgentOverride = true
|
||||
willRedirect(
|
||||
{ preventDefault: vi.fn() },
|
||||
'https://accounts.google.com/v3/signin/identifier',
|
||||
false,
|
||||
true
|
||||
)
|
||||
const heldWrite = sendCommand.mock.results.at(-1)?.value as Promise<void>
|
||||
rejectNextUserAgentOverride = true
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached')
|
||||
releaseHeldUserAgentOverride()
|
||||
await heldWrite
|
||||
|
||||
sendCommand.mockClear()
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { userAgent: '' })
|
||||
browserManager.unregisterGuest('browser-redirect-ua')
|
||||
})
|
||||
|
||||
// Prefer a stale UA to aborting the redirect when CDP is unavailable.
|
||||
|
||||
// Direct navigation permits setUserAgent before request dispatch.
|
||||
|
||||
// Keep the process UA override until direct navigation can replace the stale auth base UA.
|
||||
})
|
||||
-54
@@ -73,60 +73,6 @@ describe('browserManager', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drops a queued failure when a replacement navigation starts before registration', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
id: 407,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://example.com/'),
|
||||
...guestUaMethods()
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) =>
|
||||
id === guest.id
|
||||
? guest
|
||||
: id === rendererWebContentsId
|
||||
? { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
: null
|
||||
)
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
const didFailLoad = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as (
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedUrl: string,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true)
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
expect(
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-late-registration',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith(
|
||||
'browser:guest-load-failed',
|
||||
expect.anything()
|
||||
)
|
||||
expect(browserManager.getBrowserPageLoadError('browser-late-registration')).toBeNull()
|
||||
})
|
||||
|
||||
it('restores a certificate failure when a retry navigation aborts before committing', () => {
|
||||
const guest = {
|
||||
id: 406,
|
||||
|
||||
+1
-206
@@ -32,21 +32,12 @@ vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({
|
||||
import { browserManager } from '../browser-manager'
|
||||
import {
|
||||
createDownloadItem,
|
||||
getDownloadItemEventHandler,
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from '../browser-manager-test-harness'
|
||||
|
||||
const {
|
||||
guestOffMock,
|
||||
guestOnMock,
|
||||
guestSetBackgroundThrottlingMock,
|
||||
guestSetWindowOpenHandlerMock,
|
||||
guestOpenDevToolsMock,
|
||||
webContentsFromIdMock,
|
||||
shellOpenExternalMock
|
||||
} = browserMocks
|
||||
const { webContentsFromIdMock } = browserMocks
|
||||
|
||||
let didCreateWindow: (window: { webContents: unknown }) => void = () => {}
|
||||
const fakeGuest = (id: number) => ({
|
||||
@@ -76,202 +67,6 @@ describe('browserManager popup child policies', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('attaches guest policies to created popup child windows', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const childSetBackgroundThrottlingMock = vi.fn()
|
||||
const childSetWindowOpenHandlerMock = vi.fn()
|
||||
const childOnMock = vi.fn()
|
||||
const childOffMock = vi.fn()
|
||||
const childOpenDevToolsMock = vi.fn()
|
||||
const childGuest = {
|
||||
id: 4040,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: childSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: childSetWindowOpenHandlerMock,
|
||||
on: childOnMock,
|
||||
off: childOffMock,
|
||||
openDevTools: childOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://identity.example.com/login')
|
||||
}
|
||||
const guest = {
|
||||
id: 404,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const didCreateWindowHandler = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-create-window'
|
||||
)?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined
|
||||
expect(didCreateWindowHandler).toBeTypeOf('function')
|
||||
|
||||
didCreateWindowHandler?.({ webContents: childGuest })
|
||||
|
||||
expect(childSetBackgroundThrottlingMock).not.toHaveBeenCalled()
|
||||
expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength(
|
||||
1
|
||||
)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1)
|
||||
|
||||
const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
|
||||
url: string
|
||||
}) => { action: 'allow' | 'deny' }
|
||||
expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({
|
||||
action: 'allow'
|
||||
})
|
||||
expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', {
|
||||
browserPageId: 'browser-1',
|
||||
origin: 'null',
|
||||
action: 'blocked'
|
||||
})
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: childGuest.id,
|
||||
permission: 'notifications',
|
||||
rawUrl: 'https://identity.example.com/login'
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', {
|
||||
browserPageId: 'browser-1',
|
||||
permission: 'notifications',
|
||||
origin: 'https://identity.example.com'
|
||||
})
|
||||
|
||||
const childDidFailLoadHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: Electron.Event,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean
|
||||
) => void)
|
||||
| undefined
|
||||
childDidFailLoadHandler?.(
|
||||
{} as Electron.Event,
|
||||
-105,
|
||||
'Name not resolved',
|
||||
'https://identity.example.com/unavailable',
|
||||
true
|
||||
)
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith(
|
||||
'browser:guest-load-failed',
|
||||
expect.anything()
|
||||
)
|
||||
|
||||
const childDownloadItem = createDownloadItem()
|
||||
browserManager.handleGuestWillDownload({
|
||||
guestWebContentsId: childGuest.id,
|
||||
item: childDownloadItem
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenCalledWith(
|
||||
'browser:download-requested',
|
||||
expect.objectContaining({ browserPageId: 'browser-1' })
|
||||
)
|
||||
const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done')
|
||||
childDownloadDoneHandler?.({} as Electron.Event, 'completed')
|
||||
|
||||
const managerState = browserManager as unknown as {
|
||||
popupOwnerContextByGuestId: Map<number, unknown>
|
||||
}
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true)
|
||||
|
||||
const cleanupChildOnMock = vi.fn()
|
||||
const cleanupChildGuest = {
|
||||
...childGuest,
|
||||
id: 4041,
|
||||
on: cleanupChildOnMock,
|
||||
off: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn()
|
||||
}
|
||||
const childDidCreateWindowHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-create-window'
|
||||
)?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined
|
||||
childDidCreateWindowHandler?.({ webContents: cleanupChildGuest })
|
||||
expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true)
|
||||
const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock
|
||||
.calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' }
|
||||
expect(
|
||||
cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' })
|
||||
).toMatchObject({ action: 'allow' })
|
||||
const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find(
|
||||
([event]) => event === 'destroyed'
|
||||
)?.[1] as (() => void) | undefined
|
||||
cleanupChildDestroyedHandler?.()
|
||||
expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false)
|
||||
|
||||
const replacementGuest = {
|
||||
...guest,
|
||||
id: 405,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn()
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === replacementGuest.id) {
|
||||
return replacementGuest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
browserManager.attachGuestPolicies(replacementGuest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: replacementGuest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next')
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false)
|
||||
|
||||
const childDestroyedHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'destroyed'
|
||||
)?.[1] as (() => void) | undefined
|
||||
childDestroyedHandler?.()
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false)
|
||||
|
||||
browserManager.unregisterAll()
|
||||
|
||||
expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function))
|
||||
expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function))
|
||||
expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function))
|
||||
})
|
||||
|
||||
it("saves a popup page's agent download to its path and refuses a second claim on the tab", async () => {
|
||||
const opener = fakeGuest(406)
|
||||
const popup = fakeGuest(4060)
|
||||
|
||||
-51
@@ -67,21 +67,6 @@ describe('browser guest destruction ownership', () => {
|
||||
mocks.guests.clear()
|
||||
})
|
||||
|
||||
it('releases registered callbacks and ownership after 1000 distinct guest destructions', () => {
|
||||
for (let index = 0; index < 1000; index++) {
|
||||
register(`retired-${index}`).destroy()
|
||||
}
|
||||
expectRetainedCount(0)
|
||||
})
|
||||
|
||||
it('keeps explicit unregister before destruction idempotent', () => {
|
||||
const guest = register('explicit')
|
||||
manager.unregisterGuest('explicit')
|
||||
guest.destroy()
|
||||
manager.unregisterGuest('explicit')
|
||||
expectRetainedCount(0)
|
||||
})
|
||||
|
||||
it('does not let a captured old destroyed callback retire a replacement guest', () => {
|
||||
const old = register('replacement')
|
||||
const [oldDestroyed] = old.listeners('destroyed')
|
||||
@@ -93,42 +78,6 @@ describe('browser guest destruction ownership', () => {
|
||||
expectRetainedCount(1)
|
||||
})
|
||||
|
||||
it('cleans a popup without retiring its live primary page', () => {
|
||||
const parent = register('popup-parent')
|
||||
const popup = createGuest()
|
||||
manager.attachGuestPolicies(popup.asWebContents(), {
|
||||
rootGuestWebContentsId: parent.id,
|
||||
browserTabId: 'popup-parent'
|
||||
})
|
||||
popup.destroy()
|
||||
expect(manager.getGuestWebContentsId('popup-parent')).toBe(parent.id)
|
||||
expectRetainedCount(1)
|
||||
})
|
||||
|
||||
it('cleans policies for a guest destroyed before registration', () => {
|
||||
const guest = createGuest()
|
||||
manager.attachGuestPolicies(guest.asWebContents())
|
||||
guest.destroy()
|
||||
expectRetainedCount(0)
|
||||
})
|
||||
|
||||
it('preserves live guest ownership when its renderer process needs reload recovery', () => {
|
||||
const guest = register('renderer-recovery')
|
||||
guest.emit('render-process-gone', {}, { reason: 'crashed', exitCode: 1 })
|
||||
expect(manager.getGuestWebContentsId('renderer-recovery')).toBe(guest.id)
|
||||
expect(manager.getSessionProfileIdForTab('renderer-recovery')).toBe('profile-1')
|
||||
expectRetainedCount(1)
|
||||
})
|
||||
|
||||
it('rebuilds ownership when a restored page registers its fresh guest', () => {
|
||||
register('fresh-owner').destroy()
|
||||
const replacement = register('fresh-owner')
|
||||
expect(manager.getGuestWebContentsId('fresh-owner')).toBe(replacement.id)
|
||||
expect(manager.getWorktreeIdForTab('fresh-owner')).toBe('worktree-1')
|
||||
expect(manager.getSessionProfileIdForTab('fresh-owner')).toBe('profile-1')
|
||||
expectRetainedCount(1)
|
||||
})
|
||||
|
||||
it('preserves a sibling page using the same browser session profile', () => {
|
||||
const retiring = register('retiring')
|
||||
const sibling = register('sibling')
|
||||
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn(),
|
||||
processUserAgentMode: 'clean',
|
||||
processUserAgent: ''
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: mocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: mocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: mocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: mocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: mocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: mocks.webContentsFromIdMock }
|
||||
}))
|
||||
vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: mocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../identity/browser-process-user-agent', () => ({
|
||||
getBrowserProcessUserAgentIdentity: () => ({
|
||||
mode: mocks.processUserAgentMode,
|
||||
userAgent: mocks.processUserAgent,
|
||||
electronUserAgent:
|
||||
mocks.processUserAgentMode === 'native'
|
||||
? mocks.processUserAgent
|
||||
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36'
|
||||
})
|
||||
}))
|
||||
|
||||
import { browserManager } from '../browser-manager'
|
||||
import { resetBrowserManagerMocks, resetBrowserManagerState } from '../browser-manager-test-harness'
|
||||
import {
|
||||
createViewportGuestFactory,
|
||||
GUEST_CLEAN_UA
|
||||
} from './browser-manager-viewport-test-fixtures'
|
||||
|
||||
const makeGuest = createViewportGuestFactory(mocks)
|
||||
const mobile = { width: 375, height: 667, deviceScaleFactor: 2, mobile: true }
|
||||
const desktop = { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }
|
||||
const guests = new Map<number, Record<string, unknown>>()
|
||||
const registeredGuests = readViewportStateMap('webContentsIdByTabId')
|
||||
const presetIntents = readViewportStateMap('viewportPresetByTabId')
|
||||
const pendingOperations = readViewportStateMap('viewportOpsByTabId')
|
||||
const cdpUserAgentOverrides = readViewportStateMap('cdpUserAgentOverrideStateByGuestId')
|
||||
|
||||
function readViewportStateMap(
|
||||
name:
|
||||
| 'webContentsIdByTabId'
|
||||
| 'viewportPresetByTabId'
|
||||
| 'viewportOpsByTabId'
|
||||
| 'cdpUserAgentOverrideStateByGuestId'
|
||||
): Map<unknown, unknown> {
|
||||
const value: unknown = browserManager[name]
|
||||
if (!(value instanceof Map)) {
|
||||
throw new Error(`Expected manager state map: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function register(tab: string, id: number) {
|
||||
const handle = makeGuest(id)
|
||||
guests.set(id, handle.guest)
|
||||
expect(
|
||||
browserManager.registerOffscreenGuest({
|
||||
browserPageId: tab,
|
||||
webContentsId: id
|
||||
})
|
||||
).toBe(true)
|
||||
return handle
|
||||
}
|
||||
|
||||
function pause(handle: ReturnType<typeof makeGuest>, method: string) {
|
||||
const entered = Promise.withResolvers<void>()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
let blocked = false
|
||||
handle.debuggerSendCommand.mockImplementation((next) => {
|
||||
if (!blocked && next === method) {
|
||||
blocked = true
|
||||
entered.resolve()
|
||||
return gate.promise
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
return { entered: entered.promise, ...gate }
|
||||
}
|
||||
|
||||
describe('browser viewport operation ownership', () => {
|
||||
beforeEach(() => {
|
||||
expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
|
||||
resetBrowserManagerMocks(mocks)
|
||||
resetBrowserManagerState()
|
||||
mocks.processUserAgentMode = 'clean'
|
||||
mocks.processUserAgent = GUEST_CLEAN_UA
|
||||
guests.clear()
|
||||
mocks.webContentsFromIdMock.mockImplementation((id) => guests.get(id))
|
||||
})
|
||||
afterEach(() => {
|
||||
browserManager.unregisterAll()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not recreate closed-tab UA intent after a late touch completion', async () => {
|
||||
const handle = register('closed', 100)
|
||||
const gate = pause(handle, 'Emulation.setTouchEmulationEnabled')
|
||||
const result = browserManager.setViewportOverride('closed', mobile)
|
||||
await gate.entered
|
||||
browserManager.unregisterGuest('closed')
|
||||
expect(presetIntents.size).toBe(0)
|
||||
const isDestroyed = handle.guest.isDestroyed
|
||||
expect(vi.isMockFunction(isDestroyed)).toBe(true)
|
||||
if (vi.isMockFunction(isDestroyed)) {
|
||||
isDestroyed.mockReturnValue(true)
|
||||
}
|
||||
gate.resolve()
|
||||
await expect(result).resolves.toBe(false)
|
||||
expect(registeredGuests.size).toBe(0)
|
||||
expect(presetIntents.size).toBe(0)
|
||||
expect(cdpUserAgentOverrides.size).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves replacement desktop intent after an old clear fails', async () => {
|
||||
const handle = register('replacement', 102)
|
||||
await browserManager.setViewportOverride('replacement', mobile)
|
||||
const gate = pause(handle, 'Emulation.setUserAgentOverride')
|
||||
const result = browserManager.setViewportOverride('replacement', null)
|
||||
await gate.entered
|
||||
browserManager.unregisterGuest('replacement')
|
||||
register('replacement', 103)
|
||||
await expect(browserManager.setViewportOverride('replacement', desktop)).resolves.toBe(true)
|
||||
gate.reject(new Error('Old target closed'))
|
||||
await expect(result).resolves.toBe(false)
|
||||
expect(registeredGuests.get('replacement')).toBe(103)
|
||||
expect(presetIntents.get('replacement')).toEqual({
|
||||
guestWebContentsId: 103,
|
||||
requested: desktop,
|
||||
applied: desktop
|
||||
})
|
||||
})
|
||||
|
||||
// Requested and applied metrics may clear while the separate UA override remains tracked.
|
||||
it('same-owner clear failure keeps the standing mobile override tracked', async () => {
|
||||
const handle = register('same-owner', 106)
|
||||
await browserManager.setViewportOverride('same-owner', mobile)
|
||||
const gate = pause(handle, 'Emulation.setUserAgentOverride')
|
||||
const result = browserManager.setViewportOverride('same-owner', null)
|
||||
await gate.entered
|
||||
gate.reject(new Error('Protocol error'))
|
||||
await expect(result).resolves.toBe(false)
|
||||
expect(presetIntents.get('same-owner')).toEqual({
|
||||
guestWebContentsId: 106,
|
||||
requested: null,
|
||||
applied: null
|
||||
})
|
||||
expect(handle.presentedUserAgent()).toContain('iPhone')
|
||||
expect(cdpUserAgentOverrides.has(106)).toBe(true)
|
||||
})
|
||||
|
||||
it('old queued operations cannot remove or join a replacement promise tail', async () => {
|
||||
const old = register('queued-replacement', 114)
|
||||
const oldGate = pause(old, 'Emulation.setTouchEmulationEnabled')
|
||||
const first = browserManager.setViewportOverride('queued-replacement', mobile)
|
||||
const second = browserManager.setViewportOverride('queued-replacement', null)
|
||||
await oldGate.entered
|
||||
browserManager.unregisterGuest('queued-replacement')
|
||||
const replacement = register('queued-replacement', 115)
|
||||
const newGate = pause(replacement, 'Emulation.setTouchEmulationEnabled')
|
||||
const replacementFirst = browserManager.setViewportOverride('queued-replacement', desktop)
|
||||
const replacementSecond = browserManager.setViewportOverride('queued-replacement', mobile)
|
||||
await newGate.entered
|
||||
const tail = pendingOperations.get('queued-replacement')
|
||||
oldGate.resolve()
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
expect(pendingOperations.get('queued-replacement')).toBe(tail)
|
||||
newGate.resolve()
|
||||
await expect(replacementFirst).resolves.toBe(true)
|
||||
await expect(replacementSecond).resolves.toBe(true)
|
||||
expect(pendingOperations.size).toBe(0)
|
||||
expect(presetIntents.get('queued-replacement')).toEqual({
|
||||
guestWebContentsId: 115,
|
||||
requested: mobile,
|
||||
applied: mobile
|
||||
})
|
||||
expect(replacement.presentedUserAgent()).toContain('iPhone')
|
||||
})
|
||||
|
||||
it('normal same-owner toggles preserve last-requested order and remove the promise tail', async () => {
|
||||
const handle = register('serialized', 116)
|
||||
const gate = pause(handle, 'Emulation.setTouchEmulationEnabled')
|
||||
const first = browserManager.setViewportOverride('serialized', mobile)
|
||||
await gate.entered
|
||||
const second = browserManager.setViewportOverride('serialized', desktop)
|
||||
const third = browserManager.setViewportOverride('serialized', null)
|
||||
gate.resolve()
|
||||
expect(await Promise.all([first, second, third])).toEqual([true, true, true])
|
||||
// Clear mobile identity when desktop metrics replace the mobile viewport.
|
||||
expect(handle.debuggerSendCommand.mock.calls.map(([method]) => method)).toEqual([
|
||||
'Emulation.setDeviceMetricsOverride',
|
||||
'Emulation.setTouchEmulationEnabled',
|
||||
'Emulation.setUserAgentOverride',
|
||||
'Emulation.setDeviceMetricsOverride',
|
||||
'Emulation.setTouchEmulationEnabled',
|
||||
'Emulation.setUserAgentOverride',
|
||||
'Emulation.clearDeviceMetricsOverride',
|
||||
'Emulation.setTouchEmulationEnabled'
|
||||
])
|
||||
expect(handle.presentedUserAgent()).toBe(GUEST_CLEAN_UA)
|
||||
expect(pendingOperations.size).toBe(0)
|
||||
expect(presetIntents.get('serialized')).toEqual({
|
||||
guestWebContentsId: 116,
|
||||
requested: null,
|
||||
applied: null
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
type AnyAuthMethod,
|
||||
type AuthenticationType,
|
||||
type AuthHandlerMiddleware,
|
||||
type ConnectConfig,
|
||||
type ParsedKey
|
||||
type ConnectConfig
|
||||
} from 'ssh2'
|
||||
|
||||
vi.mock('os', () => ({
|
||||
@@ -31,7 +30,6 @@ vi.mock('./ssh-identity-file-read', () => ({
|
||||
}))
|
||||
|
||||
import { buildConnectConfig } from '../connection/ssh-connect-config'
|
||||
import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication'
|
||||
import '../system-transport/system-ssh-args'
|
||||
import { walkInitialAuthLadder } from '../connection/testing/ssh-connection-test-fixtures'
|
||||
import type { SshTarget } from '../../../shared/ssh-types'
|
||||
@@ -144,33 +142,6 @@ describe('ordered SSH private-key authentication', () => {
|
||||
expect(nextAuth(config, false)).toBe('keyboard-interactive')
|
||||
})
|
||||
|
||||
it('keeps explicit manual keys and unresolved imported keys as singular overrides', () => {
|
||||
const manual = buildConnectConfig(
|
||||
makeTarget({
|
||||
source: 'manual',
|
||||
configHost: 'manual.example.com',
|
||||
host: 'manual.example.com',
|
||||
identityFile: '/keys/manual'
|
||||
}),
|
||||
makeResolved(),
|
||||
{ includeAgent: false, includePrivateKey: true }
|
||||
)
|
||||
const unresolvedImport = buildConnectConfig(makeTarget(), null, {
|
||||
includeAgent: false,
|
||||
includePrivateKey: true
|
||||
})
|
||||
|
||||
expect(manual.privateKey).toEqual(Buffer.from('/keys/manual'))
|
||||
expect(unresolvedImport.privateKey).toEqual(Buffer.from('/keys/stale-imported'))
|
||||
// Single-key targets also need per-stage keyboard-interactive restart for MFA.
|
||||
expect(nextAuth(manual, true)).toMatchObject({ type: 'none' })
|
||||
expect(nextAuth(manual, false)).toMatchObject({
|
||||
type: 'publickey',
|
||||
key: Buffer.from('/keys/manual')
|
||||
})
|
||||
expect(nextAuth(manual, false)).toBe('keyboard-interactive')
|
||||
})
|
||||
|
||||
it('keeps the password challenge on an agent attempt that carries a key, because no key retry follows it', () => {
|
||||
vi.stubEnv('SSH_AUTH_SOCK', '/tmp/agent.sock')
|
||||
// Real key bytes, so '/keys/explicit' counts as unencrypted and IdentitiesOnly builds its agent.
|
||||
@@ -216,35 +187,11 @@ describe('ordered SSH private-key authentication', () => {
|
||||
expect(partialSuccessAuth(config, ['keyboard-interactive'])).toBe(false)
|
||||
})
|
||||
|
||||
it('offers every resolved key for a manually owned config-picker target', () => {
|
||||
const config = buildConnectConfig(
|
||||
makeTarget({
|
||||
source: 'manual',
|
||||
configHost: 'prod',
|
||||
host: 'prod.internal',
|
||||
identityFile: undefined
|
||||
}),
|
||||
makeResolved(),
|
||||
{ includeAgent: false, includePrivateKey: true }
|
||||
)
|
||||
|
||||
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
|
||||
expect(nextAuth(config, false)).toMatchObject({
|
||||
type: 'publickey',
|
||||
key: Buffer.from('/keys/unauthorized-first')
|
||||
})
|
||||
expect(nextAuth(config, false)).toMatchObject({
|
||||
type: 'publickey',
|
||||
key: Buffer.from('/keys/authorized-second')
|
||||
})
|
||||
})
|
||||
|
||||
it('resets ordered authentication for credential retries without extra key reads', () => {
|
||||
it('resets ordered authentication for credential retries', () => {
|
||||
const config = buildConnectConfig(makeTarget(), makeResolved(), {
|
||||
includeAgent: false,
|
||||
includePrivateKey: true
|
||||
})
|
||||
const readsAfterResolution = mockReadFileSync.mock.calls.length
|
||||
|
||||
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
|
||||
config.password = 'retry-password'
|
||||
@@ -257,26 +204,5 @@ describe('ordered SSH private-key authentication', () => {
|
||||
type: 'publickey',
|
||||
key: Buffer.from('/keys/unauthorized-first')
|
||||
})
|
||||
expect(mockReadFileSync).toHaveBeenCalledTimes(readsAfterResolution)
|
||||
})
|
||||
|
||||
it('keeps encrypted-key prompts tied to the matching fresh identity path', () => {
|
||||
vi.spyOn(utils, 'parseKey').mockImplementation((key) => {
|
||||
if (
|
||||
Buffer.from(key as Buffer)
|
||||
.toString()
|
||||
.includes('encrypted-second')
|
||||
) {
|
||||
return new Error('Encrypted private OpenSSH key detected, but no passphrase given')
|
||||
}
|
||||
return { isPrivateKey: () => true } as ParsedKey
|
||||
})
|
||||
const config = buildConnectConfig(
|
||||
makeTarget(),
|
||||
makeResolved({ identityFile: ['/keys/first', '/keys/encrypted-second'] }),
|
||||
{ includeAgent: false, includePrivateKey: true }
|
||||
)
|
||||
|
||||
expect(getPassphrasePrivateKeyPath(config)).toBe('/keys/encrypted-second')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,6 @@ import type { SFTPWrapper } from 'ssh2'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { uploadBuffer, writeStringViaSftp } from './sftp-upload'
|
||||
import { uploadFile } from './sftp-file-upload'
|
||||
import { writeRemoteInstallFile } from '../../../managed-server/install/remote-install-transfers'
|
||||
import type { SshConnection } from '../../connection/ssh-connection'
|
||||
import { getRemoteHostPlatform } from '../../remote-host/ssh-remote-platform'
|
||||
|
||||
function sftpNoSuchFileError(): Error {
|
||||
return Object.assign(new Error('file does not exist'), { code: 2 })
|
||||
@@ -78,25 +75,3 @@ describe('late SFTP stream errors', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandboxed SFTP subsystem diagnosis', () => {
|
||||
it('leaves a permission refusal as itself rather than blaming a chroot', async () => {
|
||||
// Permission denial names visible paths; mislabeling it as sandboxing sends users to fix ProxyJump for chmod.
|
||||
const conn = {
|
||||
writeFile: () => Promise.reject(Object.assign(new Error('permission denied'), { code: 3 }))
|
||||
} as unknown as SshConnection
|
||||
|
||||
const failure: unknown = await writeRemoteInstallFile(
|
||||
conn,
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
'/home/user/.orca-remote/relay-1/.version',
|
||||
'v1'
|
||||
).then(
|
||||
() => null,
|
||||
(err: unknown) => err
|
||||
)
|
||||
|
||||
expect((failure as Error).message).toBe('permission denied')
|
||||
expect(failure).not.toHaveProperty('sandboxedSftpNamespace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import '../config/ssh-g-config-resolution'
|
||||
@@ -62,16 +62,6 @@ describe('the union of known_hosts entries', () => {
|
||||
expect(verdict(entries, 'shared.example', ED_C)).toBe('mismatch')
|
||||
})
|
||||
|
||||
it('skips a missing file and keeps the rest', async () => {
|
||||
const root = await createRoot()
|
||||
const present = join(root, 'known_hosts')
|
||||
await writeFile(present, hostLine('alpha.example', ED_A))
|
||||
|
||||
const entries = await loadKnownHostsEntries([join(root, 'absent'), present])
|
||||
|
||||
expect(verdict(entries, 'alpha.example', ED_A)).toBe('match')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'skips an unreadable file and keeps the rest',
|
||||
async () => {
|
||||
@@ -118,15 +108,6 @@ describe('loadKnownHostsEvidence', () => {
|
||||
expect(evidence.unreadableFileCount).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('counts a path that is a directory', async () => {
|
||||
const root = await createRoot()
|
||||
await mkdir(join(root, 'a_directory'))
|
||||
|
||||
const evidence = await loadKnownHostsEvidence([join(root, 'a_directory')])
|
||||
|
||||
expect(evidence.unreadableFileCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
/** OpenSSH10.2p1 prints spaced trust paths unquoted; splitting can hide existing changed-key evidence as absent files. */
|
||||
|
||||
@@ -31,76 +31,6 @@ describe('which server an SSH host runs on connect', () => {
|
||||
expect(d.autoUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks for an update right after pairing with a server another version runs', async () => {
|
||||
const d = deps({
|
||||
isEmptyHost: () => true,
|
||||
deploy: vi.fn(async () => ({
|
||||
outcome: 'already-current' as const,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the orchestrator reads only the environment id.
|
||||
environment: { id: 'env-1' } as never,
|
||||
activeVersion: '0.9'
|
||||
})),
|
||||
autoUpdate: vi.fn(async () => ({
|
||||
outcome: 'deferred' as const,
|
||||
code: 'orcad_update_terminals_running',
|
||||
reason: '3 terminals are running'
|
||||
}))
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({
|
||||
route: 'managed',
|
||||
environmentId: 'env-1',
|
||||
update: { state: 'deferred', detail: '3 terminals are running' }
|
||||
})
|
||||
expect(d.autoUpdate).toHaveBeenCalledWith('env-1', expect.anything())
|
||||
expect(d.abandonDeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// COMPAT(legacy-relay-bridge): the census only reports; converting leaves relay terminals running.
|
||||
it('converts anyway when the bridge takes the live or unproven relay terminals', async () => {
|
||||
for (const verdict of ['live', 'unverifiable'] as const) {
|
||||
const proof = { verdict, count: 2, bridged: true as const }
|
||||
const d = deps({ relayTerminals: async () => proof })
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toMatchObject({
|
||||
route: 'managed'
|
||||
})
|
||||
expect(d.convert).toHaveBeenCalledWith(target, proof)
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces a refusal for any other reason, naming it', async () => {
|
||||
const d = deps({
|
||||
convert: async () => ({
|
||||
outcome: 'refused',
|
||||
verdict: 'live',
|
||||
code: 'orcad_migration_preflight_blocked',
|
||||
reason: 'An automation still runs on this host.'
|
||||
})
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({
|
||||
route: 'unserved',
|
||||
reason: 'refused',
|
||||
detail: 'An automation still runs on this host.'
|
||||
})
|
||||
})
|
||||
|
||||
it('offers "Move anyway" when only saved state refuses', async () => {
|
||||
const d = deps({
|
||||
convert: async () => ({
|
||||
outcome: 'refused',
|
||||
verdict: 'live',
|
||||
code: 'orcad_migration_preflight_blocked',
|
||||
reason: 'This SSH host cannot move yet: saved tabs and panes (2).',
|
||||
canLeaveBehind: true
|
||||
})
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({
|
||||
route: 'unserved',
|
||||
reason: 'refused',
|
||||
detail: 'This SSH host cannot move yet: saved tabs and panes (2).',
|
||||
canLeaveBehind: true
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the relay ladder for good when orcad cannot run, and remembers why', async () => {
|
||||
const d = deps({
|
||||
isEmptyHost: () => true,
|
||||
@@ -168,32 +98,6 @@ describe('which server an SSH host runs on connect', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('touches no host when this build carries no orcad template', async () => {
|
||||
const d = deps({ hasTemplate: () => false, isEmptyHost: () => true })
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({
|
||||
route: 'unserved',
|
||||
reason: 'orcad_unavailable',
|
||||
detail: 'artifacts_unavailable'
|
||||
})
|
||||
expect(d.deploy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries a transient failure on the next connect without recording it', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
convert: async () => {
|
||||
throw new Error('Connection lost')
|
||||
}
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({
|
||||
route: 'unserved',
|
||||
reason: 'failed',
|
||||
detail: 'Connection lost'
|
||||
})
|
||||
expect(d.recordUnavailable).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('names a conflict the server will keep refusing, and the way out, instead of a retry', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
@@ -241,19 +145,6 @@ describe('which server an SSH host runs on connect', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps a staged claim of another desktop’s move a retry, since it expires', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
convert: async () => {
|
||||
throw new Error('orcad_migration_staged_claim_conflict:repository-path:/root/migrepo')
|
||||
}
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toMatchObject({
|
||||
reason: 'failed'
|
||||
})
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('records why when even the stdio bridge cannot reach the deployed server', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
@@ -341,36 +232,5 @@ describe('which server an SSH host runs on connect', () => {
|
||||
})
|
||||
expect(order).toEqual(['start', 'update'])
|
||||
})
|
||||
|
||||
it('keeps the old version serving while terminals run, and retries on a later connect', async () => {
|
||||
const reason = '2 terminals are running on this host.'
|
||||
const d = managed({
|
||||
autoUpdate: async () => ({
|
||||
outcome: 'deferred',
|
||||
code: 'orcad_update_terminals_running',
|
||||
reason
|
||||
})
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({
|
||||
route: 'managed',
|
||||
environmentId: 'env-9',
|
||||
update: { state: 'deferred', detail: reason }
|
||||
})
|
||||
expect(d.recordUpdateFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still connects when the update check itself throws', async () => {
|
||||
const d = managed({
|
||||
autoUpdate: async () => {
|
||||
throw new Error('ssh dropped')
|
||||
}
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({
|
||||
route: 'managed',
|
||||
environmentId: 'env-9',
|
||||
update: { state: 'check-failed', detail: 'ssh dropped' }
|
||||
})
|
||||
expect(d.recordUpdateFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,14 +38,6 @@ function loginShellProbe(shell: string, mode: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function decodePowerShellCommand(command: string): string {
|
||||
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
|
||||
if (!match) {
|
||||
throw new Error(`No encoded PowerShell command found in: ${command}`)
|
||||
}
|
||||
return Buffer.from(match[1]!, 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
describe('resolveRemoteNodePath', () => {
|
||||
beforeEach(() => {
|
||||
execCommandMock.mockReset()
|
||||
@@ -60,46 +52,36 @@ describe('resolveRemoteNodePath', () => {
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
|
||||
'/home/u/.nvm/versions/node/v22.22.0/bin/node'
|
||||
)
|
||||
|
||||
expect(execCommandMock.mock.calls[1]![1]).toContain("PATH='/usr/bin':$PATH npm --version")
|
||||
expect(execCommandMock.mock.calls[2]![1]).toContain(
|
||||
"PATH='/home/u/.nvm/versions/node/v22.22.0/bin':$PATH npm --version"
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'accepts npm elsewhere on PATH without probing another Node candidate',
|
||||
async () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'orca-split-node-npm-'))
|
||||
try {
|
||||
const nodePath = path.join(root, 'selected node', 'bin', 'node')
|
||||
const npmBinDir = path.join(root, 'npm elsewhere', 'bin')
|
||||
mkdirSync(path.dirname(nodePath), { recursive: true })
|
||||
mkdirSync(npmBinDir, { recursive: true })
|
||||
writeFileSync(nodePath, '#!/bin/sh\nprintf "v22.22.0\\n"\n')
|
||||
writeFileSync(path.join(npmBinDir, 'npm'), '#!/bin/sh\nprintf "11.13.0\\n"\n')
|
||||
chmodSync(nodePath, 0o755)
|
||||
chmodSync(path.join(npmBinDir, 'npm'), 0o755)
|
||||
it.runIf(process.platform !== 'win32')('accepts npm elsewhere on PATH', async () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'orca-split-node-npm-'))
|
||||
try {
|
||||
const nodePath = path.join(root, 'selected node', 'bin', 'node')
|
||||
const npmBinDir = path.join(root, 'npm elsewhere', 'bin')
|
||||
mkdirSync(path.dirname(nodePath), { recursive: true })
|
||||
mkdirSync(npmBinDir, { recursive: true })
|
||||
writeFileSync(nodePath, '#!/bin/sh\nprintf "v22.22.0\\n"\n')
|
||||
writeFileSync(path.join(npmBinDir, 'npm'), '#!/bin/sh\nprintf "11.13.0\\n"\n')
|
||||
chmodSync(nodePath, 0o755)
|
||||
chmodSync(path.join(npmBinDir, 'npm'), 0o755)
|
||||
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce(`${nodePath}\n${path.join(root, 'fallback', 'bin', 'node')}\n`)
|
||||
.mockImplementationOnce((_conn: SshConnection, command: string) =>
|
||||
Promise.resolve(
|
||||
execFileSync('/bin/sh', ['-c', command], {
|
||||
encoding: 'utf8',
|
||||
env: { HOME: root, PATH: npmBinDir }
|
||||
})
|
||||
)
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce(`${nodePath}\n${path.join(root, 'fallback', 'bin', 'node')}\n`)
|
||||
.mockImplementationOnce((_conn: SshConnection, command: string) =>
|
||||
Promise.resolve(
|
||||
execFileSync('/bin/sh', ['-c', command], {
|
||||
encoding: 'utf8',
|
||||
env: { HOME: root, PATH: npmBinDir }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe(nodePath)
|
||||
// One inventory exec plus one candidate probe keeps SSH startup work bounded.
|
||||
expect(execCommandMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe(nodePath)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('finds node under a MISE_DATA_DIR exported from a shell dotfile', async () => {
|
||||
execCommandMock
|
||||
@@ -290,14 +272,6 @@ describe('resolveRemoteNodePath', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts Node 18 (the exact minimum) as valid', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/usr/local/bin/node\n')
|
||||
.mockResolvedValueOnce('v18.0.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
|
||||
})
|
||||
|
||||
it('falls back to the login shell when path probes find nothing', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n')
|
||||
@@ -328,37 +302,6 @@ describe('resolveRemoteNodePath', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('respects a non-default $SHELL instead of hardcoding bash', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n')
|
||||
.mockResolvedValueOnce('/usr/bin/fish')
|
||||
.mockImplementationOnce(loginShellAnswer('/opt/homebrew/bin/node\n'))
|
||||
.mockResolvedValueOnce('v22.0.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
expect(execCommandMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
conn,
|
||||
loginShellProbe('/usr/bin/fish', '-lc'),
|
||||
{ wrapCommand: false, timeoutMs: 8_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses /bin/sh when the remote shell expansion falls back to it', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n')
|
||||
.mockResolvedValueOnce('/bin/sh\n')
|
||||
.mockImplementationOnce(loginShellAnswer('/usr/local/bin/node\n'))
|
||||
.mockResolvedValueOnce('v20.0.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
|
||||
expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, loginShellProbe('/bin/sh', '-c'), {
|
||||
wrapCommand: false,
|
||||
timeoutMs: 8_000
|
||||
})
|
||||
})
|
||||
|
||||
it('uses -c (not -lc) for a csh login shell, which rejects combined -lc', async () => {
|
||||
// csh/tcsh require plain -c; -lc/-l can fail with Bad modifier (#8701).
|
||||
execCommandMock
|
||||
@@ -374,25 +317,6 @@ describe('resolveRemoteNodePath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses -c (not -lc) for a tcsh login shell', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n')
|
||||
.mockResolvedValueOnce('/usr/bin/tcsh\n')
|
||||
.mockImplementationOnce(loginShellAnswer('/usr/local/bin/node\n'))
|
||||
.mockResolvedValueOnce('v20.0.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
|
||||
expect(execCommandMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
conn,
|
||||
loginShellProbe('/usr/bin/tcsh', '-c'),
|
||||
{
|
||||
wrapCommand: false,
|
||||
timeoutMs: 8_000
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
@@ -416,73 +340,6 @@ describe('resolveRemoteNodePath', () => {
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('rethrows SSH session-limit errors from candidate version checks in strict mode', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
})
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/usr/local/bin/node\n')
|
||||
.mockRejectedValueOnce(sessionLimitError)
|
||||
|
||||
await expect(
|
||||
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('rethrows SSH session-limit errors from login-shell resolution in strict mode', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
})
|
||||
execCommandMock.mockResolvedValueOnce('\n').mockRejectedValueOnce(sessionLimitError)
|
||||
|
||||
await expect(
|
||||
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
})
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n')
|
||||
.mockResolvedValueOnce('/bin/bash')
|
||||
.mockResolvedValueOnce('\n')
|
||||
.mockRejectedValueOnce(sessionLimitError)
|
||||
|
||||
await expect(
|
||||
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
})
|
||||
execCommandMock.mockRejectedValueOnce(sessionLimitError)
|
||||
|
||||
await expect(
|
||||
resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), {
|
||||
rethrowSessionLimitErrors: true
|
||||
})
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
})
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n')
|
||||
.mockRejectedValueOnce(sessionLimitError)
|
||||
|
||||
await expect(
|
||||
resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), {
|
||||
rethrowSessionLimitErrors: true
|
||||
})
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => {
|
||||
// Shared bootstrap abort must remain cancellation; swallowing it as Node-not-found defeats sequential fallback.
|
||||
const controller = new AbortController()
|
||||
@@ -508,9 +365,6 @@ describe('resolveRemoteNodePath', () => {
|
||||
await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe(
|
||||
'C:/Program Files/nodejs/node.exe'
|
||||
)
|
||||
|
||||
const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string)
|
||||
expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/)
|
||||
})
|
||||
|
||||
describe('strict resolution, which settles rung D only on an answered "no Node"', () => {
|
||||
|
||||
Reference in New Issue
Block a user