mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
test: remove 145 low-value tests and nine suites (#37982)
* test: remove 145 low-value test declarations * test: clean up remaining type-only relay imports --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
assignmentLeaseKeyId,
|
||||
classifyAssignmentLease
|
||||
} from './assignment-lease.js'
|
||||
import { ASSIGNMENT_LEASE_SHADOW_EVENT, AssignmentLeaseShadow } from './assignment-lease-shadow.js'
|
||||
import { AssignmentLeaseShadow } from './assignment-lease-shadow.js'
|
||||
import type { RelayAssignmentStore } from './assignment-store.js'
|
||||
import type { RelayConfig } from '../config.js'
|
||||
import type { RelayCredentialStore } from '../credential-store.js'
|
||||
@@ -61,14 +61,6 @@ const classify = async (value: string | undefined, helloEpoch = 4) =>
|
||||
})
|
||||
|
||||
describe('assignment lease classification', () => {
|
||||
it('names the key by a short hash of it', () => {
|
||||
expect(assignmentLeaseKeyId(KEY)).toMatch(/^k1-[0-9a-f]{8}$/)
|
||||
expect(assignmentLeaseKeyId(KEY)).toBe(
|
||||
`k1-${createHash('sha256').update(KEY).digest('hex').slice(0, 8)}`
|
||||
)
|
||||
expect(assignmentLeaseKeyId(OTHER_KEY)).not.toBe(assignmentLeaseKeyId(KEY))
|
||||
})
|
||||
|
||||
it('sorts every lease into one class', async () => {
|
||||
expect(await classify(undefined)).toBe('absent')
|
||||
expect(await classify(await lease())).toBe('valid')
|
||||
@@ -90,58 +82,6 @@ describe('assignment lease classification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assignment lease shadow', () => {
|
||||
it('does nothing while the switch is off', () => {
|
||||
const shadow = new AssignmentLeaseShadow({ enabled: () => false, key: KEY, cellId: CELL_ID })
|
||||
expect(shadow.check({ lease: undefined, ...HOST, helloEpoch: 4 })).toBeNull()
|
||||
})
|
||||
|
||||
it('counts each class against the database answer and logs one line per window', async () => {
|
||||
let now = 0
|
||||
const log = vi.fn()
|
||||
const shadow = new AssignmentLeaseShadow({
|
||||
enabled: () => true,
|
||||
key: KEY,
|
||||
cellId: CELL_ID,
|
||||
now: () => now,
|
||||
log
|
||||
})
|
||||
const valid = await lease()
|
||||
const record = async (value: string | undefined, dbValid: boolean, dbReadMs: number) => {
|
||||
const check = shadow.check({ lease: value, ...HOST, helloEpoch: 4 })
|
||||
expect(check).not.toBeNull()
|
||||
if (check) shadow.record(check, dbValid, dbReadMs)
|
||||
await check
|
||||
await Promise.resolve()
|
||||
}
|
||||
await record(valid, true, 2)
|
||||
await record(valid, false, 4)
|
||||
await record(undefined, true, 6)
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
now = 60_000
|
||||
await record(valid, true, 8)
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
|
||||
event: ASSIGNMENT_LEASE_SHADOW_EVENT,
|
||||
cellId: CELL_ID,
|
||||
windowMs: 60_000,
|
||||
classes: {
|
||||
agree: 2,
|
||||
disagree: 1,
|
||||
absent: 1,
|
||||
expired: 0,
|
||||
badSignature: 0,
|
||||
wrongHost: 0,
|
||||
wrongCell: 0,
|
||||
epochBehind: 0,
|
||||
epochAhead: 0
|
||||
},
|
||||
dbRefused: 1,
|
||||
dbReadMs: { samples: 4, p50: 4, p99: 8, max: 8 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
readonly OPEN = 1
|
||||
readonly CLOSED = 3
|
||||
@@ -181,7 +121,13 @@ describe('host hello with the lease shadow on', () => {
|
||||
{} as RelayCredentialStore,
|
||||
{ verifyCellAssignment } as unknown as RelayAssignmentStore,
|
||||
new ProcessQueuedByteBudget(),
|
||||
{ recordAuth: vi.fn(), recordForwardedBytes: vi.fn(), recordHttp: vi.fn(), recordReconnect: vi.fn(), recordSql: vi.fn() },
|
||||
{
|
||||
recordAuth: vi.fn(),
|
||||
recordForwardedBytes: vi.fn(),
|
||||
recordHttp: vi.fn(),
|
||||
recordReconnect: vi.fn(),
|
||||
recordSql: vi.fn()
|
||||
},
|
||||
Date.now,
|
||||
Math.random,
|
||||
'incarnation-1',
|
||||
@@ -189,7 +135,13 @@ describe('host hello with the lease shadow on', () => {
|
||||
)
|
||||
const socket = new FakeSocket()
|
||||
const hostLease = input.withLease ? await lease({ relayHostId }) : undefined
|
||||
registry.acceptControl(socket as unknown as WebSocket, identity, undefined, undefined, hostLease)
|
||||
registry.acceptControl(
|
||||
socket as unknown as WebSocket,
|
||||
identity,
|
||||
undefined,
|
||||
undefined,
|
||||
hostLease
|
||||
)
|
||||
socket.emit(
|
||||
'message',
|
||||
Buffer.from(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayHomeCellUnavailableError, type RelayAssignment } from './assignment-store.js'
|
||||
import type { RelayAssignment } from './assignment-store.js'
|
||||
import type { RelayConfig } from '../config.js'
|
||||
|
||||
const fakes = vi.hoisted(() => ({
|
||||
@@ -42,9 +42,9 @@ describe('assignment rejection logging', () => {
|
||||
|
||||
expect(hinted.status).toBe(503)
|
||||
expect(await hinted.json()).toEqual({ error: 'relay_capacity_exhausted' })
|
||||
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
||||
entry.includes('assignment rejected')
|
||||
)
|
||||
const line = warn.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.find((entry) => entry.includes('assignment rejected'))
|
||||
expect(line).toContain('route=assign')
|
||||
expect(line).toContain('lane=sticky')
|
||||
expect(line).toContain('hinted=true')
|
||||
@@ -53,67 +53,6 @@ describe('assignment rejection logging', () => {
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('separates an unavailable home cell from capacity and names its cause', async () => {
|
||||
const host = 'cccccccccccccccc'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => {
|
||||
throw new RelayHomeCellUnavailableError('cell-asia-1', 'not_ready')
|
||||
}),
|
||||
// The sticky lane refuses a host whose home cell is not live, so this
|
||||
// arrives hinted on the placement lane.
|
||||
resolve: vi.fn(async () => null)
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toEqual({ error: 'relay_home_cell_unavailable' })
|
||||
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
||||
entry.includes('assignment rejected')
|
||||
)
|
||||
expect(line).toContain('lane=placement')
|
||||
expect(line).toContain('hinted=true')
|
||||
expect(line).toContain('reason=relay_home_cell_unavailable')
|
||||
expect(line).toContain('cause=not_ready')
|
||||
expect(line).toContain('cell=cell-asia-1')
|
||||
expect(line).not.toContain('relay_capacity_exhausted')
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('logs an unhinted placement rejection without the raw host id', async () => {
|
||||
const host = 'gggggggggggggggg'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => {
|
||||
throw new Error('relay_connection_headroom_exhausted')
|
||||
}),
|
||||
resolve: vi.fn(async () => null)
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/assign', assignmentRequest(host))
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
||||
entry.includes('assignment rejected')
|
||||
)
|
||||
expect(line).toContain('route=assign')
|
||||
expect(line).toContain('lane=placement')
|
||||
expect(line).toContain('hinted=false')
|
||||
expect(line).toContain('reason=relay_connection_headroom_exhausted')
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('names the throttled host once per window and closes it with the suppressed count', async () => {
|
||||
vi.useFakeTimers()
|
||||
const host = 'mmmmmmmmmmmmmmmm'
|
||||
@@ -148,27 +87,6 @@ describe('assignment rejection logging', () => {
|
||||
expect(throttled()[1]).toContain('suppressed=1')
|
||||
expect(throttled()[1]).toContain(`host=${relayHostLogDigest(host)}`)
|
||||
})
|
||||
|
||||
it('stays silent for successful assignments', async () => {
|
||||
const host = 'ssssssssssssssss'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => assignment('cell-r', host)),
|
||||
resolve: vi.fn(async () => null)
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
expect((await app.request('/v1/assign', assignmentRequest(host))).status).toBe(200)
|
||||
expect(
|
||||
warn.mock.calls.map((call) => String(call[0])).filter((entry) =>
|
||||
entry.includes('assignment rejected')
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function assignmentRequest(
|
||||
@@ -224,80 +142,3 @@ function config(overrides: Partial<RelayConfig> = {}): RelayConfig {
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('assignment grant logging', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('logs sticky grants with cell id and host digest only', async () => {
|
||||
const host = 'gggggggggggggggg'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => assignment('cell-r', host)),
|
||||
resolve: vi.fn(async () => assignment('cell-r', host))
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
||||
entry.includes('assignment granted')
|
||||
)
|
||||
expect(line).toContain('lane=sticky')
|
||||
expect(line).toContain('cell=cell-r')
|
||||
expect(line).toContain(`host=${relayHostLogDigest(host)}`)
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('logs a hinted grant served by the placement lane', async () => {
|
||||
const host = 'rrrrrrrrrrrrrrrr'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => assignment('cell-new', host)),
|
||||
resolve: vi.fn(async () => null)
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
||||
entry.includes('assignment granted')
|
||||
)
|
||||
expect(line).toContain('lane=placement')
|
||||
expect(line).toContain('hinted=true')
|
||||
expect(line).toContain('cell=cell-new')
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('does not log unhinted placement grants', async () => {
|
||||
const host = 'pppppppppppppppp'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => assignment('cell-r', host)),
|
||||
resolve: vi.fn(async () => null)
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
expect((await app.request('/v1/assign', assignmentRequest(host))).status).toBe(200)
|
||||
expect(
|
||||
warn.mock.calls.map((call) => String(call[0])).filter((entry) =>
|
||||
entry.includes('assignment granted')
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,11 +34,15 @@ describe('assignment reserve-mode behavior', () => {
|
||||
// A store that has never read the set: the table is unreadable.
|
||||
async function unknownSet(now: () => number) {
|
||||
const unknown = new RelayAssignmentStore(database!, now)
|
||||
await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone')
|
||||
await database!.query(
|
||||
'ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone'
|
||||
)
|
||||
return {
|
||||
unknown,
|
||||
restore: async () =>
|
||||
await database!.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes')
|
||||
await database!.query(
|
||||
'ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,55 +65,6 @@ describe('assignment reserve-mode behavior', () => {
|
||||
expect(await store.releaseExpiredActivityLeases()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('logs an unreadable reserve-mode set at most once a minute', async () => {
|
||||
let now = 1_000
|
||||
await setup(() => now)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const clock = vi.spyOn(performance, 'now')
|
||||
let at = 1_000_000
|
||||
clock.mockImplementation(() => at)
|
||||
const { unknown, restore } = await unknownSet(() => now)
|
||||
try {
|
||||
for (let read = 0; read < 3; read += 1) {
|
||||
expect(await unknown.reserveModeCells()).toBeNull()
|
||||
at += 10_000
|
||||
}
|
||||
at += 60_000
|
||||
expect(await unknown.reserveModeCells()).toBeNull()
|
||||
const lines = warn.mock.calls.filter(([line]) => String(line).includes('orca_relay_reserve_mode_read_failed'))
|
||||
expect(lines).toHaveLength(2)
|
||||
} finally {
|
||||
await restore()
|
||||
clock.mockRestore()
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('tells break glass whether a cell is still heartbeating', async () => {
|
||||
let now = 1_000
|
||||
const store = await setup(() => now)
|
||||
expect(await store.cellHeartbeatFresh('cell-a')).toBe(false)
|
||||
await store.reconcileCells([{ ...CELLS[0]!, connectionHardCap: 600, connectionUnobservedBound: 50 }, CELLS[1]!])
|
||||
await store.recordCellHeartbeat({
|
||||
cellId: 'cell-a',
|
||||
cellUrl: CELLS[0]!.url,
|
||||
cellIncarnation: '11111111-1111-4111-8111-111111111111',
|
||||
startedAt: 50,
|
||||
ready: false,
|
||||
observedRequests: 0,
|
||||
totalConnections: 0,
|
||||
inFlightConnections: 0,
|
||||
reservedConnectionUnits: 0,
|
||||
enforcedConnectionUnits: 0,
|
||||
connectionHardCap: 600,
|
||||
connectionUnobservedBound: 50
|
||||
})
|
||||
// Not ready is still alive: break glass is for a cell that has gone silent.
|
||||
expect(await store.cellHeartbeatFresh('cell-a')).toBe(true)
|
||||
now += 45_001
|
||||
expect(await store.cellHeartbeatFresh('cell-a')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an idle host sticky on a reserve-mode cell where today it would be re-placed', async () => {
|
||||
let now = 1_000
|
||||
const store = await setup(() => now)
|
||||
@@ -136,8 +91,13 @@ describe('assignment reserve-mode behavior', () => {
|
||||
await setReserve(store, [first.cellId])
|
||||
// The reserve ledger stamps last_activity_at and the cell keeps no activity rows.
|
||||
now += 5 * 60_000
|
||||
await database!.query('DELETE FROM relay_assignment_activity_leases WHERE user_id = ?', [identity.userId])
|
||||
await database!.query('UPDATE relay_assignments SET last_activity_at = ? WHERE user_id = ?', [now, identity.userId])
|
||||
await database!.query('DELETE FROM relay_assignment_activity_leases WHERE user_id = ?', [
|
||||
identity.userId
|
||||
])
|
||||
await database!.query('UPDATE relay_assignments SET last_activity_at = ? WHERE user_id = ?', [
|
||||
now,
|
||||
identity.userId
|
||||
])
|
||||
await store.setCellAdmissionState(first.cellId, 'existing-only')
|
||||
now += 2 * 60_000
|
||||
expect(await store.assign(identity)).toMatchObject({
|
||||
@@ -221,12 +181,16 @@ describe('assignment reserve-mode behavior', () => {
|
||||
await store.changeActivity(identity, 'control', -1)
|
||||
now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1
|
||||
expect([...((await store.reserveModeCells()) ?? [])]).toEqual([])
|
||||
await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone')
|
||||
await database!.query(
|
||||
'ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone'
|
||||
)
|
||||
at += 61_000
|
||||
expect(await store.reserveModeCells()).toBeNull()
|
||||
expect((await store.assign(identity)).assignmentEpoch).toBeGreaterThan(first.assignmentEpoch)
|
||||
} finally {
|
||||
await database!.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes').catch(() => undefined)
|
||||
await database!
|
||||
.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes')
|
||||
.catch(() => undefined)
|
||||
clock.mockRestore()
|
||||
}
|
||||
})
|
||||
@@ -277,11 +241,16 @@ describe('assignment reserve-mode behavior', () => {
|
||||
source_request_units, target_reserved_units, expires_at, created_at, updated_at)
|
||||
VALUES ('user-a', 'host000000000001', 'cell-b', 'cell-a', 1, 2, 0, 1, 9999, 1000, 1000)`
|
||||
)
|
||||
await expect(store.setCellAdmitMode('cell-a', 'reserve')).rejects.toThrow('cell_has_open_migration')
|
||||
await expect(store.setCellAdmitMode('cell-a', 'reserve')).rejects.toThrow(
|
||||
'cell_has_open_migration'
|
||||
)
|
||||
expect(await store.cellAdmitMode('cell-a')).toMatchObject({ admitMode: 'db' })
|
||||
await store.setCellAdmitMode('cell-a', 'db')
|
||||
await database!.query(`UPDATE relay_assignment_migrations SET aborted_at = 1001`)
|
||||
await store.setCellAdmitMode('cell-a', 'reserve')
|
||||
expect(await store.cellAdmitMode('cell-a')).toMatchObject({ admitMode: 'reserve', updatedAt: 1_000 })
|
||||
expect(await store.cellAdmitMode('cell-a')).toMatchObject({
|
||||
admitMode: 'reserve',
|
||||
updatedAt: 1_000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type WebSocket from 'ws'
|
||||
import { z } from 'zod'
|
||||
import type { RelayAssignmentStore } from './assignment/assignment-store.js'
|
||||
import { CELL_FLAG_DEFAULTS, type CellFlags } from './cell-flags.js'
|
||||
import type { CellFlags } from './cell-flags.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import type { RelayCredentialStore } from './credential-store.js'
|
||||
import { HostSessionRegistry, type HostSession } from './host-session-registry.js'
|
||||
@@ -227,26 +227,6 @@ describe('cell seat feed', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('answers a first poll with every seated host, matching the controls count', async () => {
|
||||
const { connect, poll, read } = createCell()
|
||||
await connect(1)
|
||||
await connect(2, { epoch: 4 })
|
||||
expect((await poll()).status).toBe(200)
|
||||
const body = await read()
|
||||
expect(body).toMatchObject({
|
||||
v: 1,
|
||||
cellId: 'production-gce-c7',
|
||||
incarnation,
|
||||
seq: 2,
|
||||
draining: false,
|
||||
counts: { controls: 2, seats: 2 }
|
||||
})
|
||||
expect(body.full).toHaveLength(body.counts.seats)
|
||||
expect(body.full).toContainEqual(
|
||||
expect.objectContaining({ userId: 'user-2', epoch: 4, generation: 1, state: 'active' })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a closing socket seated until its close lands, as the change log does', async () => {
|
||||
const { connect, read } = createCell()
|
||||
const socket = await connect(1)
|
||||
@@ -270,10 +250,7 @@ describe('cell seat feed', () => {
|
||||
const body = await read(`${incarnation}:${seq}`)
|
||||
expect(body.full).toBeUndefined()
|
||||
expect(body.draining).toBe(true)
|
||||
expect(body.changes?.map((change) => change.kind)).toEqual([
|
||||
'drain-only',
|
||||
'leave'
|
||||
])
|
||||
expect(body.changes?.map((change) => change.kind)).toEqual(['drain-only', 'leave'])
|
||||
expect(body.changes?.[1]).toMatchObject({
|
||||
userId: 'user-1',
|
||||
closeCode: RELAY_CLOSE_CODE.DRAINING,
|
||||
@@ -322,9 +299,7 @@ describe('cell seat feed', () => {
|
||||
await connect(1, { generation: 2 })
|
||||
const body = await read(`${incarnation}:${seq}`)
|
||||
// Readers drop the leave: its generation is older than the seat's.
|
||||
expect(
|
||||
body.changes?.map((change) => [change.kind, change.generation])
|
||||
).toEqual([
|
||||
expect(body.changes?.map((change) => [change.kind, change.generation])).toEqual([
|
||||
['leave', 1],
|
||||
['join', 2]
|
||||
])
|
||||
@@ -338,25 +313,6 @@ describe('cell seat feed', () => {
|
||||
expect((await poll('not a cursor')).status).toBe(400)
|
||||
})
|
||||
|
||||
it('reports the applied cell flags, so a flip can be read back through the feed and runtime status', async () => {
|
||||
let applied: AppliedControlFlags<CellFlags> = { generation: 0, flags: CELL_FLAG_DEFAULTS }
|
||||
const { read, app } = createCell(config(), () => applied)
|
||||
expect((await read()).flagsApplied).toEqual({ generation: 0, flags: CELL_FLAG_DEFAULTS })
|
||||
applied = { generation: 12, flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true, ticketCheck: 'shadow' } }
|
||||
expect((await read()).flagsApplied).toEqual(applied)
|
||||
const runtime = await app.request('/v1/admin/runtime-status', {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer deploy-token', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ v: 1 })
|
||||
})
|
||||
expect(await runtime.json()).toMatchObject({
|
||||
flagsApplied: applied,
|
||||
// Not reserve mode, so db whatever the switch says.
|
||||
phonePathEffective: 'db',
|
||||
databasePoolMax: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts only the directors rehome identity, verified once per poll', async () => {
|
||||
const { poll } = createCell()
|
||||
adminTokenChecks.count = 0
|
||||
@@ -377,24 +333,4 @@ describe('cell seat feed', () => {
|
||||
const director = createCell(config({ role: 'director', cellId: 'director' }))
|
||||
expect((await director.poll()).status).toBe(404)
|
||||
})
|
||||
|
||||
// Review B1: the feed must not need the pair on more cells, and must not change what a cell
|
||||
// with or without it reports as its rehome protocol.
|
||||
it('leaves the reported rehome protocol to the existing env', async () => {
|
||||
const rehomeProtocol = async (relayConfig: RelayConfig) => {
|
||||
const response = await createCell(relayConfig).app.request('/v1/admin/runtime-status', {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer deploy-token', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ v: 1 })
|
||||
})
|
||||
return z.object({ regionalRehomeProtocol: z.number() }).parse(await response.json())
|
||||
.regionalRehomeProtocol
|
||||
}
|
||||
expect(await rehomeProtocol(config())).toBe(3)
|
||||
expect(
|
||||
await rehomeProtocol(
|
||||
config({ rehomeAudience: undefined, rehomeDirectorServiceAccount: undefined })
|
||||
)
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,11 @@ import type { CellReserveClient } from './cell-reserve-client.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
|
||||
import { ReserveAssignment, type ReservePlacementMode } from './reserve-assignment.js'
|
||||
import { ReservePlacer } from './reserve-placement.js'
|
||||
import { ShadowSeatDirectory, type SeatFeedCell, type SeatFeedResponse } from './shadow-seat-directory.js'
|
||||
import {
|
||||
ShadowSeatDirectory,
|
||||
type SeatFeedCell,
|
||||
type SeatFeedResponse
|
||||
} from './shadow-seat-directory.js'
|
||||
|
||||
const INCARNATION = '11111111-1111-4111-8111-111111111111'
|
||||
const CELLS = [
|
||||
@@ -66,7 +70,9 @@ describe('reserve-aware cell status', () => {
|
||||
database = undefined
|
||||
})
|
||||
|
||||
async function setup(options: { mode?: ReservePlacementMode; startedAt?: number; placement?: boolean } = {}) {
|
||||
async function setup(
|
||||
options: { mode?: ReservePlacementMode; startedAt?: number; placement?: boolean } = {}
|
||||
) {
|
||||
const now = { value: 100_000 }
|
||||
database = await openInMemoryRelayDatabase()
|
||||
const store = new RelayAssignmentStore(database, () => now.value, { heartbeatTtlMs: 45_000 })
|
||||
@@ -98,7 +104,10 @@ describe('reserve-aware cell status', () => {
|
||||
directory,
|
||||
cells: () => cells,
|
||||
startedAt: options.startedAt ?? 0,
|
||||
placer: new ReservePlacer(() => now.value, () => 0.25),
|
||||
placer: new ReservePlacer(
|
||||
() => now.value,
|
||||
() => 0.25
|
||||
),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status route never books or demotes.
|
||||
client: {} as CellReserveClient,
|
||||
readRow: async () => null,
|
||||
@@ -130,12 +139,19 @@ describe('reserve-aware cell status', () => {
|
||||
const reserve = await store.reserveAwareCellDeploymentStatus('cell-a')
|
||||
expect(reserve).toMatchObject({
|
||||
admitMode: 'reserve',
|
||||
status: { cellId: 'cell-a', runtime: { cellIncarnation: INCARNATION, ready: true, heartbeatFresh: true } }
|
||||
status: {
|
||||
cellId: 'cell-a',
|
||||
runtime: { cellIncarnation: INCARNATION, ready: true, heartbeatFresh: true }
|
||||
}
|
||||
})
|
||||
expect(Object.keys(reserve.status).filter((key) => PER_HOST_FIELDS.includes(key))).toEqual([])
|
||||
await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone')
|
||||
await database!.query(
|
||||
'ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone'
|
||||
)
|
||||
const unknown = new RelayAssignmentStore(database!, () => 100_000)
|
||||
await expect(unknown.reserveAwareCellDeploymentStatus('cell-b')).rejects.toThrow('reserve_mode_unknown')
|
||||
await expect(unknown.reserveAwareCellDeploymentStatus('cell-b')).rejects.toThrow(
|
||||
'reserve_mode_unknown'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the 404 for a reserve cell without the opt-in, and 400s an unknown view', async () => {
|
||||
@@ -146,18 +162,15 @@ describe('reserve-aware cell status', () => {
|
||||
expect((await status({ cellId: 'cell-a', view: 'all' })).status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers a database cell with the opt-in as today, plus reserve: null', async () => {
|
||||
const { status } = await setup()
|
||||
const today = await statusBody(await status({ cellId: 'cell-b' }))
|
||||
const response = await status({ cellId: 'cell-b', view: 'reserve-aware' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ ...today, status: { ...today.status, reserve: null } })
|
||||
})
|
||||
|
||||
it('answers a reserve cell from the map, with perHost null and never zeros', async () => {
|
||||
const { directory, now, status } = await setup()
|
||||
directory.apply('cell-a', feed('cell-a', { draining: true }), now.value, now.value - 1_500)
|
||||
directory.apply('cell-b', feed('cell-b', { admitModeEffective: 'db', admitModeRaw: 'db' }), now.value, now.value)
|
||||
directory.apply(
|
||||
'cell-b',
|
||||
feed('cell-b', { admitModeEffective: 'db', admitModeRaw: 'db' }),
|
||||
now.value,
|
||||
now.value
|
||||
)
|
||||
now.value += 20_000
|
||||
const response = await status({ cellId: 'cell-a', view: 'reserve-aware' })
|
||||
expect(response.status).toBe(200)
|
||||
@@ -233,19 +246,25 @@ describe('reserve-aware cell status', () => {
|
||||
// A starting director with one cell unheard.
|
||||
const starting = await setup({ startedAt: 99_000 })
|
||||
starting.directory.apply('cell-a', feed('cell-a'), starting.now.value, starting.now.value)
|
||||
expect(await unavailable(await starting.status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('map-incomplete')
|
||||
)
|
||||
expect(
|
||||
await unavailable(await starting.status({ cellId: 'cell-a', view: 'reserve-aware' }))
|
||||
).toEqual(expected('map-incomplete'))
|
||||
await database?.close()
|
||||
const { directory, now, status } = await setup()
|
||||
directory.apply('cell-b', feed('cell-b'), now.value, now.value)
|
||||
directory.markNoFeed('cell-a', now.value)
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(expected('no-feed'))
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('no-feed')
|
||||
)
|
||||
// A feed without the drain flag or counts is an older image: no feed, never an empty cell.
|
||||
directory.apply('cell-a', feed('cell-a', { draining: undefined }), now.value, now.value)
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(expected('no-feed'))
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('no-feed')
|
||||
)
|
||||
directory.apply('cell-a', feed('cell-a', { counts: undefined }), now.value, now.value)
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(expected('no-feed'))
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('no-feed')
|
||||
)
|
||||
// The process that answered the feed is not the one heartbeating the runtime row.
|
||||
directory.apply(
|
||||
'cell-a',
|
||||
@@ -257,41 +276,6 @@ describe('reserve-aware cell status', () => {
|
||||
expected('incarnation-mismatch')
|
||||
)
|
||||
})
|
||||
|
||||
it('probes rehome trust on a reserve cell from its runtime rows', async () => {
|
||||
const { store } = await setup()
|
||||
await database!.query(
|
||||
`INSERT INTO relay_cell_capabilities (cell_id, cell_incarnation, regional_rehome_protocol, last_heartbeat_at)
|
||||
VALUES ('cell-a', ?, 1, 100000)`,
|
||||
[INCARNATION]
|
||||
)
|
||||
const sourceFetch = vi.fn(async () =>
|
||||
Response.json({ v: 1, outcome: 'host-not-connected', sharedRuntimeIdentityRejected: true })
|
||||
)
|
||||
const app = createRelayApp(
|
||||
config({
|
||||
rehomeDirectorServiceAccount: 'relay-director@example.test',
|
||||
rehomeAudience: 'https://relay.example.test/v1/admin/host-drain'
|
||||
}),
|
||||
{
|
||||
store: {} as never,
|
||||
assignments: store,
|
||||
drain: vi.fn(),
|
||||
regionalRehomeIdentityToken: vi.fn(async () => 'rehome-token'),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the probe only awaits a Response.
|
||||
regionalRehomeFetch: sourceFetch as unknown as typeof fetch,
|
||||
ready: vi.fn(async () => true)
|
||||
}
|
||||
)
|
||||
const response = await app.request('/v1/admin/regional-rehome-trust-probe', {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer deploy-token', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ v: 1, sourceCellId: 'cell-a', sourceCellIncarnation: INCARNATION })
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ proven: true })
|
||||
expect(sourceFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
async function statusBody(response: Response) {
|
||||
|
||||
@@ -2,11 +2,7 @@ import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RelayAssignmentStore } from './assignment/assignment-store.js'
|
||||
import type { ControlRenewalOutcome } from './control-renewal-statement.js'
|
||||
import {
|
||||
openInMemoryRelayDatabase,
|
||||
type RelayDatabase,
|
||||
type SqlRow
|
||||
} from './database.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from './database.js'
|
||||
|
||||
const now = 1_900_000_000_000
|
||||
const expiresAt = now + 105_000
|
||||
@@ -57,53 +53,6 @@ class RenewalStatementProbe implements RelayDatabase {
|
||||
}
|
||||
|
||||
describe('batched control renewals on PostgreSQL', () => {
|
||||
it('spends one statement on every host that came due', async () => {
|
||||
const probe = new RenewalStatementProbe(() => 'renewed')
|
||||
const store = new RelayAssignmentStore(probe, () => now)
|
||||
|
||||
const outcomes = await store.renewControlActivities([
|
||||
renewal('user-a', 'host000000000001'),
|
||||
renewal('user-a', 'host000000000002'),
|
||||
renewal('user-b', 'host000000000003')
|
||||
])
|
||||
|
||||
expect(outcomes).toEqual(['renewed', 'renewed', 'renewed'])
|
||||
expect(probe.statements).toHaveLength(1)
|
||||
expect(probe.statements[0]!.params[0]).toEqual(['user-a', 'user-a', 'user-b'])
|
||||
expect(probe.statements[0]!.params[4]).toEqual([expiresAt, expiresAt, expiresAt])
|
||||
})
|
||||
|
||||
it('locks assignment rows in primary-key order and still answers in input order', async () => {
|
||||
const probe = new RenewalStatementProbe((userId) =>
|
||||
userId === 'user-b' ? 'control_activity_moved' : 'renewed'
|
||||
)
|
||||
const store = new RelayAssignmentStore(probe, () => now)
|
||||
|
||||
const outcomes = await store.renewControlActivities([
|
||||
renewal('user-c', 'host000000000003'),
|
||||
renewal('user-a', 'host000000000002'),
|
||||
renewal('user-b', 'host000000000001'),
|
||||
renewal('user-a', 'host000000000001')
|
||||
])
|
||||
|
||||
// (user_id, relay_host_id) is the primary key of relay_assignments, and the
|
||||
// statement's ORDER BY repeats it: no batch can queue against another in a
|
||||
// different sequence.
|
||||
expect(probe.statements[0]!.params[0]).toEqual(['user-a', 'user-a', 'user-b', 'user-c'])
|
||||
expect(probe.statements[0]!.params[1]).toEqual([
|
||||
'host000000000001',
|
||||
'host000000000002',
|
||||
'host000000000001',
|
||||
'host000000000003'
|
||||
])
|
||||
expect(outcomes).toEqual([
|
||||
'renewed',
|
||||
'renewed',
|
||||
'control_activity_moved',
|
||||
'renewed'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a malformed request out of the statement and fails only that row', async () => {
|
||||
const probe = new RenewalStatementProbe(() => 'renewed')
|
||||
const store = new RelayAssignmentStore(probe, () => now)
|
||||
@@ -148,39 +97,6 @@ describe('batched control renewals on PostgreSQL', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs the batch on the priority lane and the per-host fallback on the general one', async () => {
|
||||
const probe = new RenewalStatementProbe(() => 'renewed')
|
||||
probe.failuresRemaining = 1
|
||||
const lanes: string[] = []
|
||||
const database: RelayDatabase = {
|
||||
dialect: 'postgres',
|
||||
query: async (sql, params) => {
|
||||
lanes.push('general')
|
||||
return await probe.query(sql, params)
|
||||
},
|
||||
queryPriority: async (sql, params) => {
|
||||
lanes.push('priority')
|
||||
return await probe.query(sql, params)
|
||||
},
|
||||
queryLocked: async () => await probe.queryLocked(),
|
||||
transaction: async () => await probe.transaction(),
|
||||
close: async () => undefined
|
||||
}
|
||||
const store = new RelayAssignmentStore(database, () => now)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
await store.renewControlActivities([
|
||||
renewal('user-a', 'host000000000001'),
|
||||
renewal('user-a', 'host000000000002')
|
||||
])
|
||||
|
||||
// A 200-row fallback would otherwise jump the whole general queue.
|
||||
expect(lanes).toEqual(['priority', 'general', 'general'])
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fan out per host when the batch never got a connection', async () => {
|
||||
const probe = new RenewalStatementProbe(() => 'renewed')
|
||||
probe.failuresRemaining = 1
|
||||
@@ -216,59 +132,6 @@ describe('batched control renewals on PostgreSQL', () => {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts a lone renewal that threw before rethrowing it', async () => {
|
||||
const probe = new RenewalStatementProbe(() => 'renewed')
|
||||
probe.failuresRemaining = 1
|
||||
const recordControlRenewal = vi.fn()
|
||||
const store = new RelayAssignmentStore(probe, () => now, { recordControlRenewal })
|
||||
|
||||
// A one-row flush keeps the pre-batch contract and rethrows, but the metric
|
||||
// still owes an outcome for the attempt.
|
||||
await expect(
|
||||
store.renewControlActivities([renewal('user-a', 'host000000000001')])
|
||||
).rejects.toThrow('statement timeout')
|
||||
|
||||
expect(recordControlRenewal).toHaveBeenCalledTimes(1)
|
||||
expect(recordControlRenewal.mock.calls[0]![1]).toBe('database_error')
|
||||
})
|
||||
|
||||
it('counts a batch in which every row was rejected before the statement', async () => {
|
||||
const probe = new RenewalStatementProbe(() => 'renewed')
|
||||
const recordControlRenewal = vi.fn()
|
||||
const store = new RelayAssignmentStore(probe, () => now, { recordControlRenewal })
|
||||
|
||||
const outcomes = await store.renewControlActivities([
|
||||
{ ...renewal('user-a', 'host000000000001'), activityId: '' },
|
||||
renewal('user-a', 'host000000000002', now - 1)
|
||||
])
|
||||
|
||||
expect(outcomes).toEqual(['invalid_activity_id', 'invalid_activity_expiry'])
|
||||
expect(probe.statements).toHaveLength(0)
|
||||
expect(recordControlRenewal.mock.calls.map((call) => call[1])).toEqual([
|
||||
'invalid_activity_id',
|
||||
'invalid_activity_expiry'
|
||||
])
|
||||
})
|
||||
|
||||
it('counts one renewal outcome metric per row', async () => {
|
||||
const probe = new RenewalStatementProbe((userId) =>
|
||||
userId === 'user-b' ? 'assignment_not_found' : 'renewed'
|
||||
)
|
||||
const recordControlRenewal = vi.fn()
|
||||
const store = new RelayAssignmentStore(probe, () => now, { recordControlRenewal })
|
||||
|
||||
await store.renewControlActivities([
|
||||
renewal('user-a', 'host000000000001'),
|
||||
renewal('user-b', 'host000000000002')
|
||||
])
|
||||
|
||||
expect(recordControlRenewal).toHaveBeenCalledTimes(2)
|
||||
expect(recordControlRenewal.mock.calls.map((call) => call[1])).toEqual([
|
||||
'renewed',
|
||||
'assignment_not_found'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('batched control renewals on SQLite', () => {
|
||||
|
||||
@@ -210,20 +210,6 @@ describe('host pre-auth latency', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('records a hello the database refuses on the db path', async () => {
|
||||
const h = setup({ admitMode: 'db', databaseSays: false })
|
||||
const { keyPair, identity } = hostKey()
|
||||
const socket = new FakeSocket()
|
||||
h.registry.acceptControl(socket.asWebSocket(), identity)
|
||||
h.advance(120)
|
||||
sendHello(socket, keyPair, identity.relayHostId)
|
||||
await vi.waitFor(() => expect(socket.close).toHaveBeenCalled())
|
||||
|
||||
expect(socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch')
|
||||
expect(h.take).not.toHaveBeenCalled()
|
||||
expect(h.recordHostPreAuth).toHaveBeenCalledExactlyOnceWith('db', 'refused', 120)
|
||||
})
|
||||
|
||||
it('records a hello timeout as a refusal before any check, once', () => {
|
||||
vi.useFakeTimers()
|
||||
const h = setup({ admitMode: 'db', databaseSays: true })
|
||||
@@ -240,17 +226,6 @@ describe('host pre-auth latency', () => {
|
||||
expect(h.recordHostPreAuth).toHaveBeenCalledExactlyOnceWith('beforeCheck', 'refused', 2_000)
|
||||
})
|
||||
|
||||
it('records a host that hangs up before its hello as abandoned', () => {
|
||||
const h = setup({ admitMode: 'db', databaseSays: true })
|
||||
const { identity } = hostKey()
|
||||
const socket = new FakeSocket()
|
||||
h.registry.acceptControl(socket.asWebSocket(), identity)
|
||||
h.advance(300)
|
||||
socket.close()
|
||||
|
||||
expect(h.recordHostPreAuth).toHaveBeenCalledExactlyOnceWith('beforeCheck', 'abandoned', 300)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'records a database answer (%s) that arrives after the host hung up as abandoned',
|
||||
async (valid) => {
|
||||
@@ -270,33 +245,6 @@ describe('host pre-auth latency', () => {
|
||||
expect(h.recordHostPreAuth).toHaveBeenCalledExactlyOnceWith('db', 'abandoned', 900)
|
||||
}
|
||||
)
|
||||
|
||||
it('records a host that hangs up during its proof as abandoned on its path', async () => {
|
||||
const h = setup({ admitMode: 'reserve', databaseSays: false })
|
||||
const { keyPair, identity } = hostKey()
|
||||
const socket = new FakeSocket()
|
||||
h.registry.acceptControl(socket.asWebSocket(), identity)
|
||||
sendHello(socket, keyPair, identity.relayHostId)
|
||||
await vi.waitFor(() => expect(socket.send).toHaveBeenCalledTimes(1))
|
||||
h.advance(700)
|
||||
socket.close()
|
||||
|
||||
expect(h.recordHostPreAuth).toHaveBeenCalledExactlyOnceWith('reserve', 'abandoned', 700)
|
||||
})
|
||||
|
||||
it('records the pool-pressure shed as a refusal before any check', async () => {
|
||||
const h = setup({ admitMode: 'reserve', databaseSays: true, databaseShedding: true })
|
||||
h.take.mockReturnValue(null)
|
||||
const { keyPair, identity } = hostKey()
|
||||
const socket = new FakeSocket()
|
||||
h.registry.acceptControl(socket.asWebSocket(), identity)
|
||||
h.advance(40)
|
||||
sendHello(socket, keyPair, identity.relayHostId)
|
||||
await vi.waitFor(() => expect(socket.close).toHaveBeenCalled())
|
||||
|
||||
expect(h.verifyCellAssignment).not.toHaveBeenCalled()
|
||||
expect(h.recordHostPreAuth).toHaveBeenCalledExactlyOnceWith('beforeCheck', 'refused', 40)
|
||||
})
|
||||
})
|
||||
|
||||
function config(): RelayConfig {
|
||||
|
||||
@@ -127,41 +127,6 @@ describe('POST /v1/admin/host-whereabouts', () => {
|
||||
expect(capacity.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns the database row, the map seats, the verdict and the named cells', async () => {
|
||||
const lookup = vi.fn(async () => ROW)
|
||||
const app = appWith(lookup, { shadowSeats: directory() })
|
||||
|
||||
const response = await app.request(
|
||||
'/v1/admin/host-whereabouts',
|
||||
request({ v: 1, relayHostId: HOST, userId: 'user-a' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(lookup).toHaveBeenCalledWith({ userId: 'user-a', relayHostId: HOST })
|
||||
expect(await response.json()).toMatchObject({
|
||||
v: 1,
|
||||
directorIncarnation: 'director-incarnation',
|
||||
relayHostId: HOST,
|
||||
shadow: { state: 'on', complete: true },
|
||||
users: [
|
||||
{
|
||||
userId: 'user-a',
|
||||
database: ROW,
|
||||
seats: [{ cellId: 'cell-a', epoch: 3, state: 'active' }],
|
||||
recentlyLeft: [],
|
||||
verdict: { class: 'agree', explained: true }
|
||||
}
|
||||
],
|
||||
cells: {
|
||||
'cell-a': {
|
||||
status: 'live',
|
||||
lastFailure: null,
|
||||
flagsApplied: { generation: '7', flags: { readinessLocal: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('names the user from the map when the request has only a host id', async () => {
|
||||
const lookup = vi.fn(async () => ROW)
|
||||
const app = appWith(lookup, { shadowSeats: directory() })
|
||||
@@ -196,44 +161,6 @@ describe('POST /v1/admin/host-whereabouts', () => {
|
||||
users: [{ database: ROW, verdict: { class: 'map-only-cell-unlive', explained: true } }]
|
||||
})
|
||||
})
|
||||
|
||||
it('answers an unknown host with an empty result, not a 404', async () => {
|
||||
const lookup = vi.fn(async () => null)
|
||||
const shadowOff = appWith(lookup)
|
||||
const unknown = await shadowOff.request(
|
||||
'/v1/admin/host-whereabouts',
|
||||
request({ v: 1, relayHostId: 'uuuuuuuuuuuuuuuu' })
|
||||
)
|
||||
expect(unknown.status).toBe(200)
|
||||
expect(await unknown.json()).toEqual({
|
||||
v: 1,
|
||||
directorIncarnation: 'director-incarnation',
|
||||
relayHostId: 'uuuuuuuuuuuuuuuu',
|
||||
shadow: { state: 'off' },
|
||||
users: [],
|
||||
cells: {}
|
||||
})
|
||||
expect(lookup).not.toHaveBeenCalled()
|
||||
|
||||
const named = await shadowOff.request(
|
||||
'/v1/admin/host-whereabouts',
|
||||
request({ v: 1, relayHostId: 'uuuuuuuuuuuuuuuu', userId: 'user-z' })
|
||||
)
|
||||
expect(await named.json()).toMatchObject({
|
||||
users: [{ userId: 'user-z', database: null, seats: [], recentlyLeft: [], verdict: null }]
|
||||
})
|
||||
})
|
||||
|
||||
it('answers a briefly unreachable database with a retryable 503', async () => {
|
||||
const app = appWith(async () => {
|
||||
throw new Error('Connection terminated due to connection timeout')
|
||||
})
|
||||
const response = await app.request(
|
||||
'/v1/admin/host-whereabouts',
|
||||
request({ v: 1, relayHostId: HOST, userId: 'user-a' })
|
||||
)
|
||||
expect(response.status).toBe(503)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RelayAssignmentStore.hostWhereabouts', () => {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PLACEMENT_INTAKE_FLOOR,
|
||||
PLACEMENT_INTAKE_SHARE,
|
||||
PLACEMENT_INTAKE_WINDOW_MS,
|
||||
PlacementLoadBand
|
||||
} from './placement-load-band.js'
|
||||
import { PLACEMENT_INTAKE_SHARE, PlacementLoadBand } from './placement-load-band.js'
|
||||
import { RelayAssignmentStore } from './assignment/assignment-store.js'
|
||||
import { openInMemoryRelayDatabase } from './database.js'
|
||||
|
||||
@@ -36,8 +31,7 @@ function simulate(
|
||||
.filter((cell) => cell.reserved < cell.capacity)
|
||||
.map((cell) => ({ cellId: cell.cellId, loadRatio: cell.reserved / cell.capacity, cell }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.loadRatio - right.loadRatio || left.cellId.localeCompare(right.cellId)
|
||||
(left, right) => left.loadRatio - right.loadRatio || left.cellId.localeCompare(right.cellId)
|
||||
)
|
||||
const director = directors[Math.floor(route() * directors.length)]
|
||||
const picked = director?.pick(ranked, at(index))
|
||||
@@ -73,26 +67,6 @@ function drainShare(paceMs: number, seed: number) {
|
||||
}
|
||||
|
||||
describe('PlacementLoadBand', () => {
|
||||
it('spreads a drain over an empty cell and four fuller cells within one window', () => {
|
||||
const cells: SimulatedCell[] = [
|
||||
{ cellId: 'cell-empty', reserved: 0, capacity: 6000 },
|
||||
...['cell-b', 'cell-c', 'cell-d', 'cell-e'].map((cellId) => ({
|
||||
cellId,
|
||||
reserved: 2100,
|
||||
capacity: 6000
|
||||
}))
|
||||
]
|
||||
const director = new PlacementLoadBand(seededRandom(7), quiet)
|
||||
const counts = simulate([director], cells, 2000, () => 1_000)
|
||||
|
||||
expect([...counts.values()].reduce((sum, count) => sum + count, 0)).toBe(2000)
|
||||
expect(counts.get('cell-empty')).toBeLessThanOrEqual(PLACEMENT_INTAKE_SHARE * 2000 + 1)
|
||||
for (const cellId of ['cell-b', 'cell-c', 'cell-d', 'cell-e']) {
|
||||
expect(counts.get(cellId)).toBeGreaterThan(200)
|
||||
}
|
||||
for (const cell of cells) expect(cell.reserved).toBeLessThanOrEqual(cell.capacity)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1,200 s', 1_200_000],
|
||||
['300 s', 300_000]
|
||||
@@ -105,74 +79,6 @@ describe('PlacementLoadBand', () => {
|
||||
for (const cell of cells) expect(cell.reserved).toBeLessThanOrEqual(cell.capacity)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives the empty cell about 60% when only one other cell is eligible', () => {
|
||||
const cells: SimulatedCell[] = [
|
||||
{ cellId: 'cell-empty', reserved: 0, capacity: 6000 },
|
||||
{ cellId: 'cell-b', reserved: 2100, capacity: 6000 }
|
||||
]
|
||||
const counts = simulate([new PlacementLoadBand(seededRandom(4), quiet)], cells, 1000, () => 1)
|
||||
const share = (counts.get('cell-empty') ?? 0) / 1000
|
||||
expect(share).toBeGreaterThan(0.55)
|
||||
expect(share).toBeLessThan(0.65)
|
||||
})
|
||||
|
||||
it('logs one summary a minute with skips, fallbacks and band size', () => {
|
||||
const lines: Record<string, unknown>[] = []
|
||||
const band = new PlacementLoadBand(seededRandom(6), (line) => lines.push(JSON.parse(line)))
|
||||
const ranked = [
|
||||
{ cellId: 'cell-a', loadRatio: 0 },
|
||||
{ cellId: 'cell-b', loadRatio: 0.5 }
|
||||
]
|
||||
for (let second = 0; second <= 60; second += 1) band.pick(ranked, second * 1_000)
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toMatchObject({
|
||||
event: 'orca_relay_placement_load_band',
|
||||
windowMs: 60_000,
|
||||
picks: 61,
|
||||
bandSizeMax: 1
|
||||
})
|
||||
expect(lines[0]?.budgetSkips).toBeGreaterThan(0)
|
||||
expect(lines[0]?.fallbacks).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('falls back to the least-loaded cell when every candidate is over budget', () => {
|
||||
const band = new PlacementLoadBand(seededRandom(3), quiet)
|
||||
const only = [{ cellId: 'cell-a', loadRatio: 0.5 }]
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
expect(band.pick(only, 1_000)?.cellId).toBe('cell-a')
|
||||
}
|
||||
expect(band.pick([], 1_000)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps quiet placement on the least-loaded cell outside the band', () => {
|
||||
let now = 0
|
||||
const band = new PlacementLoadBand(seededRandom(5), quiet)
|
||||
const ranked = [
|
||||
{ cellId: 'cell-a', loadRatio: 0.1 },
|
||||
{ cellId: 'cell-b', loadRatio: 0.3 }
|
||||
]
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
now += PLACEMENT_INTAKE_WINDOW_MS / PLACEMENT_INTAKE_FLOOR
|
||||
expect(band.pick(ranked, now)?.cellId).toBe('cell-a')
|
||||
}
|
||||
})
|
||||
|
||||
it('picks at random among cells inside the band', () => {
|
||||
const band = new PlacementLoadBand(seededRandom(9), quiet)
|
||||
const ranked = [
|
||||
{ cellId: 'cell-a', loadRatio: 0.3 },
|
||||
{ cellId: 'cell-b', loadRatio: 0.32 },
|
||||
{ cellId: 'cell-c', loadRatio: 0.4 }
|
||||
]
|
||||
const picked = new Set<string>()
|
||||
let now = 0
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
now += PLACEMENT_INTAKE_WINDOW_MS
|
||||
picked.add(band.pick(ranked, now)?.cellId ?? '')
|
||||
}
|
||||
expect([...picked].sort()).toEqual(['cell-a', 'cell-b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('leastLoadedCell with the load band', () => {
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
SLOW_CONNECTION_WINDOW,
|
||||
SlowConnectionRetirement
|
||||
} from './postgres-connection-latency.js'
|
||||
import { DIRECTOR_SLOW_CONNECTION_STALLS } from './test-fixtures/director-slow-connection-stalls.js'
|
||||
import { DIRECTOR_TAIL_CONNECTION_STALLS } from './test-fixtures/director-tail-connection-stalls.js'
|
||||
|
||||
let clock = 0
|
||||
|
||||
@@ -19,7 +17,10 @@ type Slot = { name: string; statementMs: () => number }
|
||||
|
||||
// Hands out its connections in turn; a client released with a truthy value is replaced by a fresh
|
||||
// 3 ms one, as pg-pool would dial a new connection on the next ask.
|
||||
function pool(slots: Slot[], options: { retiresSlowConnections?: boolean } = { retiresSlowConnections: true }) {
|
||||
function pool(
|
||||
slots: Slot[],
|
||||
options: { retiresSlowConnections?: boolean } = { retiresSlowConnections: true }
|
||||
) {
|
||||
const destroyed: string[] = []
|
||||
let dialed = 0
|
||||
let turn = 0
|
||||
@@ -167,18 +168,6 @@ describe('slow connection retirement', () => {
|
||||
expect(tx.destroyed).toEqual(['slow'])
|
||||
})
|
||||
|
||||
it('opens no connection itself, so the pool never grows', async () => {
|
||||
const slow = pool([
|
||||
{ name: 'a', statementMs: steady(3) },
|
||||
{ name: 'slow', statementMs: steady(55) }
|
||||
])
|
||||
await runStatements(slow.database, SLOW_CONNECTION_WINDOW * 2)
|
||||
|
||||
expect(slow.destroyed).toEqual(['slow'])
|
||||
// Two original connections plus the one redial that replaced the retired client.
|
||||
expect(slow.dialed()).toBe(3)
|
||||
})
|
||||
|
||||
it('retires at most one connection per pool per interval', async () => {
|
||||
const two = pool([
|
||||
{ name: 'a', statementMs: steady(3) },
|
||||
@@ -221,7 +210,9 @@ describe('slow connection retirement', () => {
|
||||
|
||||
expect(retiredAt.length).toBeLessThanOrEqual(3)
|
||||
expect(retiredAt.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...retiredAt)).toBeLessThan(SLOW_CONNECTION_PEER_SLACK_MS + SLOW_CONNECTION_RETIRE_INTERVAL_MS)
|
||||
expect(Math.max(...retiredAt)).toBeLessThan(
|
||||
SLOW_CONNECTION_PEER_SLACK_MS + SLOW_CONNECTION_RETIRE_INTERVAL_MS
|
||||
)
|
||||
})
|
||||
|
||||
// Every 50th statement takes `outlierMs`: a 2% tail, like the ~200/400/800 ms retransmit stalls.
|
||||
@@ -239,7 +230,11 @@ describe('slow connection retirement', () => {
|
||||
await runStatements(lossy.database, 600)
|
||||
|
||||
expect(lossy.destroyed).toEqual(['lossy'])
|
||||
expect(JSON.parse(warnings[0] ?? '{}')).toMatchObject({ shape: 'tail', tailMs: 415, peerTailMs: 3 })
|
||||
expect(JSON.parse(warnings[0] ?? '{}')).toMatchObject({
|
||||
shape: 'tail',
|
||||
tailMs: 415,
|
||||
peerTailMs: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('retires nothing when every connection has the same tail, as a whole-database spike does', async () => {
|
||||
@@ -256,13 +251,6 @@ describe('slow connection retirement', () => {
|
||||
expect(far.destroyed).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['14:58', 320, '14:58:22'],
|
||||
['15:24', 367, '15:24:22']
|
||||
] as const)('replays the %s tail stall of 2026-10-26 and retires c%i in the window ending %s', (stall, id, windowEnd) => {
|
||||
expect(replayTail(stall)).toEqual([{ connectionId: id, windowEnd }])
|
||||
})
|
||||
|
||||
it('lets one peer with a slow statement of its own block a tail retirement', async () => {
|
||||
const noisy = pool([
|
||||
{ name: 'a', statementMs: steady(3) },
|
||||
@@ -307,88 +295,9 @@ describe('slow connection retirement', () => {
|
||||
retirement.shouldRetire(live, 51)
|
||||
expect(retirement.trackedClients).toBe(1)
|
||||
})
|
||||
|
||||
it('replays the 09:38 stall of 2026-10-26 and retires only its slow connection', () => {
|
||||
expect(replay('09:38')).toEqual([{ connectionId: 11, windowEnd: '09:38:21' }])
|
||||
})
|
||||
|
||||
it('replays the 09:51 stall, where a second connection (c31, 38 ms) also turned slow', () => {
|
||||
expect(replay('09:51')).toEqual([
|
||||
{ connectionId: 19, windowEnd: '09:51:51' },
|
||||
{ connectionId: 31, windowEnd: '09:52:51' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function fakeClient(): pg.PoolClient {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retirement rule only uses clients as identity keys.
|
||||
return {} as pg.PoolClient
|
||||
}
|
||||
|
||||
// Spreads each 30 s window's statements evenly at its median and releases after every statement,
|
||||
// as autocommit queries do. Stops judging a connection once it is retired.
|
||||
function replay(stall: '09:38' | '09:51'): { connectionId: number; windowEnd: string }[] {
|
||||
const at = (time: string) => Date.parse(`2026-10-26T${time}Z`)
|
||||
const samples = DIRECTOR_SLOW_CONNECTION_STALLS.filter(([name]) => name === stall).flatMap(
|
||||
([, windowEnd, connectionId, statements, medianMs]) =>
|
||||
Array.from({ length: statements }, (_, index) => ({
|
||||
at: at(windowEnd) - 30_000 + ((index + 0.5) * 30_000) / statements,
|
||||
windowEnd,
|
||||
connectionId,
|
||||
medianMs
|
||||
}))
|
||||
)
|
||||
samples.sort((a, b) => a.at - b.at)
|
||||
const clients = new Map<number, pg.PoolClient>()
|
||||
const retired: { connectionId: number; windowEnd: string }[] = []
|
||||
const retirement = new SlowConnectionRetirement(() => clock)
|
||||
for (const sample of samples) {
|
||||
if (retired.some((entry) => entry.connectionId === sample.connectionId)) {
|
||||
continue
|
||||
}
|
||||
clock = sample.at
|
||||
const client = clients.get(sample.connectionId) ?? fakeClient()
|
||||
clients.set(sample.connectionId, client)
|
||||
retirement.track(client)
|
||||
noteConnectionStatement(client, sample.medianMs)
|
||||
if (retirement.shouldRetire(client, sample.connectionId)) {
|
||||
retired.push({ connectionId: sample.connectionId, windowEnd: sample.windowEnd })
|
||||
}
|
||||
}
|
||||
return retired
|
||||
}
|
||||
|
||||
// Spreads each window's statements evenly at its median, with ceil(1%) of them at its p99 and one at
|
||||
// its max, and releases after every statement. Stops judging a connection once it is retired.
|
||||
function replayTail(stall: '14:58' | '15:24'): { connectionId: number; windowEnd: string }[] {
|
||||
const at = (time: string) => Date.parse(`2026-10-26T${time}Z`)
|
||||
const samples = DIRECTOR_TAIL_CONNECTION_STALLS.filter(([name]) => name === stall).flatMap(
|
||||
([, windowEnd, connectionId, statements, medianMs, p99Ms, maxMs]) => {
|
||||
const tailEvery = Math.floor(statements / Math.ceil(statements * 0.01))
|
||||
return Array.from({ length: statements }, (_, index) => ({
|
||||
at: at(windowEnd) - 30_000 + ((index + 0.5) * 30_000) / statements,
|
||||
windowEnd,
|
||||
connectionId,
|
||||
ms: index === statements - 1 ? maxMs : (index + 1) % tailEvery === 0 ? p99Ms : medianMs
|
||||
}))
|
||||
}
|
||||
)
|
||||
samples.sort((a, b) => a.at - b.at)
|
||||
const clients = new Map<number, pg.PoolClient>()
|
||||
const retired: { connectionId: number; windowEnd: string }[] = []
|
||||
const retirement = new SlowConnectionRetirement(() => clock)
|
||||
for (const sample of samples) {
|
||||
if (retired.some((entry) => entry.connectionId === sample.connectionId)) {
|
||||
continue
|
||||
}
|
||||
clock = sample.at
|
||||
const client = clients.get(sample.connectionId) ?? fakeClient()
|
||||
clients.set(sample.connectionId, client)
|
||||
retirement.track(client)
|
||||
noteConnectionStatement(client, sample.ms)
|
||||
if (retirement.shouldRetire(client, sample.connectionId)) {
|
||||
retired.push({ connectionId: sample.connectionId, windowEnd: sample.windowEnd })
|
||||
}
|
||||
}
|
||||
return retired
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayAssignmentRowBusyError, type RelayAssignment } from './assignment/assignment-store.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import type { StickySlotSample } from './sticky-slot-statements.js'
|
||||
import {
|
||||
ScriptedPoolClient,
|
||||
scriptedPostgresDatabase
|
||||
} from './test-fixtures/scripted-postgres-pool.js'
|
||||
|
||||
const fakes = vi.hoisted(() => ({
|
||||
verifyRelayToken: vi.fn(async (token: string) => ({
|
||||
@@ -105,26 +100,6 @@ describe('public assignment reconnect lane', () => {
|
||||
expect(assign).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sends an unverified reconnect hint through the placement lane unchanged', async () => {
|
||||
const host = 'nnnnnnnnnnnnnnnn'
|
||||
const assign = vi.fn(async () => assignment('cell-n', host))
|
||||
const resolve = vi.fn(async () => null)
|
||||
const outcomes: string[] = []
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: { assign, resolve } as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true),
|
||||
recordAssignmentAdmission: (outcome) => outcomes.push(outcome)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(assign).toHaveBeenCalledTimes(1)
|
||||
expect(outcomes).toEqual(['placement'])
|
||||
})
|
||||
|
||||
it('rejects on the fast lane when the verification probe fails transiently', async () => {
|
||||
const host = 'tttttttttttttttt'
|
||||
const assign = vi.fn()
|
||||
@@ -208,70 +183,6 @@ describe('public assignment reconnect lane', () => {
|
||||
expect(await response.json()).toEqual({ error: 'relay_assignment_row_busy' })
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('reports what each sticky slot spent its hold on', async () => {
|
||||
vi.useFakeTimers({ toFake: ['performance'] })
|
||||
const first = 'ffffffffffffffff'
|
||||
const queued = 'qqqqqqqqqqqqqqqq'
|
||||
const newcomer = 'nnnnnnnnnnnnnnnn'
|
||||
const firstHost = new ScriptedPoolClient()
|
||||
const queuedHost = new ScriptedPoolClient()
|
||||
// first: verify, assign; newcomer: assign; queued: verify, assign.
|
||||
const database = scriptedPostgresDatabase([
|
||||
firstHost,
|
||||
firstHost,
|
||||
new ScriptedPoolClient(),
|
||||
queuedHost,
|
||||
queuedHost
|
||||
])
|
||||
const firstAssign = deferred<RelayAssignment>()
|
||||
const firstReady = deferred<void>()
|
||||
const resolve = vi.fn(async ({ relayHostId }: { relayHostId: string }) => {
|
||||
await database.query('SELECT verify')
|
||||
return assignment('cell-r', relayHostId)
|
||||
})
|
||||
const assign = vi.fn(async ({ relayHostId }: { relayHostId: string }) => {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.query('SELECT pin')
|
||||
await transaction.query('UPDATE lease')
|
||||
})
|
||||
if (relayHostId === first) {
|
||||
firstReady.resolve()
|
||||
return await firstAssign.promise
|
||||
}
|
||||
return assignment('cell-r', relayHostId)
|
||||
})
|
||||
const samples: StickySlotSample[] = []
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: { assign, resolve } as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true),
|
||||
recordStickySlot: (sample) => samples.push(sample)
|
||||
})
|
||||
|
||||
const holding = app.request('/v1/assign', assignmentRequest(first, { reconnect: true }))
|
||||
await firstReady.promise
|
||||
expect(firstHost.statements).toHaveLength(5)
|
||||
const waiting = app.request('/v1/assign', assignmentRequest(queued, { reconnect: true }))
|
||||
// A placement request's statements are its own, even while a sticky slot is held.
|
||||
expect((await app.request('/v1/assign', assignmentRequest(newcomer))).status).toBe(200)
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
firstAssign.resolve(assignment('cell-r', first))
|
||||
expect((await holding).status).toBe(200)
|
||||
expect((await waiting).status).toBe(200)
|
||||
|
||||
// Verify, then BEGIN, two statements and COMMIT, each on that host's own connection.
|
||||
const roundTrips = (sample: StickySlotSample | undefined) =>
|
||||
[...(sample?.statementMsByConnection ?? [])].map(([id, ms]) => [id, ms.length])
|
||||
expect(samples).toHaveLength(2)
|
||||
expect(samples[0]).toMatchObject({ statements: 5 })
|
||||
expect(roundTrips(samples[0])).toEqual([[1, 5]])
|
||||
expect(samples[0]?.queueWaitMs).toBe(0)
|
||||
expect(samples[1]).toMatchObject({ statements: 5 })
|
||||
expect(roundTrips(samples[1])).toEqual([[3, 5]])
|
||||
expect(samples[1]?.queueWaitMs).toBe(60)
|
||||
})
|
||||
})
|
||||
|
||||
function assignmentRequest(relayHostId: string, extra: { reconnect?: boolean } = {}): RequestInit {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayAssignmentStore } from './assignment/assignment-store.js'
|
||||
import { rehomeSourceRegionAllowed } from './idle-regional-rehome-selection.js'
|
||||
import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js'
|
||||
import { REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS } from './regional-rehome-poll-telemetry.js'
|
||||
|
||||
type Region = 'us-central1' | 'asia-east2'
|
||||
const cell = (id: string, region: Region) => ({
|
||||
@@ -141,12 +139,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('rehome source-region filter', () => {
|
||||
it('keys on the source region, never on the target region', () => {
|
||||
expect(rehomeSourceRegionAllowed('us-central1', 'us-central1')).toBe(true)
|
||||
expect(rehomeSourceRegionAllowed('asia-east2', 'us-central1')).toBe(false)
|
||||
expect(rehomeSourceRegionAllowed('asia-east2', undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps US to Asia moves and drops Asia to US moves', async () => {
|
||||
const context = await setup('us-central1')
|
||||
const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety())
|
||||
@@ -168,23 +160,6 @@ describe('rehome source-region filter', () => {
|
||||
await context.database.close()
|
||||
})
|
||||
|
||||
it('counts the skipped source cells in the poll summary line', async () => {
|
||||
const context = await setup('us-central1')
|
||||
const lines: string[] = []
|
||||
vi.spyOn(console, 'warn').mockImplementation((line: unknown) => {
|
||||
lines.push(String(line))
|
||||
})
|
||||
await context.store.selectIdleRegionalRehomeCandidates(context.safety())
|
||||
context.advance(REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS)
|
||||
await context.beatAll()
|
||||
await context.store.selectIdleRegionalRehomeCandidates(context.safety())
|
||||
const summary = lines
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||||
.find(({ event }) => event === 'orca_relay_regional_rehome_poll_summary')
|
||||
expect(summary).toMatchObject({ open: 2, skippedOffRegionSourceCells: 2 })
|
||||
await context.database.close()
|
||||
})
|
||||
|
||||
it('reports the filtered hosts in the preview, agreeing with selection', async () => {
|
||||
const context = await setup('us-central1')
|
||||
const preview = await context.store.previewRegionalRehomeEligibility(context.safety())
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RelayAssignmentStore } from './assignment/assignment-store.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import {
|
||||
combineRegionalRehomeSafety,
|
||||
REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT,
|
||||
regionalRehomeSafetyFailure
|
||||
} from './regional-rehome-safety.js'
|
||||
import { startRegionalRehomeWorker } from './regional-rehome-worker.js'
|
||||
|
||||
describe('regional rehome worker', () => {
|
||||
@@ -14,12 +9,16 @@ describe('regional rehome worker', () => {
|
||||
it('bounds empty polling to the six-second cadence and stops its timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([])
|
||||
const worker = startRegionalRehomeWorker(config(), {
|
||||
selectIdleRegionalRehomeCandidates
|
||||
} as unknown as RelayAssignmentStore, {
|
||||
safetySnapshot: () => safety(Date.now()),
|
||||
random: () => 0
|
||||
})!
|
||||
const worker = startRegionalRehomeWorker(
|
||||
config(),
|
||||
{
|
||||
selectIdleRegionalRehomeCandidates
|
||||
} as unknown as RelayAssignmentStore,
|
||||
{
|
||||
safetySnapshot: () => safety(Date.now()),
|
||||
random: () => 0
|
||||
}
|
||||
)!
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(1)
|
||||
@@ -36,56 +35,6 @@ describe('regional rehome worker', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('passes unsafe process telemetry to the durable claim gate', async () => {
|
||||
let now = 0
|
||||
let sqlFailures = 0
|
||||
const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([])
|
||||
const assignments = {
|
||||
selectIdleRegionalRehomeCandidates
|
||||
} as unknown as RelayAssignmentStore
|
||||
const worker = startRegionalRehomeWorker(config(), assignments, {
|
||||
now: () => now,
|
||||
safetySnapshot: () => ({ ...safety(now), sqlFailures }),
|
||||
intervalMs: 60_000
|
||||
})!
|
||||
await settleWorker()
|
||||
selectIdleRegionalRehomeCandidates.mockClear()
|
||||
now = 100
|
||||
sqlFailures = 1
|
||||
await worker.run()
|
||||
worker.stop()
|
||||
|
||||
expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ observedAt: 100, sqlFailures: 1 })
|
||||
)
|
||||
})
|
||||
|
||||
it('starts on directors with no candidates and stays disabled on cells', async () => {
|
||||
let now = 0
|
||||
const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([])
|
||||
const assignments = {
|
||||
selectIdleRegionalRehomeCandidates
|
||||
} as unknown as RelayAssignmentStore
|
||||
const worker = startRegionalRehomeWorker(config(), assignments, {
|
||||
now: () => now,
|
||||
safetySnapshot: () => safety(now),
|
||||
intervalMs: 60_000
|
||||
})
|
||||
expect(worker).not.toBeNull()
|
||||
await settleWorker()
|
||||
selectIdleRegionalRehomeCandidates.mockClear()
|
||||
now = 100
|
||||
await worker!.run()
|
||||
worker!.stop()
|
||||
expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()
|
||||
|
||||
expect(
|
||||
startRegionalRehomeWorker(config({ role: 'cell' }), {} as RelayAssignmentStore, {
|
||||
safetySnapshot: () => safety(1)
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('stops walking the page when the source names a deferral no candidate can pass', async () => {
|
||||
const fetchImpl = respondWith([{ outcome: 'deferred', reason: 'concurrency-limit' }])
|
||||
const summaries = collectSummaries()
|
||||
@@ -143,39 +92,6 @@ describe('regional rehome worker', () => {
|
||||
outcomes: { 'deferred:host-unsupported': 1, busy: 1, committed: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a source that answers with an error in the same summary', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response('nope', { status: 503 }))
|
||||
const summaries = collectSummaries()
|
||||
try {
|
||||
await runOnePoll(fetchImpl, 2, summaries)
|
||||
} finally {
|
||||
summaries.restore()
|
||||
}
|
||||
|
||||
expect(summaries.entries[0]).toMatchObject({ dispatched: 2, outcomes: { failed: 2 } })
|
||||
})
|
||||
|
||||
it('treats the reconnect threshold as per-cell and excludes the director', () => {
|
||||
const cells = 2
|
||||
const limit = cells * REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT
|
||||
const processSafety = { ...safety(100), reconnects: limit * 10 }
|
||||
const fleetSafety = { ...safety(100), reconnects: limit }
|
||||
expect(
|
||||
regionalRehomeSafetyFailure(
|
||||
combineRegionalRehomeSafety(processSafety, fleetSafety),
|
||||
100,
|
||||
cells
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
regionalRehomeSafetyFailure(
|
||||
combineRegionalRehomeSafety(processSafety, { ...fleetSafety, reconnects: limit + 1 }),
|
||||
100,
|
||||
cells
|
||||
)
|
||||
).toBe('elevated_reconnects')
|
||||
})
|
||||
})
|
||||
|
||||
function respondWith(bodies: { outcome: string; reason?: string }[]) {
|
||||
@@ -271,8 +187,3 @@ function safety(observedAt: number) {
|
||||
databasePoolWaitMsMax: 0
|
||||
}
|
||||
}
|
||||
|
||||
async function settleWorker(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
@@ -45,15 +45,6 @@ describe('Relay regional placement', () => {
|
||||
|
||||
afterEach(async () => await database.close())
|
||||
|
||||
it('prefers the requested region and keeps unhinted placement US-first', async () => {
|
||||
await expect(
|
||||
store.assign({ userId: 'asia-user', relayHostId: 'asiahost00000001' }, 'asia-east2')
|
||||
).resolves.toMatchObject({ cellId: 'asia-c1', region: 'asia-east2' })
|
||||
await expect(
|
||||
store.assign({ userId: 'us-user', relayHostId: 'ushost0000000001' })
|
||||
).resolves.toMatchObject({ cellId: 'us-c1', region: 'us-central1' })
|
||||
})
|
||||
|
||||
it('falls back globally only when the target region has no safe general cell', async () => {
|
||||
await store.configureCell(CELLS[1]!, 'migration-only')
|
||||
await store.configureCell(CELLS[2]!, 'migration-only')
|
||||
@@ -89,8 +80,9 @@ describe('Relay regional placement', () => {
|
||||
'us-central1'
|
||||
)
|
||||
).resolves.toMatchObject({ cellId: 'us-c1', region: 'us-central1' })
|
||||
expect(await database.query(`SELECT preferred_region FROM relay_assignment_region_preferences`))
|
||||
.toEqual([{ preferred_region: 'asia-east2' }])
|
||||
expect(
|
||||
await database.query(`SELECT preferred_region FROM relay_assignment_region_preferences`)
|
||||
).toEqual([{ preferred_region: 'asia-east2' }])
|
||||
})
|
||||
|
||||
it('returns at most two deterministic healthy general probe origins per region', async () => {
|
||||
@@ -203,14 +195,6 @@ describe('Relay regional placement', () => {
|
||||
for (const cell of CELLS.slice(1)) await store.configureCell(cell, 'migration-only')
|
||||
}
|
||||
|
||||
it('books the reserve cell rather than spill out of a full region', async () => {
|
||||
await fillAsia()
|
||||
const place = vi.fn(async () => onReserve)
|
||||
await expect(assignAsia(place)).resolves.toEqual({ ...onReserve, placed: true })
|
||||
expect(place).toHaveBeenCalledOnce()
|
||||
expect(await database.query(`SELECT cell_id FROM relay_assignments`)).toEqual([])
|
||||
})
|
||||
|
||||
it('answers a throttled reserve cell with its pace, not a cell abroad', async () => {
|
||||
await fillAsia()
|
||||
await expect(
|
||||
@@ -233,15 +217,6 @@ describe('Relay regional placement', () => {
|
||||
store.assign({ ...identity, relayHostId: 'spillhost0000002' }, 'asia-east2', 'asia-east2')
|
||||
).resolves.toMatchObject({ cellId: 'us-c1' })
|
||||
})
|
||||
|
||||
it('leaves a region with database room to the database lane', async () => {
|
||||
const place = vi.fn(async () => onReserve)
|
||||
await expect(assignAsia(place)).resolves.toMatchObject({
|
||||
cellId: 'asia-c1',
|
||||
region: 'asia-east2'
|
||||
})
|
||||
expect(place).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('expires identity-linked region preferences after 30 days', async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { RELAY_CLOSE_CODE } from '@orca-cloud/relay-contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from './assignment/assignment-store.js'
|
||||
import { openInMemoryRelayDatabase } from './database.js'
|
||||
import { hostCohortBucket } from './host-cohort-bucket.js'
|
||||
import {
|
||||
RESERVE_REHOME_HOST_COOLDOWN_MS,
|
||||
RESERVE_REHOME_SWITCH_STALE_MS,
|
||||
@@ -16,7 +15,10 @@ const ROOM: RehomeTargetRoom = { free: 500, ceiling: 1_000, bookable: true }
|
||||
// 2,000 controls refill one token a minute.
|
||||
const CONTROLS = 2_000
|
||||
|
||||
function admitted(rehome: ReserveRehome, overrides: Partial<Parameters<ReserveRehome['admit']>[0]> = {}) {
|
||||
function admitted(
|
||||
rehome: ReserveRehome,
|
||||
overrides: Partial<Parameters<ReserveRehome['admit']>[0]> = {}
|
||||
) {
|
||||
return rehome.admit({
|
||||
identity: HOST,
|
||||
sourceCellId: 'us-r',
|
||||
@@ -37,25 +39,14 @@ function warmed(percent = 100) {
|
||||
}
|
||||
|
||||
describe('rehome on reconnect rules', () => {
|
||||
it('is off at 0%, and admits a host whose every rule holds', () => {
|
||||
expect(new ReserveRehome({ percent: 0, switchOpen: () => true }).enabled).toBe(false)
|
||||
expect(admitted(warmed())).toBeNull()
|
||||
})
|
||||
|
||||
it('stays off while the operator switch is closed', () => {
|
||||
expect(admitted(new ReserveRehome({ percent: 100, switchOpen: () => false }))).toBe('switch')
|
||||
})
|
||||
|
||||
it('moves only the cohort below the percent', () => {
|
||||
const bucket = hostCohortBucket(HOST)
|
||||
expect(admitted(new ReserveRehome({ percent: bucket, switchOpen: () => true }))).toBe('cohort')
|
||||
expect(admitted(warmed(bucket + 1))).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a host that another install dropped in the last 10 minutes', () => {
|
||||
const peerDropped = { region: 'us-central1' as const, closeCode: RELAY_CLOSE_CODE.PEER_DROPPED }
|
||||
expect(admitted(warmed(), { leaves: [{ ...peerDropped, at: 60_000 - 10 * 60_000 }] })).toBe('peer-dropped')
|
||||
expect(admitted(warmed(), { leaves: [{ ...peerDropped, at: 60_000 - 10 * 60_000 - 1 }] })).toBeNull()
|
||||
expect(admitted(warmed(), { leaves: [{ ...peerDropped, at: 60_000 - 10 * 60_000 }] })).toBe(
|
||||
'peer-dropped'
|
||||
)
|
||||
expect(
|
||||
admitted(warmed(), { leaves: [{ ...peerDropped, at: 60_000 - 10 * 60_000 - 1 }] })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a host whose remembered leaves span regions, or that it moved within a day', () => {
|
||||
@@ -64,7 +55,9 @@ describe('rehome on reconnect rules', () => {
|
||||
{ region: 'asia-east2' as const, at: 2 }
|
||||
]
|
||||
expect(admitted(warmed(), { leaves })).toBe('moved-recently')
|
||||
expect(admitted(warmed(), { leaves: [{ region: undefined, at: 1 }] })).toBe('unknown-leave-region')
|
||||
expect(admitted(warmed(), { leaves: [{ region: undefined, at: 1 }] })).toBe(
|
||||
'unknown-leave-region'
|
||||
)
|
||||
const rehome = warmed()
|
||||
rehome.recordMoved(HOST, 0)
|
||||
expect(admitted(rehome)).toBe('moved-recently')
|
||||
@@ -72,17 +65,27 @@ describe('rehome on reconnect rules', () => {
|
||||
})
|
||||
|
||||
it('moves no one into a region under 10% free, however full the source is', () => {
|
||||
expect(admitted(warmed(), { target: { free: 20, ceiling: 1_000, bookable: true } })).toBe('target-room')
|
||||
expect(admitted(warmed(), { target: { free: 99, ceiling: 1_000, bookable: true } })).toBe('target-room')
|
||||
expect(admitted(warmed(), { target: { free: 20, ceiling: 1_000, bookable: true } })).toBe(
|
||||
'target-room'
|
||||
)
|
||||
expect(admitted(warmed(), { target: { free: 99, ceiling: 1_000, bookable: true } })).toBe(
|
||||
'target-room'
|
||||
)
|
||||
expect(admitted(warmed(), { target: { free: 100, ceiling: 1_000, bookable: true } })).toBeNull()
|
||||
expect(admitted(warmed(), { target: { free: 500, ceiling: 1_000, bookable: false } })).toBe('target-room')
|
||||
expect(admitted(warmed(), { target: { free: 0, ceiling: 0, bookable: false } })).toBe('target-room')
|
||||
expect(admitted(warmed(), { target: { free: 500, ceiling: 1_000, bookable: false } })).toBe(
|
||||
'target-room'
|
||||
)
|
||||
expect(admitted(warmed(), { target: { free: 0, ceiling: 0, bookable: false } })).toBe(
|
||||
'target-room'
|
||||
)
|
||||
})
|
||||
|
||||
it('takes a source cell at 0.05% of its controls a minute, burst one, and refunds an unmade move', () => {
|
||||
const rehome = warmed()
|
||||
expect(admitted(rehome)).toBeNull()
|
||||
expect(admitted(rehome, { identity: { ...HOST, relayHostId: 'qrstuvwxyzabcdef' } })).toBe('source-budget')
|
||||
expect(admitted(rehome, { identity: { ...HOST, relayHostId: 'qrstuvwxyzabcdef' } })).toBe(
|
||||
'source-budget'
|
||||
)
|
||||
expect(admitted(rehome, { sourceCellId: 'us-r2', now: 0 })).toBe('source-budget')
|
||||
rehome.refund('us-r')
|
||||
expect(admitted(rehome, { identity: { ...HOST, relayHostId: 'qrstuvwxyzabcdef' } })).toBeNull()
|
||||
@@ -100,7 +103,10 @@ describe('rehome switch', () => {
|
||||
enabled: true,
|
||||
notBefore: 0
|
||||
})
|
||||
const control = new ReserveRehomeSwitch(async () => await answer(), () => now)
|
||||
const control = new ReserveRehomeSwitch(
|
||||
async () => await answer(),
|
||||
() => now
|
||||
)
|
||||
expect(control.isOpen(now)).toBe(false)
|
||||
await control.refresh()
|
||||
expect(control.isOpen(now)).toBe(true)
|
||||
@@ -129,14 +135,17 @@ describe('the rehome switch under a stalled read', () => {
|
||||
let now = 1_000
|
||||
let release!: (value: { enabled: boolean; notBefore: number }) => void
|
||||
const reads: ('stalled' | 'off')[] = []
|
||||
const control = new ReserveRehomeSwitch(async () => {
|
||||
if (reads.length === 0) {
|
||||
reads.push('stalled')
|
||||
return await new Promise((resolve) => (release = resolve))
|
||||
}
|
||||
reads.push('off')
|
||||
return { enabled: false, notBefore: 0 }
|
||||
}, () => now)
|
||||
const control = new ReserveRehomeSwitch(
|
||||
async () => {
|
||||
if (reads.length === 0) {
|
||||
reads.push('stalled')
|
||||
return await new Promise((resolve) => (release = resolve))
|
||||
}
|
||||
reads.push('off')
|
||||
return { enabled: false, notBefore: 0 }
|
||||
},
|
||||
() => now
|
||||
)
|
||||
const stalled = control.refresh()
|
||||
await control.refresh()
|
||||
expect(reads).toEqual(['stalled'])
|
||||
|
||||
@@ -5,11 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type WebSocket from 'ws'
|
||||
import type { RawData } from 'ws'
|
||||
import { RELAY_ADMISSION_BUDGETS } from '@orca-cloud/relay-contract'
|
||||
import {
|
||||
ProcessQueuedByteBudget,
|
||||
wireSplice,
|
||||
type SpliceCloseInfo
|
||||
} from './splice-forwarder.js'
|
||||
import { ProcessQueuedByteBudget, wireSplice } from './splice-forwarder.js'
|
||||
|
||||
// SAFETY: ws exports Receiver at runtime; its published types omit this Writable constructor.
|
||||
const { Receiver } = createRequire(import.meta.url)('ws') as {
|
||||
@@ -39,29 +35,6 @@ class FakeSocket extends EventEmitter {
|
||||
|
||||
describe('splice forwarder', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('preserves text/binary opcodes and propagates peer close', () => {
|
||||
const client = new FakeSocket()
|
||||
const host = new FakeSocket()
|
||||
const onClose = vi.fn()
|
||||
const onForwardedBytes = vi.fn()
|
||||
wireSplice({
|
||||
client: client as unknown as WebSocket,
|
||||
host: host as unknown as WebSocket,
|
||||
budget: new ProcessQueuedByteBudget(),
|
||||
onClose,
|
||||
onForwardedBytes
|
||||
})
|
||||
client.emit('message', Buffer.from('text'), false)
|
||||
client.emit('message', Buffer.from([1, 2]), true)
|
||||
expect(host.sent).toEqual([
|
||||
{ data: Buffer.from('text'), binary: false },
|
||||
{ data: Buffer.from([1, 2]), binary: true }
|
||||
])
|
||||
expect(onForwardedBytes.mock.calls).toEqual([[4], [2]])
|
||||
client.emit('close', 1000, Buffer.alloc(0))
|
||||
expect(host.closes[0]?.code).toBe(4408)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hard-closes a splice exceeding the queue limit and releases its global queued-byte reservation', () => {
|
||||
const client = new FakeSocket()
|
||||
@@ -82,40 +55,6 @@ describe('splice forwarder', () => {
|
||||
expect(budget.current()).toBe(0)
|
||||
})
|
||||
|
||||
it('reports the close trigger so limit and oversize kills are attributable', () => {
|
||||
const wire = (): { client: FakeSocket; host: FakeSocket; closes: SpliceCloseInfo[] } => {
|
||||
const client = new FakeSocket()
|
||||
const host = new FakeSocket()
|
||||
const closes: SpliceCloseInfo[] = []
|
||||
wireSplice({
|
||||
client: client as unknown as WebSocket,
|
||||
host: host as unknown as WebSocket,
|
||||
budget: new ProcessQueuedByteBudget(),
|
||||
onClose: vi.fn(),
|
||||
onClosed: (closeInfo) => closes.push(closeInfo)
|
||||
})
|
||||
return { client, host, closes }
|
||||
}
|
||||
|
||||
const queueLimit = wire()
|
||||
queueLimit.host.bufferedAmount = 1024 * 1024
|
||||
queueLimit.client.emit('message', Buffer.alloc(8 * 1024 * 1024), true)
|
||||
queueLimit.client.emit('message', Buffer.alloc(512 * 1024), true)
|
||||
expect(queueLimit.closes).toEqual([
|
||||
{ code: 4429, reason: 'relay queue limit exceeded', trigger: 'queue-limit' }
|
||||
])
|
||||
|
||||
// The ws receiver error for a frame above maxPayload names the payload cap.
|
||||
const oversize = wire()
|
||||
oversize.host.emit('error', new RangeError('Max payload size exceeded'))
|
||||
expect(oversize.closes[0]?.trigger).toBe('host-oversize-frame')
|
||||
|
||||
const peerClose = wire()
|
||||
peerClose.client.emit('close', 1001, Buffer.alloc(0))
|
||||
expect(peerClose.closes[0]?.trigger).toBe('client-closed')
|
||||
expect(peerClose.closes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('queues one full catalog-sized frame for a backpressured peer without closing', () => {
|
||||
// Large workspace catalogs exceed 1MiB; one maxFrameBytes frame must survive backpressure.
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Director instance ...48a393ae on 2026-10-26, from its runtime lines' stickySqlByConnection: per 30 s
|
||||
// window ending at `windowEnd` (UTC), one pooled connection's statement count and median ms.
|
||||
// Columns: stall, windowEnd, connectionId, statements, medianMs.
|
||||
type StallWindow = readonly [
|
||||
stall: '09:38' | '09:51',
|
||||
windowEnd: string,
|
||||
connectionId: number,
|
||||
statements: number,
|
||||
medianMs: number
|
||||
]
|
||||
|
||||
export const DIRECTOR_SLOW_CONNECTION_STALLS: readonly StallWindow[] = [
|
||||
['09:38', '09:37:21', 7, 1216, 3.536],
|
||||
['09:38', '09:37:21', 10, 10, 3.595],
|
||||
['09:38', '09:37:21', 11, 322, 3.796],
|
||||
['09:38', '09:37:51', 7, 184, 3.535],
|
||||
['09:38', '09:37:51', 11, 733, 3.592],
|
||||
['09:38', '09:37:51', 12, 616, 3.435],
|
||||
['09:38', '09:38:21', 11, 488, 52.398],
|
||||
['09:38', '09:38:21', 13, 237, 3.481],
|
||||
['09:38', '09:38:51', 11, 495, 58.993],
|
||||
['09:38', '09:39:21', 11, 533, 56.129],
|
||||
['09:38', '09:39:21', 12, 10, 4.379],
|
||||
['09:38', '09:39:51', 11, 1052, 8.312],
|
||||
['09:38', '09:39:51', 12, 213, 3.549],
|
||||
['09:38', '09:40:21', 11, 713, 3.62],
|
||||
['09:38', '09:40:21', 15, 868, 3.405],
|
||||
['09:51', '09:50:51', 19, 654, 3.388],
|
||||
['09:51', '09:50:51', 29, 829, 3.121],
|
||||
['09:51', '09:51:21', 19, 835, 3.536],
|
||||
['09:51', '09:51:21', 29, 382, 3.205],
|
||||
['09:51', '09:51:51', 19, 498, 58.791],
|
||||
['09:51', '09:52:21', 19, 536, 55.549],
|
||||
['09:51', '09:52:51', 19, 19, 57.906],
|
||||
['09:51', '09:52:51', 29, 991, 3.375],
|
||||
['09:51', '09:52:51', 31, 354, 37.754],
|
||||
['09:51', '09:53:21', 19, 506, 3.272],
|
||||
['09:51', '09:53:21', 31, 784, 19.04],
|
||||
['09:51', '09:53:51', 19, 1262, 3.304],
|
||||
['09:51', '09:53:51', 31, 400, 3.205],
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
// Director instance ...1dc2e1c3 on 2026-10-26, from its runtime lines' stickySqlByConnection: per 30 s
|
||||
// window ending at `windowEnd` (UTC), one pooled connection's statement count, median, p99 and max ms.
|
||||
type TailStallWindow = readonly [
|
||||
stall: '14:58' | '15:24',
|
||||
windowEnd: string,
|
||||
connectionId: number,
|
||||
statements: number,
|
||||
medianMs: number,
|
||||
p99Ms: number,
|
||||
maxMs: number
|
||||
]
|
||||
|
||||
export const DIRECTOR_TAIL_CONNECTION_STALLS: readonly TailStallWindow[] = [
|
||||
['14:58', '14:56:52', 315, 1053, 3.481, 10.815, 65.702],
|
||||
['14:58', '14:56:52', 320, 727, 3.474, 11.571, 18.8],
|
||||
['14:58', '14:56:52', 321, 10, 3.598, 6.737, 6.737],
|
||||
['14:58', '14:57:22', 315, 741, 3.594, 11.245, 19.562],
|
||||
['14:58', '14:57:22', 320, 584, 3.461, 8.032, 9.57],
|
||||
['14:58', '14:57:52', 315, 1205, 3.462, 7.312, 20.124],
|
||||
['14:58', '14:57:52', 320, 425, 3.523, 13.859, 18.517],
|
||||
['14:58', '14:58:22', 315, 418, 3.395, 7.049, 17.142],
|
||||
['14:58', '14:58:22', 320, 413, 4.854, 416.306, 815.523],
|
||||
['14:58', '14:58:52', 315, 780, 3.506, 8.739, 51.87],
|
||||
['14:58', '14:58:52', 320, 914, 4.543, 215.253, 418.243],
|
||||
['14:58', '14:59:22', 315, 790, 3.453, 7.602, 16.841],
|
||||
['14:58', '14:59:22', 320, 968, 3.517, 10.595, 214.811],
|
||||
['14:58', '14:59:22', 324, 10, 3.296, 7.945, 7.945],
|
||||
['14:58', '14:59:22', 325, 22, 3.277, 7.373, 7.373],
|
||||
['14:58', '14:59:52', 315, 921, 3.425, 11.826, 52.059],
|
||||
['14:58', '14:59:52', 325, 958, 3.422, 7.779, 17.631],
|
||||
['15:24', '15:22:52', 363, 162, 3.531, 6.968, 8.816],
|
||||
['15:24', '15:22:52', 365, 852, 3.307, 7.833, 13.862],
|
||||
['15:24', '15:22:52', 366, 417, 3.645, 10.906, 17.662],
|
||||
['15:24', '15:23:22', 365, 770, 3.341, 8.067, 20.72],
|
||||
['15:24', '15:23:22', 366, 154, 3.492, 7.03, 7.406],
|
||||
['15:24', '15:23:22', 367, 513, 3.654, 11.077, 211.46],
|
||||
['15:24', '15:23:52', 365, 847, 3.438, 9.728, 29.608],
|
||||
['15:24', '15:23:52', 367, 629, 3.476, 9.464, 17.448],
|
||||
['15:24', '15:24:22', 365, 522, 3.309, 12.595, 25.105],
|
||||
['15:24', '15:24:22', 367, 687, 4.551, 414.215, 417.657],
|
||||
['15:24', '15:24:52', 367, 604, 4.967, 414.827, 418.756],
|
||||
['15:24', '15:24:52', 368, 48, 3.75, 10.407, 10.407],
|
||||
['15:24', '15:24:52', 370, 6, 4.097, 9.925, 9.925],
|
||||
['15:24', '15:25:22', 367, 845, 4.71, 415.764, 818.912],
|
||||
['15:24', '15:25:22', 370, 30, 3.399, 7.265, 7.265],
|
||||
['15:24', '15:25:52', 367, 1220, 4.383, 213.454, 216.581],
|
||||
['15:24', '15:25:52', 370, 196, 3.363, 13.136, 17.227],
|
||||
['15:24', '15:25:52', 371, 420, 3.445, 18.831, 42.126],
|
||||
['15:24', '15:26:22', 367, 570, 3.259, 13.859, 39.525],
|
||||
['15:24', '15:26:22', 371, 792, 3.295, 8.899, 44.169],
|
||||
['15:24', '15:26:22', 372, 286, 3.255, 6.977, 13.688],
|
||||
]
|
||||
@@ -1,110 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ConnectorDeliverySchema,
|
||||
ConnectorPollResponseSchema,
|
||||
ConnectorAcksRequestSchema
|
||||
} from './delivery-messages.js'
|
||||
import {
|
||||
ConnectorEventSchema,
|
||||
ConnectorEventsRequestSchema,
|
||||
ConnectorPendingPromptSchema,
|
||||
ConnectorReplyResponseSchema
|
||||
} from './event-messages.js'
|
||||
import { ConnectorHelloRequestSchema, ConnectorHelloResponseSchema } from './hello-messages.js'
|
||||
import { ConnectorEventSchema } from './event-messages.js'
|
||||
import { ConnectorHelloRequestSchema } from './hello-messages.js'
|
||||
import { CONNECTOR_LIMITS } from './connector-limits.js'
|
||||
|
||||
const conversation = { source: 'slack', externalId: 'T1:C9:1792.9:U5' } as const
|
||||
const base = { deliveryId: 'T1:C9:1793.0001', seq: 41, expiresAt: 1_793_000_300_000, conversation }
|
||||
const start = { ...base, kind: 'start', agent: 'claude', placeId: 'pl_1', prompt: 'fix it' }
|
||||
const poll = (deliveries: unknown[], extra: object = {}) =>
|
||||
ConnectorPollResponseSchema.safeParse({
|
||||
v: 1,
|
||||
serverNow: 1_793_000_000_000,
|
||||
holdMs: 50_000,
|
||||
deliveries,
|
||||
...extra
|
||||
})
|
||||
|
||||
describe('poll response', () => {
|
||||
it('parses every known delivery kind and drops unknown fields', () => {
|
||||
const result = poll([
|
||||
{ ...start, addedLater: true },
|
||||
{ ...base, kind: 'send', text: 'more' },
|
||||
{ ...base, kind: 'answer', promptId: 'p7', optionId: 'allow_once' },
|
||||
{ ...base, kind: 'cancel' }
|
||||
])
|
||||
expect(result.success && result.data.deliveries.map((d) => d.kind)).toEqual([
|
||||
'start',
|
||||
'send',
|
||||
'answer',
|
||||
'cancel'
|
||||
])
|
||||
expect(result.data?.deliveries[0]).not.toHaveProperty('addedLater')
|
||||
expect(result.data?.control).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a delivery the cloud must never send', () => {
|
||||
for (const delivery of [
|
||||
{ ...base, kind: 'rewind' },
|
||||
{ ...start, prompt: '' },
|
||||
{ deliveryId: base.deliveryId },
|
||||
{ ...base, conversation: { source: 'github', externalId: 'G1' }, kind: 'cancel' }
|
||||
]) {
|
||||
expect(ConnectorDeliverySchema.safeParse(delivery).success).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries one control at a time', () => {
|
||||
expect(poll([], { control: { stop: 'client_too_old' } }).data?.control).toEqual({
|
||||
stop: 'client_too_old'
|
||||
})
|
||||
expect(poll([], { control: { pausedForMs: 0 } }).success).toBe(false)
|
||||
expect(poll([], { control: { stop: 'client_too_old', reconnect: true } }).success).toBe(false)
|
||||
expect(poll([], { control: { reconnect: true, addedLater: 1 } }).data?.control).toEqual({
|
||||
reconnect: true
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds the batch and the hold', () => {
|
||||
const tooMany = Array.from({ length: CONNECTOR_LIMITS.maxDeliveriesPerPoll + 1 }, () => start)
|
||||
expect(poll(tooMany).success).toBe(false)
|
||||
expect(poll([], { holdMs: CONNECTOR_LIMITS.pollTimeoutMs }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('acks', () => {
|
||||
it('accepts received and done with each result outcome', () => {
|
||||
const results = [
|
||||
{ outcome: 'accepted', reopened: true },
|
||||
{ outcome: 'refused', reason: 'prompt_not_pending' },
|
||||
{ outcome: 'unknown', reason: 'no_local_record' },
|
||||
{ outcome: 'expired' },
|
||||
{ outcome: 'unsupported' }
|
||||
]
|
||||
expect(
|
||||
ConnectorAcksRequestSchema.safeParse({
|
||||
v: 1,
|
||||
helloRev: 7,
|
||||
acks: [
|
||||
{ deliveryId: 'd1', step: 'received' },
|
||||
...results.map((result) => ({ deliveryId: 'd1', step: 'done', result }))
|
||||
]
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
ConnectorAcksRequestSchema.safeParse({
|
||||
v: 1,
|
||||
helloRev: 7,
|
||||
acks: [{ deliveryId: 'd1', step: 'done' }]
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('names the hello they belong to', () => {
|
||||
const acks = [{ deliveryId: 'd1', step: 'received' }]
|
||||
expect(ConnectorAcksRequestSchema.safeParse({ v: 1, acks }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
const turnEnded = {
|
||||
@@ -116,58 +15,18 @@ describe('events', () => {
|
||||
stopReason: null
|
||||
}
|
||||
|
||||
it('names the turn that ended by its start', () => {
|
||||
const { turnId: _, ...withoutTurnId } = turnEnded
|
||||
expect(ConnectorEventSchema.safeParse(turnEnded).success).toBe(true)
|
||||
expect(ConnectorEventSchema.safeParse(withoutTurnId).success).toBe(false)
|
||||
})
|
||||
|
||||
it('caps the last assistant message in UTF-8 bytes', () => {
|
||||
const limit = CONNECTOR_LIMITS.maxLastAssistantMessageBytes
|
||||
expect(
|
||||
ConnectorEventSchema.safeParse({ ...turnEnded, lastAssistantMessage: 'a'.repeat(limit) })
|
||||
.success
|
||||
).toBe(true)
|
||||
expect(
|
||||
ConnectorEventSchema.safeParse({ ...turnEnded, lastAssistantMessage: 'é'.repeat(limit / 2 + 1) })
|
||||
.success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('carries the pending prompt in snapshots', () => {
|
||||
expect(
|
||||
ConnectorEventSchema.safeParse({
|
||||
kind: 'snapshot',
|
||||
conversations: [
|
||||
{
|
||||
conversation,
|
||||
paneKey: 'tab:leaf',
|
||||
agent: 'codex',
|
||||
working: false,
|
||||
prompt: { kind: 'approval', promptId: 'p1', tool: 'Bash', options: [] }
|
||||
}
|
||||
]
|
||||
...turnEnded,
|
||||
lastAssistantMessage: 'é'.repeat(limit / 2 + 1)
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('numbers every events batch within the hello it belongs to', () => {
|
||||
const events = [{ kind: 'turn-started', conversation, paneKey: 'tab:leaf', at: 1 }]
|
||||
const parse = (body: object) => ConnectorEventsRequestSchema.safeParse({ v: 1, events, ...body })
|
||||
expect(parse({ helloRev: 7, seq: 0 }).success).toBe(true)
|
||||
expect(parse({ helloRev: 7 }).success).toBe(false)
|
||||
expect(parse({ seq: 0 }).success).toBe(false)
|
||||
expect(parse({ helloRev: 7, seq: -1 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('carries a question the agent left without text, as the seam reports it', () => {
|
||||
const prompt = {
|
||||
kind: 'question',
|
||||
promptId: 'p2',
|
||||
question: '',
|
||||
options: [{ id: '1', label: '' }]
|
||||
}
|
||||
expect(ConnectorPendingPromptSchema.safeParse(prompt).success).toBe(true)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,37 +73,12 @@ describe('hello and replies', () => {
|
||||
projects
|
||||
}).success
|
||||
const longest = 'x'.repeat(CONNECTOR_LIMITS.maxDisplayTextLength)
|
||||
expect(hello(Array.from({ length: CONNECTOR_LIMITS.maxHelloProjects }, () => project(longest)))).toBe(true)
|
||||
expect(
|
||||
hello(Array.from({ length: CONNECTOR_LIMITS.maxHelloProjects }, () => project(longest)))
|
||||
).toBe(true)
|
||||
expect(hello([project(`${longest}x`)])).toBe(false)
|
||||
expect(
|
||||
hello(Array.from({ length: CONNECTOR_LIMITS.maxHelloProjects + 1 }, () => project('orca')))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('answers a hello with the revision the cloud assigned', () => {
|
||||
expect(ConnectorHelloResponseSchema.safeParse({ helloRev: 3 }).success).toBe(true)
|
||||
expect(ConnectorHelloResponseSchema.safeParse({}).success).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a reply as posted or queued', () => {
|
||||
expect(ConnectorReplyResponseSchema.parse({ status: 'queued' })).toEqual({ status: 'queued' })
|
||||
expect(
|
||||
ConnectorReplyResponseSchema.parse({ status: 'posted', messageId: '1793.0001' })
|
||||
).toEqual({
|
||||
status: 'posted',
|
||||
messageId: '1793.0001'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a conversation from any connected platform', () => {
|
||||
for (const source of ['slack', 'discord', 'linear']) {
|
||||
expect(
|
||||
ConnectorDeliverySchema.safeParse({
|
||||
...base,
|
||||
conversation: { source, externalId: 'x1' },
|
||||
kind: 'cancel'
|
||||
}).success
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -676,10 +676,10 @@
|
||||
"oracle": "After acknowledged phone replies with stale host status, the exact card disappears and Send is enabled; unknown, refused or unverifiable-settlement replies retain enabled choices, while an older host's accepted reply dismisses like an acknowledged one; a same-occurrence view toggle retains pending acknowledgment and real replacements discard it. A slow 32 KiB SSH answer completes all chunks before Enter; cancellation preserves issued bytes but omits remaining groups. Paired settlement invokes the actual provider and lost callbacks remain unknown; older hosts deliver ordinary messages, launch and selector groups once and their accepted answers dismiss as before. An answered card stays hidden across a view remount; a collapsed card is a strip that frees the composer and expands back; an identical question in a new wait shows again; a composer send caught by a new card keeps its bubble without submitting or clearing the line under it.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/native-chat/use-mobile-native-chat-controller.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts mobile/src/session/native-chat/MobileNativeChatView.rows.test.ts mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/native-chat/input/mobile-native-chat-permission-send.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.test.ts mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.test.ts mobile/src/session/native-chat/mobile-native-chat-send.test.ts mobile/src/session/native-chat/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/native-chat/use-mobile-native-chat-controller.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/native-chat/input/mobile-native-chat-permission-send.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.test.ts mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.test.ts mobile/src/session/native-chat/mobile-native-chat-send.test.ts mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --maxWorkers=1",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/native-chat/use-mobile-native-chat-controller.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts mobile/src/session/native-chat/MobileNativeChatView.rows.test.ts mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts --maxWorkers=2"
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/native-chat/use-mobile-native-chat-controller.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts --maxWorkers=2"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx",
|
||||
@@ -712,7 +712,6 @@
|
||||
"mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts",
|
||||
"mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts",
|
||||
"mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts",
|
||||
"mobile/src/session/native-chat/MobileNativeChatView.rows.test.ts",
|
||||
"mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts",
|
||||
"mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts",
|
||||
"mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts",
|
||||
@@ -722,7 +721,6 @@
|
||||
"mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.test.ts",
|
||||
"mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.test.ts",
|
||||
"mobile/src/session/native-chat/mobile-native-chat-send.test.ts",
|
||||
"mobile/src/session/native-chat/MobileNativeChatCardHeaderAction.test.tsx",
|
||||
"mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx",
|
||||
"tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts"
|
||||
],
|
||||
@@ -814,16 +812,7 @@
|
||||
"durationSeconds": 232.36,
|
||||
"summary": "257 tests passed across 19 explicit files, covering production cards, observed sends, existing launch/paste callers, IPC validation, SSH acknowledgment and daemon settlement recovery."
|
||||
},
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts --maxWorkers=2",
|
||||
"currentCommand": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/native-chat/use-mobile-native-chat-controller.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts mobile/src/session/native-chat/MobileNativeChatView.rows.test.ts mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts --maxWorkers=2",
|
||||
"result": "passed",
|
||||
"durationSeconds": 10.32,
|
||||
"summary": "73 tests passed across four explicit files after the controller extraction, covering the production view/card/composer, occurrence dismissal, controller session scoping, unknown/refused replies and stopping active dictation."
|
||||
},
|
||||
|
||||
{
|
||||
"date": "2026-10-06",
|
||||
"runner": "local",
|
||||
@@ -834,16 +823,7 @@
|
||||
"durationSeconds": 14.53,
|
||||
"summary": "391 tests passed across 29 explicit files after the round-4 review fix, including older-host acceptance, the inert hidden composer, collapse that keeps a partial answer and returns focus to the composer, pane-scoped card focus, wait-start occurrence identity and the pane remount signal."
|
||||
},
|
||||
{
|
||||
"date": "2026-10-06",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.19,
|
||||
"summary": "186 tests passed across 12 explicit files after the round-3 review fixes, including older-host answers dismissing like acknowledged ones, collapse that keeps the card mounted, one card per occurrence, wait-start question identity and only Send blocked.",
|
||||
"currentCommand": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/native-chat/use-mobile-native-chat-controller.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts mobile/src/session/native-chat/MobileNativeChatView.rows.test.ts mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/native-chat/input/mobile-native-chat-permission-send.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.test.ts mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.test.ts mobile/src/session/native-chat/mobile-native-chat-send.test.ts mobile/src/session/native-chat/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx --maxWorkers=2"
|
||||
},
|
||||
|
||||
{
|
||||
"date": "2026-10-06",
|
||||
"runner": "local",
|
||||
@@ -871,6 +851,8 @@
|
||||
"evidence": "No polling, subprocess, startup await, new status producer or dismissal timer. Reuses the existing bounded per-tab committed observation and per-PTY queue; waits for each group before its existing pacing timer. Duplicate-cancel and yielding chunk tests bound writes. Optional paired proof reuses provider settlement only for requesting clients; legacy requests keep their raw path."
|
||||
},
|
||||
"knownGaps": [
|
||||
"Historical receipt before suite removal; its original command and results are preserved without assigning them to remaining tests: {\"date\":\"2026-10-05\",\"runner\":\"local\",\"platform\":\"macos\",\"command\":\"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts --maxWorkers=2\",\"currentCommand\":\"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/native-chat/use-mobile-native-chat-controller.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts mobile/src/session/native-chat/MobileNativeChatView.rows.test.ts mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts --maxWorkers=2\",\"result\":\"passed\",\"durationSeconds\":10.32,\"summary\":\"73 tests passed across four explicit files after the controller extraction, covering the production view/card/composer, occurrence dismissal, controller session scoping, unknown/refused replies and stopping active dictation.\"}",
|
||||
"Historical receipt before suite removal; its original command and results are preserved without assigning them to remaining tests: {\"date\":\"2026-10-06\",\"runner\":\"local\",\"platform\":\"macos\",\"command\":\"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2\",\"result\":\"passed\",\"durationSeconds\":1.19,\"summary\":\"186 tests passed across 12 explicit files after the round-3 review fixes, including older-host answers dismissing like acknowledged ones, collapse that keeps the card mounted, one card per occurrence, wait-start question identity and only Send blocked.\",\"currentCommand\":\"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/native-chat/use-mobile-native-chat-controller.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/native-chat/MobileNativeChatView.stop.test.ts mobile/src/session/native-chat/MobileNativeChatView.failure-banner.test.ts mobile/src/session/native-chat/MobileNativeChatView.rows.test.ts mobile/src/session/native-chat/MobileNativeChatView.composer-lock.test.ts mobile/src/session/native-chat/MobileNativeChatView.tail-follow.test.ts mobile/src/session/native-chat/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/native-chat/input/mobile-native-chat-permission-send.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.test.ts mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.test.ts mobile/src/session/native-chat/mobile-native-chat-send.test.ts mobile/src/session/native-chat/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx --maxWorkers=2\"}",
|
||||
"Provider acknowledgment proves handoff, not agent consumption or prompt closure; a host prompt gate remains deferred pending captured closing lifecycles.",
|
||||
"Rendered Electron and physical mobile/microphone behavior require remote QA; native surfaces are mocked in these unit tests.",
|
||||
"Physical WSL, Linux/Windows, paired mixed-version deployment and network fault campaigns have not been exercised.",
|
||||
@@ -6078,8 +6060,7 @@
|
||||
"a trusted press on an external anchor reports it once and stops the navigation, including through what the anchor wraps and an SVG animated href",
|
||||
"a fragment link scrolls in the document, raw or percent-encoded, and a bare hash goes to the top",
|
||||
"a sibling preview document is left to the guest's own navigation, and a middle click opens nothing whether or not it is trusted",
|
||||
"an SVG link is resolved against the document, so its relative, rooted and fragment forms behave exactly like the identical HTML anchor",
|
||||
"the preload reports on exactly the channel main listens on"
|
||||
"an SVG link is resolved against the document, so its relative, rooted and fragment forms behave exactly like the identical HTML anchor"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7108,7 +7108,6 @@
|
||||
"src/packages/process-host/src/wsl-distro-allowlist.test.ts": 55,
|
||||
"src/preload/api/notification-sound-playback.test.ts": 64,
|
||||
"src/preload/api/platform-bridge.test.ts": 48,
|
||||
"src/preload/api/wsl-hosts-api.test.ts": 45,
|
||||
"src/preload/app-restart-checkpoint-routing.test.ts": 2028,
|
||||
"src/preload/browser-client-page-renderer-requests.test.ts": 154,
|
||||
"src/preload/browser-extension-content-script-relay.test.ts": 65,
|
||||
|
||||
@@ -40,11 +40,6 @@ function firstMermaid(tree: ReactTestRenderer) {
|
||||
}
|
||||
|
||||
describe('MobileMarkdown mermaid routing', () => {
|
||||
it('routes a closed mermaid fence to MermaidDiagram', () => {
|
||||
const tree = render('```mermaid\ngraph TD; A-->B\n```')
|
||||
expect(mermaidCount(tree)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a streaming (unterminated) mermaid fence as raw code', () => {
|
||||
const tree = render('```mermaid\ngraph TD; A-->B')
|
||||
expect(mermaidCount(tree)).toBe(0)
|
||||
@@ -75,9 +70,4 @@ describe('MobileMarkdown mermaid routing', () => {
|
||||
)
|
||||
expect(firstMermaid(tree)).not.toBe(initial)
|
||||
})
|
||||
|
||||
it('does not route other code fences to MermaidDiagram', () => {
|
||||
const tree = render('```ts\nconst a = 1\n```')
|
||||
expect(mermaidCount(tree)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,15 +34,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('troubleshooting paired endpoints', () => {
|
||||
it('loads endpoint advice before the user runs diagnostics', async () => {
|
||||
hosts.catalog.mockResolvedValue([{ endpoint: 'ws://desktop.local:6769' }])
|
||||
await act(async () => {
|
||||
tree = create(createElement(Harness))
|
||||
})
|
||||
expect(state.current?.diagnosticStatus).toBe('idle')
|
||||
expect(state.current?.hostEndpoints).toEqual(['ws://desktop.local:6769'])
|
||||
})
|
||||
|
||||
it('keeps the newly probed endpoints when the initial catalog read finishes later', async () => {
|
||||
let finishCatalog: (value: { endpoint: string }[]) => void = () => {}
|
||||
hosts.catalog
|
||||
|
||||
@@ -55,25 +55,6 @@ describe('Markdown preview images', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('draws an SVG from its source text, since hosts read SVG as text', async () => {
|
||||
const markup = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4"/>'
|
||||
const client = {
|
||||
sendRequest: vi.fn(async () => ({
|
||||
id: '1',
|
||||
ok: true as const,
|
||||
_meta: { runtimeId: 'remote' },
|
||||
result: { content: markup, truncated: false, byteLength: markup.length }
|
||||
}))
|
||||
}
|
||||
await expect(
|
||||
loadMarkdownPreviewImage(client, 'wt', 'docs/readme.md', 'logo.svg')
|
||||
).resolves.toBe(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`)
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.read', {
|
||||
worktree: 'id:wt',
|
||||
relativePath: 'docs/logo.svg'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets native Image load public URLs without sending them to the workspace host', async () => {
|
||||
const client = host()
|
||||
await expect(
|
||||
@@ -81,10 +62,4 @@ describe('Markdown preview images', () => {
|
||||
).resolves.toBe('https://example.com/a.png')
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a disconnected host instead of trying to read phone-local files', async () => {
|
||||
await expect(loadMarkdownPreviewImage(null, 'wt', 'readme.md', 'a.png')).rejects.toThrow(
|
||||
'Waiting for desktop'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostProfile } from '../transport/types'
|
||||
import { MobileHomeTasksAndAutomations } from './MobileHomeTasksAndAutomations'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
CalendarClock: 'CalendarClock',
|
||||
ChevronRight: 'ChevronRight',
|
||||
ListTodo: 'ListTodo'
|
||||
}))
|
||||
vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' }))
|
||||
|
||||
function host(id: string): HostProfile {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
endpoint: `ws://${id}.local:6768`,
|
||||
deviceToken: `token-${id}`,
|
||||
publicKeyB64: `key-${id}`,
|
||||
lastConnected: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('Home Tasks and Automations sections', () => {
|
||||
let tree: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => tree?.unmount())
|
||||
tree = null
|
||||
})
|
||||
|
||||
function render(connectedHosts: HostProfile[]) {
|
||||
const callbacks = { onOpenTasks: vi.fn(), onOpenAutomations: vi.fn() }
|
||||
act(() => {
|
||||
tree = create(createElement(MobileHomeTasksAndAutomations, { connectedHosts, ...callbacks }))
|
||||
})
|
||||
return callbacks
|
||||
}
|
||||
|
||||
function row(label: string): ReactTestInstance {
|
||||
return tree!.root
|
||||
.findAll((node) => String(node.type) === 'Pressable')
|
||||
.find((node) =>
|
||||
node
|
||||
.findAll((child) => String(child.type) === 'Text')
|
||||
.some((child) => child.props.children === label)
|
||||
)!
|
||||
}
|
||||
|
||||
function picker(title: string): ReactTestInstance {
|
||||
return tree!.root
|
||||
.findAll((node) => String(node.type) === 'PickerModal')
|
||||
.find((node) => node.props.title === title)!
|
||||
}
|
||||
|
||||
it('opens the only connected host from the Automations row, placed right after Tasks', () => {
|
||||
const callbacks = render([host('desk')])
|
||||
const rows = tree!.root.findAll((node) => String(node.type) === 'Pressable')
|
||||
expect(rows.indexOf(row('Automations'))).toBe(rows.indexOf(row('Tasks')) + 1)
|
||||
expect(row('Automations').props.disabled).toBe(false)
|
||||
act(() => row('Automations').props.onPress())
|
||||
expect(callbacks.onOpenAutomations).toHaveBeenCalledExactlyOnceWith('desk')
|
||||
expect(callbacks.onOpenTasks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the automation host choice independent and disables both rows without a connection', () => {
|
||||
const callbacks = render([host('desk'), host('laptop')])
|
||||
act(() => row('Automations').props.onPress())
|
||||
expect(picker('Open Automations On').props.visible).toBe(true)
|
||||
expect(picker('Open Tasks On').props.visible).toBe(false)
|
||||
act(() => picker('Open Automations On').props.onSelect('laptop'))
|
||||
act(() => picker('Open Automations On').props.onAfterClose())
|
||||
expect(callbacks.onOpenAutomations).toHaveBeenCalledExactlyOnceWith('laptop')
|
||||
expect(callbacks.onOpenTasks).not.toHaveBeenCalled()
|
||||
act(() => tree?.unmount())
|
||||
render([])
|
||||
expect(row('Tasks').props.disabled).toBe(true)
|
||||
expect(row('Automations').props.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,7 @@ import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { AppState } from 'react-native'
|
||||
import {
|
||||
DEFAULT_NOTIFICATION_DELIVERY,
|
||||
loadNotificationDeliveryPreferences,
|
||||
notificationPreferencesFilter,
|
||||
saveNotificationDeliveryPreferences
|
||||
loadNotificationDeliveryPreferences
|
||||
} from './notification-delivery-preferences'
|
||||
import {
|
||||
setNotificationViewingWorkspace,
|
||||
@@ -14,10 +12,7 @@ import {
|
||||
const storage = new Map<string, string>()
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
})
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null)
|
||||
}
|
||||
}))
|
||||
vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
|
||||
@@ -27,45 +22,6 @@ beforeEach(() => {
|
||||
AppState.currentState = 'background'
|
||||
})
|
||||
|
||||
it('persists only phone-specific delivery preferences', async () => {
|
||||
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
|
||||
const value = {
|
||||
...DEFAULT_NOTIFICATION_DELIVERY,
|
||||
onlyWhenDesktopAway: false,
|
||||
sound: false
|
||||
}
|
||||
await saveNotificationDeliveryPreferences(value)
|
||||
expect(await loadNotificationDeliveryPreferences()).toEqual(value)
|
||||
expect(notificationPreferencesFilter(value)).toEqual({
|
||||
onlyWhenDesktopAway: false,
|
||||
onlyWhenAgentNeedsInput: false,
|
||||
sound: false
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores unrelated stored preferences', async () => {
|
||||
storage.set(
|
||||
'orca:notificationDeliveryPreferences',
|
||||
JSON.stringify({
|
||||
onlyWhenDesktopAway: false,
|
||||
sound: false,
|
||||
suppressWhileViewing: false,
|
||||
unrelatedSetting: false
|
||||
})
|
||||
)
|
||||
expect(await loadNotificationDeliveryPreferences()).toEqual({
|
||||
onlyWhenDesktopAway: false,
|
||||
onlyWhenAgentNeedsInput: false,
|
||||
sound: false,
|
||||
suppressWhileViewing: false
|
||||
})
|
||||
expect(notificationPreferencesFilter(await loadNotificationDeliveryPreferences())).toEqual({
|
||||
onlyWhenDesktopAway: false,
|
||||
onlyWhenAgentNeedsInput: false,
|
||||
sound: false
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses only the workspace being viewed on this phone, and never while backgrounded', async () => {
|
||||
const event = { source: 'terminal-bell', worktreeId: 'folder-id' }
|
||||
setNotificationViewingWorkspace({ hostId: 'ssh-host', worktreeId: 'folder-id' })
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host'
|
||||
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
|
||||
import type { ActionSheetAction } from '../components/ActionSheetModal'
|
||||
|
||||
const sheets = vi.hoisted(() => ({ labelsByTitle: new Map<string, string[]>() }))
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Copy: 'Icon',
|
||||
FileText: 'Icon',
|
||||
Globe: 'Icon',
|
||||
RefreshCw: 'Icon',
|
||||
SquareTerminal: 'Icon'
|
||||
}))
|
||||
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({}) }))
|
||||
vi.mock('../platform/haptics', () => ({ triggerSuccess: () => {}, triggerError: () => {} }))
|
||||
vi.mock('../components/ActionSheetModal', () => ({
|
||||
ActionSheetModal: ({ title, actions }: { title: string; actions: ActionSheetAction[] }) => {
|
||||
sheets.labelsByTitle.set(
|
||||
title,
|
||||
actions.map((action) => action.label)
|
||||
)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('../components/TextInputModal', () => ({ TextInputModal: () => null }))
|
||||
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: () => null }))
|
||||
vi.mock('../components/CustomKeyModal', () => ({ CustomKeyModal: () => null }))
|
||||
vi.mock('../components/MobileDictationSetupSheet', () => ({
|
||||
MobileDictationSetupSheet: () => null
|
||||
}))
|
||||
vi.mock('./MobileSessionTerminalLayoutSheet', () => ({
|
||||
MobileSessionTerminalLayoutSheet: () => null
|
||||
}))
|
||||
vi.mock('./QuickCommandsSheet', () => ({ QuickCommandsSheet: () => null }))
|
||||
vi.mock('./MobileBrowserTabActionSheet', () => ({ MobileBrowserTabActionSheet: () => null }))
|
||||
vi.mock('./mobile-terminal-action-sheet-actions', () => ({
|
||||
getMobileTerminalActionSheetActions: () => []
|
||||
}))
|
||||
|
||||
import { MobileSessionSheets } from './MobileSessionSheets'
|
||||
|
||||
async function newTabLabels(executionHost?: string): Promise<string[]> {
|
||||
sheets.labelsByTitle.clear()
|
||||
const controller = mountFixture<Parameters<typeof MobileSessionSheets>[0]['controller']>({
|
||||
worktreeId: 'repo::/wt',
|
||||
isFolderWorkspaceRoute: false,
|
||||
isFloatingWorkspaceRoute: false,
|
||||
sessionTabs: [],
|
||||
nativeChatController: { isTabChatView: () => false },
|
||||
createTabAgentActions: [],
|
||||
sendDiffNotesAgentActions: [],
|
||||
closeWithBulkActions: () => []
|
||||
})
|
||||
await act(async () => {
|
||||
create(
|
||||
createElement(
|
||||
WorkspaceExecutionHostContext.Provider,
|
||||
{ value: normalizeExecutionHostId(executionHost) ?? undefined },
|
||||
createElement(MobileSessionSheets, { controller })
|
||||
)
|
||||
)
|
||||
})
|
||||
return sheets.labelsByTitle.get('New Tab') ?? []
|
||||
}
|
||||
|
||||
describe('the New Tab sheet', () => {
|
||||
it('offers Markdown Note for a workspace the desktop hosts', async () => {
|
||||
expect(await newTabLabels()).toContain('Markdown Note')
|
||||
})
|
||||
|
||||
it('leaves out Markdown Note for a server workspace, whose host opens no editor tabs', async () => {
|
||||
const labels = await newTabLabels('runtime:vm')
|
||||
expect(labels).toEqual(['Terminal', 'Browser'])
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
|
||||
const layout = vi.hoisted(() => ({ sideRail: false, padAvailable: false, os: 'ios' }))
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'web' } }))
|
||||
vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => null }))
|
||||
vi.mock('../platform/host-os', () => ({ hostOs: () => layout.os }))
|
||||
vi.mock('../layout/use-shell-side-rail', () => ({
|
||||
useShellSideRail: () => layout.sideRail,
|
||||
useDirectionPadAvailable: () => layout.padAvailable
|
||||
}))
|
||||
import { useMobileSessionPresentation } from './use-mobile-session-presentation'
|
||||
|
||||
type Scope = Parameters<typeof useMobileSessionPresentation>[0]
|
||||
let tree: ReactTestRenderer | undefined
|
||||
afterEach(() => {
|
||||
act(() => tree?.unmount())
|
||||
tree = undefined
|
||||
Object.assign(layout, { sideRail: false, padAvailable: false, os: 'ios' })
|
||||
})
|
||||
it.each(['ios', 'android'])(
|
||||
'keeps the keyboard boundary aligned across Duo posture changes on %s',
|
||||
(os) => {
|
||||
layout.os = os
|
||||
const scope = mountFixture<Scope>({
|
||||
client: null,
|
||||
connState: 'connected',
|
||||
activeHandle: 'a',
|
||||
terminalsLoaded: true,
|
||||
visibleTabs: [{ id: 'a', type: 'terminal', terminal: 'a' }],
|
||||
insets: { top: 0, left: 0, right: 0, bottom: 34 },
|
||||
keyboardHeight: 334,
|
||||
terminalKeyboardMetrics: new Map(),
|
||||
terminalFrameRef: { current: null },
|
||||
toastOpacityRef: { current: {} }
|
||||
})
|
||||
function Screen({ keyboardHeight, view }: { keyboardHeight: number; view: string }) {
|
||||
const scene = mountFixture<Scope>({
|
||||
showNativeChat: view === 'chat',
|
||||
activeMarkdownTab: view === 'markdown' ? {} : null,
|
||||
activeFileTab: view === 'file' ? {} : null,
|
||||
activeBrowserTab: view === 'browser' ? {} : null
|
||||
})
|
||||
const value = useMobileSessionPresentation({ ...scope, ...scene, keyboardHeight })
|
||||
return createElement('KeyboardBoundary', value)
|
||||
}
|
||||
const render = (keyboardHeight = 334, view = 'terminal') =>
|
||||
act(() => {
|
||||
if (tree) {
|
||||
tree.update(createElement(Screen, { keyboardHeight, view }))
|
||||
} else {
|
||||
tree = create(createElement(Screen, { keyboardHeight, view }))
|
||||
}
|
||||
})
|
||||
const lift = () =>
|
||||
tree!.root.find((node) => String(node.type) === 'KeyboardBoundary').props.keyboardLift
|
||||
render()
|
||||
expect(lift()).toBe(os === 'ios' ? 300 : 334)
|
||||
layout.sideRail = true
|
||||
render()
|
||||
expect(lift()).toBe(334)
|
||||
layout.sideRail = false
|
||||
layout.padAvailable = true
|
||||
render()
|
||||
expect(lift()).toBe(334)
|
||||
for (const view of ['chat', 'browser', 'markdown', 'file']) {
|
||||
render(334, view)
|
||||
expect(lift()).toBe(os === 'ios' ? 300 : 334)
|
||||
render()
|
||||
expect(lift()).toBe(334)
|
||||
}
|
||||
render(0)
|
||||
expect(lift()).toBe(0)
|
||||
layout.padAvailable = false
|
||||
render()
|
||||
expect(lift()).toBe(os === 'ios' ? 300 : 334)
|
||||
}
|
||||
)
|
||||
@@ -1,91 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Keyboard } from 'react-native'
|
||||
import { MobileNativeChatAsk } from './input/MobileNativeChatAsk'
|
||||
import { MobileNativeChatPermission } from './input/MobileNativeChatPermission'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View',
|
||||
Platform: { OS: 'ios', select: (o: Record<string, unknown>) => o.ios }
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Check: 'Check',
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronUp: 'ChevronUp',
|
||||
ShieldQuestion: 'ShieldQuestion',
|
||||
X: 'X'
|
||||
}))
|
||||
vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' }))
|
||||
|
||||
describe('rendered prompt card collapse controls', () => {
|
||||
it('collapses an Ask from its header chevron without cancelling or answering it', async () => {
|
||||
const onCollapse = vi.fn()
|
||||
const onCancel = vi.fn(async () => true)
|
||||
const onAnswer = vi.fn(async () => true)
|
||||
let tree: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
tree = create(
|
||||
createElement(MobileNativeChatAsk, {
|
||||
prompt: {
|
||||
questions: [{ question: 'Pick?', options: [{ label: 'East' }], multiSelect: false }]
|
||||
},
|
||||
onAnswer,
|
||||
onCancel,
|
||||
onCollapse
|
||||
})
|
||||
)
|
||||
})
|
||||
act(() => tree!.root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress())
|
||||
expect(onCollapse).toHaveBeenCalledOnce()
|
||||
expect(Keyboard.dismiss).toHaveBeenCalledOnce()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
expect(onAnswer).not.toHaveBeenCalled()
|
||||
act(() => tree!.unmount())
|
||||
})
|
||||
|
||||
it('shows Collapse in a terminal permission header and keeps Cancel where the lane can cancel', async () => {
|
||||
const permission = {
|
||||
title: 'Allow Bash?',
|
||||
options: [
|
||||
{ label: 'Allow', send: '1' },
|
||||
{ label: 'Deny', send: '\x1b' }
|
||||
]
|
||||
}
|
||||
const onCollapse = vi.fn()
|
||||
const onCancel = vi.fn(async () => true)
|
||||
let tree: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
tree = create(
|
||||
createElement(MobileNativeChatPermission, {
|
||||
permission,
|
||||
onRespond: async () => true,
|
||||
onCollapse
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(tree!.root.findAllByProps({ accessibilityLabel: 'Cancel' })).toHaveLength(0)
|
||||
act(() => tree!.root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress())
|
||||
expect(onCollapse).toHaveBeenCalledOnce()
|
||||
await act(async () => {
|
||||
tree!.update(
|
||||
createElement(MobileNativeChatPermission, {
|
||||
permission,
|
||||
onRespond: async () => true,
|
||||
onCancel,
|
||||
onCollapse
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(tree!.root.findAllByProps({ accessibilityLabel: 'Collapse' })).toHaveLength(0)
|
||||
act(() => tree!.root.findByProps({ accessibilityLabel: 'Cancel' }).props.onPress())
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
act(() => tree!.unmount())
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
installChatViewHooks,
|
||||
assistantTurn,
|
||||
listIds,
|
||||
render,
|
||||
renderedRow,
|
||||
update
|
||||
} from './MobileNativeChatView.harness.test-fixture'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const { reactNativeStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
|
||||
return reactNativeStub()
|
||||
})
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('react-native-gesture-handler', async () => {
|
||||
const { gestureHandlerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
|
||||
return gestureHandlerStub()
|
||||
})
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowDown: 'ArrowDown',
|
||||
ChevronsDownUp: 'ChevronsDownUp',
|
||||
ChevronsUpDown: 'ChevronsUpDown',
|
||||
Square: 'Square'
|
||||
}))
|
||||
vi.mock('./transcript/MobileNativeChatMessage', () => ({
|
||||
MobileNativeChatMessage: 'ChatMessage'
|
||||
}))
|
||||
vi.mock('./transcript/MobileNativeChatLiveLine', () => ({
|
||||
MobileNativeChatLiveLine: 'LiveStatus'
|
||||
}))
|
||||
vi.mock('./input/MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
|
||||
vi.mock('./input/MobileNativeChatPermission', () => ({
|
||||
MobileNativeChatPermission: 'ChatPermission'
|
||||
}))
|
||||
vi.mock('./input/MobileNativeChatQuestion', () => ({
|
||||
MobileNativeChatQuestion: 'ChatQuestion'
|
||||
}))
|
||||
vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
|
||||
vi.mock('./composer/MobileNativeChatComposer', async () => {
|
||||
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
|
||||
return composerStub()
|
||||
})
|
||||
|
||||
describe('MobileNativeChatView: rows and the streaming bubble', () => {
|
||||
installChatViewHooks()
|
||||
|
||||
// Streaming eligibility is tested in the overlay, which stays mounted across chat-view toggles.
|
||||
it('appends the gated streaming bubble after the folded transcript', async () => {
|
||||
const folded = [assistantTurn('a1', 'The tests pass.')]
|
||||
await render({ folded })
|
||||
expect(listIds()).toEqual(['a1'])
|
||||
|
||||
await update({ folded, streaming: 'The tests' })
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('tells a row whether the host still waits on the prompt it stands for', async () => {
|
||||
const rows = [assistantTurn('asked', 'Tea or coffee?'), assistantTurn('open', 'Which branch?')]
|
||||
await render({ messages: rows, folded: rows, actionablePromptIds: ['open'] })
|
||||
expect(renderedRow('asked').props.awaiting).toBe(false)
|
||||
expect(renderedRow('open').props.awaiting).toBe(true)
|
||||
})
|
||||
|
||||
it('renders an accepted optimistic image send without a queued state', async () => {
|
||||
await render({
|
||||
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
|
||||
})
|
||||
|
||||
expect(listIds()).toEqual(['pending-1'])
|
||||
expect(renderedRow('pending-1').props).not.toHaveProperty('queued')
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../MobileQueuedMessageComposer', () => ({
|
||||
MobileQueuedMessageComposer: 'QueuedMessageComposer'
|
||||
}))
|
||||
@@ -77,14 +77,4 @@ describe('useMobileNativeChatSkills', () => {
|
||||
expect(sendRequest).toHaveBeenCalledExactlyOnceWith('skills.discover', { worktreeId: 'wt-1' })
|
||||
expect(state?.nativeChatSkills.skills.map((entry) => entry.name)).toEqual(['shared'])
|
||||
})
|
||||
|
||||
it('lists none from a host that needs the folder the phone does not know', async () => {
|
||||
const sendRequest = vi.fn(async () => reply)
|
||||
await mount(fakeClient(sendRequest), false)
|
||||
|
||||
await act(async () => state?.loadNativeChatSkills())
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
expect(state?.nativeChatSkills.skills).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatQuestionAnswer,
|
||||
formatQuestionFreeTextAnswer,
|
||||
mobileChatQuestionKey,
|
||||
type MobileChatQuestion
|
||||
} from './mobile-native-chat-question'
|
||||
|
||||
describe('formatQuestionAnswer', () => {
|
||||
const numbered: MobileChatQuestion = {
|
||||
question: 'Pick',
|
||||
options: ['Alpha', 'Beta', 'Gamma'],
|
||||
multiSelect: false,
|
||||
optionTokens: ['1', '2', '3']
|
||||
}
|
||||
|
||||
it('echoes the leading token for single-select', () => {
|
||||
expect(formatQuestionAnswer(numbered, ['Beta'])).toBe('2')
|
||||
})
|
||||
|
||||
it('comma-joins tokens for multi-select', () => {
|
||||
const multi: MobileChatQuestion = { ...numbered, multiSelect: true }
|
||||
expect(formatQuestionAnswer(multi, ['Alpha', 'Gamma'])).toBe('1, 3')
|
||||
})
|
||||
|
||||
it('sends the label text when the option had no token (bullet list)', () => {
|
||||
const bullets: MobileChatQuestion = {
|
||||
question: 'Pick',
|
||||
options: ['Rebase', 'Merge'],
|
||||
multiSelect: false,
|
||||
optionTokens: [null, null]
|
||||
}
|
||||
expect(formatQuestionAnswer(bullets, ['Merge'])).toBe('Merge')
|
||||
})
|
||||
|
||||
it('echoes letter tokens', () => {
|
||||
const lettered: MobileChatQuestion = {
|
||||
question: 'Pick',
|
||||
options: ['Alpha', 'Beta'],
|
||||
multiSelect: false,
|
||||
optionTokens: ['a', 'b']
|
||||
}
|
||||
expect(formatQuestionAnswer(lettered, ['Beta'])).toBe('b')
|
||||
})
|
||||
|
||||
it('passes free-text / unknown entries through verbatim', () => {
|
||||
expect(formatQuestionAnswer(numbered, ['something custom'])).toBe('something custom')
|
||||
})
|
||||
|
||||
it('returns empty string when nothing is selected', () => {
|
||||
expect(formatQuestionAnswer(numbered, [])).toBe('')
|
||||
expect(formatQuestionAnswer(numbered, [' '])).toBe('')
|
||||
})
|
||||
|
||||
it('prefixes free-text answers with an opaque prompt token when provided', () => {
|
||||
expect(
|
||||
formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ')
|
||||
).toBe(`target:${encodeURIComponent('hi there')}`)
|
||||
})
|
||||
|
||||
it('encodes empty and whitespace answers only when allowed', () => {
|
||||
const withInput: MobileChatQuestion = {
|
||||
...numbered,
|
||||
freeTextToken: 'target',
|
||||
freeTextInput: { allowEmpty: true }
|
||||
}
|
||||
expect(formatQuestionFreeTextAnswer(withInput, '')).toBe('target:')
|
||||
expect(formatQuestionFreeTextAnswer(withInput, ' \n ')).toBe(
|
||||
`target:${encodeURIComponent(' \n ')}`
|
||||
)
|
||||
expect(formatQuestionFreeTextAnswer({ ...withInput, freeTextInput: undefined }, ' \n ')).toBe(
|
||||
''
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobileChatQuestionKey', () => {
|
||||
it('changes when a replacement prompt changes any positional content', () => {
|
||||
const first: MobileChatQuestion = {
|
||||
question: 'Pick',
|
||||
options: ['A', 'B'],
|
||||
multiSelect: true,
|
||||
optionTokens: ['1', '2']
|
||||
}
|
||||
expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe(
|
||||
mobileChatQuestionKey(first)
|
||||
)
|
||||
expect(mobileChatQuestionKey({ ...first, freeTextToken: 'target-2' })).not.toBe(
|
||||
mobileChatQuestionKey(first)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../src/shared/agent-launch/s
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import {
|
||||
clearMobileNativeChatInputStale,
|
||||
healMobileNativeChatStaleInput,
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale,
|
||||
@@ -27,14 +26,6 @@ describe('mobile native chat stale input markers', () => {
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
})
|
||||
|
||||
it('tracks each terminal independently', () => {
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||||
expect(isMobileNativeChatInputStale('term-2')).toBe(false)
|
||||
clearMobileNativeChatInputStale('term-1')
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('writes nothing when the terminal is not marked', async () => {
|
||||
const client = makeClient()
|
||||
await expect(
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
buildWorktreeSetHostedReviewLinkParams,
|
||||
fetchWorktreeGitHubPRLink,
|
||||
linkMobilePr
|
||||
} from './mobile-pr-link'
|
||||
|
||||
describe('buildWorktreeSetHostedReviewLinkParams', () => {
|
||||
it.each([
|
||||
['github', 'linkedPR'],
|
||||
['gitlab', 'linkedGitLabMR'],
|
||||
['bitbucket', 'linkedBitbucketPR'],
|
||||
['azure-devops', 'linkedAzureDevOpsPR'],
|
||||
['gitea', 'linkedGiteaPR']
|
||||
] as const)('maps %s reviews to the matching worktree field', (provider, key) => {
|
||||
expect(buildWorktreeSetHostedReviewLinkParams('repo42::/p', provider, 12)).toEqual({
|
||||
worktree: 'id:repo42::/p',
|
||||
[key]: 12
|
||||
})
|
||||
})
|
||||
|
||||
it('includes a submitted base ref so mobile diff review refreshes against the review base', () => {
|
||||
expect(
|
||||
buildWorktreeSetHostedReviewLinkParams('repo42::/p', 'github', 12, {
|
||||
baseRef: ' origin/release '
|
||||
})
|
||||
).toEqual({
|
||||
worktree: 'id:repo42::/p',
|
||||
baseRef: 'origin/release',
|
||||
linkedPR: 12
|
||||
})
|
||||
})
|
||||
|
||||
it('does not invent a metadata field for unsupported providers', () => {
|
||||
expect(buildWorktreeSetHostedReviewLinkParams('repo42::/p', 'unsupported', 12)).toEqual({
|
||||
worktree: 'id:repo42::/p'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchWorktreeGitHubPRLink', () => {
|
||||
const client = (result: unknown, okFlag = true) =>
|
||||
({
|
||||
sendRequest: vi.fn(async () =>
|
||||
okFlag ? { ok: true, result } : { ok: false, error: { message: 'x' } }
|
||||
)
|
||||
}) as unknown as Pick<RpcClient, 'sendRequest'>
|
||||
const none = { linkedPR: null, suppressedGitHubPR: null, pushTarget: null }
|
||||
|
||||
it('returns the linked and suppressed PR numbers when present', async () => {
|
||||
expect(
|
||||
await fetchWorktreeGitHubPRLink(
|
||||
client({ worktree: { linkedPR: null, suppressedGitHubPR: 12 } }),
|
||||
'w'
|
||||
)
|
||||
).toMatchObject({ linkedPR: null, suppressedGitHubPR: 12, pushTarget: null })
|
||||
})
|
||||
|
||||
it('returns nulls when unset, null, or non-numeric', async () => {
|
||||
expect(await fetchWorktreeGitHubPRLink(client({ worktree: {} }), 'w')).toMatchObject(none)
|
||||
expect(
|
||||
await fetchWorktreeGitHubPRLink(client({ worktree: { linkedPR: 'x' } }), 'w')
|
||||
).toMatchObject(none)
|
||||
})
|
||||
|
||||
it('returns nulls when the request fails or rejects', async () => {
|
||||
expect(await fetchWorktreeGitHubPRLink(client(null, false), 'w')).toEqual(none)
|
||||
const rejecting = {
|
||||
sendRequest: vi.fn(async () => {
|
||||
throw new Error('transport closed')
|
||||
})
|
||||
} as unknown as Pick<RpcClient, 'sendRequest'>
|
||||
expect(await fetchWorktreeGitHubPRLink(rejecting, 'w')).toEqual(none)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkMobilePr transport rejection', () => {
|
||||
it('normalizes a thrown sendRequest into { ok:false, error }', async () => {
|
||||
const rejecting = {
|
||||
sendRequest: vi.fn(async () => {
|
||||
throw new Error('socket hung up')
|
||||
})
|
||||
} as unknown as Pick<RpcClient, 'sendRequest'>
|
||||
expect(await linkMobilePr(rejecting, 'w', 5)).toEqual({ ok: false, error: 'socket hung up' })
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HostedReviewDecision } from '../../../src/shared/hosted-review'
|
||||
import {
|
||||
getHostedChecksLabel,
|
||||
getHostedMergeLabel,
|
||||
getHostedReviewLabel,
|
||||
getHostedReviewSignalTone
|
||||
} from './mobile-hosted-check-status'
|
||||
|
||||
describe('mobile hosted check status', () => {
|
||||
it.each(['merged', 'closed'] as const)('shows %s before stale merge enrichment', (state) => {
|
||||
expect(getHostedMergeLabel({ state, mergeable: 'CONFLICTING' })).toBe(
|
||||
state === 'merged' ? 'Merged' : 'Closed'
|
||||
)
|
||||
expect(getHostedReviewSignalTone({ state, mergeable: 'CONFLICTING' }, 'merge')).toBe('neutral')
|
||||
})
|
||||
|
||||
it('does not call pending checks Behind', () => {
|
||||
const item = {
|
||||
mergeable: 'MERGEABLE' as const,
|
||||
checksSummary: {
|
||||
state: 'pending' as const,
|
||||
total: 1,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
pending: 1,
|
||||
neutral: 0
|
||||
}
|
||||
}
|
||||
expect(getHostedMergeLabel(item)).toBe('Checks pending')
|
||||
expect(getHostedReviewSignalTone(item, 'merge')).toBe('warning')
|
||||
})
|
||||
|
||||
it('renders a hydrated neutral GitLab summary as unresolved checks', () => {
|
||||
const summary = {
|
||||
state: 'neutral' as const,
|
||||
total: 2,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
neutral: 1
|
||||
}
|
||||
expect(getHostedChecksLabel({ checksSummary: summary })).toBe('Unresolved checks')
|
||||
expect(getHostedReviewSignalTone({ checksSummary: summary }, 'checks')).toBe('neutral')
|
||||
})
|
||||
|
||||
it('accepts provider-neutral GitHub status fields without a shared work-item type', () => {
|
||||
expect(
|
||||
getHostedReviewSignalTone(
|
||||
{ reviewDecision: 'approved', reviewRequests: [], mergeable: 'UNKNOWN' },
|
||||
'review'
|
||||
)
|
||||
).toBe('success')
|
||||
})
|
||||
|
||||
it('renders hydrated GitLab approval, merge, and check states', () => {
|
||||
expect(getHostedReviewLabel({ reviewDecision: 'review_required', reviewerCount: 2 })).toBe(
|
||||
'Review required'
|
||||
)
|
||||
expect(
|
||||
getHostedReviewSignalTone(
|
||||
{
|
||||
reviewDecision: 'review_required',
|
||||
reviewerCount: 2,
|
||||
mergeable: 'MERGEABLE',
|
||||
checksSummary: {
|
||||
state: 'success',
|
||||
total: 1,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
neutral: 0
|
||||
}
|
||||
},
|
||||
'review'
|
||||
)
|
||||
).toBe('warning')
|
||||
expect(getHostedReviewSignalTone({ mergeable: 'MERGEABLE' }, 'merge')).toBe('success')
|
||||
expect(getHostedReviewSignalTone({ reviewDecision: 'approved' }, 'review')).toBe('success')
|
||||
expect(getHostedReviewSignalTone({ mergeable: 'CONFLICTING' }, 'merge')).toBe('danger')
|
||||
})
|
||||
|
||||
it('renders a shared typed GitLab review decision', () => {
|
||||
const reviewDecision: HostedReviewDecision = 'changes_requested'
|
||||
|
||||
expect(getHostedReviewLabel({ reviewDecision })).toBe('Changes requested')
|
||||
expect(getHostedReviewSignalTone({ reviewDecision }, 'review')).toBe('danger')
|
||||
})
|
||||
|
||||
it('keeps missing GitLab enrichment neutral', () => {
|
||||
expect(getHostedReviewLabel({})).toBe('No reviewers')
|
||||
expect(getHostedReviewSignalTone({}, 'review')).toBe('neutral')
|
||||
expect(getHostedReviewSignalTone({}, 'merge')).toBe('neutral')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isSetupHookTrusted,
|
||||
normalizeSetupHookTrust,
|
||||
persistSetupHookTrustApproval,
|
||||
wasSetupHookPreviouslyApproved
|
||||
} from './setup-hook-trust'
|
||||
import { isSetupHookTrusted, persistSetupHookTrustApproval } from './setup-hook-trust'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
@@ -18,35 +13,6 @@ describe('setup hook trust', () => {
|
||||
expect(isSetupHookTrusted(trust, [{ id: 'repo-1' }], 'repo-1', 'hash-2')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an always-trusted repo as trusted for changed setup scripts', () => {
|
||||
const trust: PersistedTrustedOrcaHooks = {
|
||||
'repo-1': { all: { approvedAt: 1000 } }
|
||||
}
|
||||
|
||||
expect(isSetupHookTrusted(trust, [{ id: 'repo-1' }], 'repo-1', 'new-hash')).toBe(true)
|
||||
})
|
||||
|
||||
it('persists and returns the approved trust state', async () => {
|
||||
let persisted: unknown
|
||||
const client = {
|
||||
sendRequest: async (_method: string, params: unknown) => {
|
||||
persisted = params
|
||||
return { ok: true, result: null }
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
|
||||
const next = await persistSetupHookTrustApproval({
|
||||
client,
|
||||
trust: {},
|
||||
repoId: 'repo-1',
|
||||
contentHash: 'setup-hash',
|
||||
alwaysTrust: false
|
||||
})
|
||||
|
||||
expect(persisted).toEqual({ trustedOrcaHooks: next })
|
||||
expect(isSetupHookTrusted(next, [{ id: 'repo-1' }], 'repo-1', 'setup-hash')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the approved hash with Always trust so a repo on two hosts stops prompting', async () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: persisting only calls sendRequest.
|
||||
const client = {
|
||||
@@ -65,20 +31,4 @@ describe('setup hook trust', () => {
|
||||
expect(isSetupHookTrusted(next, repos, 'repo-1', 'setup-hash')).toBe(true)
|
||||
expect(isSetupHookTrusted(next, repos, 'repo-1', 'edited-hash')).toBe(false)
|
||||
})
|
||||
|
||||
it('detects previous setup approval and ignores incomplete trust payloads', () => {
|
||||
expect(
|
||||
wasSetupHookPreviouslyApproved(
|
||||
{ 'repo-1': { setup: { contentHash: 'hash-1', approvedAt: 1000 } } },
|
||||
'repo-1'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(normalizeSetupHookTrust({ contentHash: 'hash-1', scriptContent: '' })).toBe(null)
|
||||
expect(
|
||||
normalizeSetupHookTrust({ contentHash: 'hash-1', scriptContent: 'pnpm install' })
|
||||
).toEqual({
|
||||
contentHash: 'hash-1',
|
||||
scriptContent: 'pnpm install'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { terminalInputSend } from './mobile-terminal-operations'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
function captureClient(
|
||||
result: Promise<unknown> = Promise.resolve({
|
||||
@@ -27,20 +26,6 @@ const BASE_ARGS = {
|
||||
} as const
|
||||
|
||||
describe('terminal live accessory raw send', () => {
|
||||
it('sends raw bytes now-or-never with the device presence tag', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{ terminal: 'terminal-a', text: '', enter: false, client: { id: 'tok', type: 'mobile' } },
|
||||
// Never park accessory keys to fire into a reconnected PTY long after the tap.
|
||||
{ failWhenDisconnected: true }
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops the bytes instead of sending while disconnected', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
@@ -60,22 +45,4 @@ describe('terminal live accessory raw send', () => {
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a rejected send without surfacing transport errors', async () => {
|
||||
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
|
||||
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('reports a fulfilled RPC failure as a failed send', async () => {
|
||||
const response: RpcResponse = {
|
||||
id: 'send',
|
||||
ok: false,
|
||||
error: { code: 'terminal_error', message: 'failed' },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
}
|
||||
const { client } = captureClient(Promise.resolve(response))
|
||||
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,12 +23,6 @@ describe('decoding a bundle range', () => {
|
||||
expect(decodeMobileWebBundleRange(RANGE, GZIP, RAW.byteLength)).toEqual(RAW)
|
||||
})
|
||||
|
||||
it('passes an identity range through', () => {
|
||||
expect(
|
||||
decodeMobileWebBundleRange({ ...RANGE, encoding: 'identity' }, RAW, RAW.byteLength)
|
||||
).toEqual(RAW)
|
||||
})
|
||||
|
||||
it('refuses a truncated gzip body', () => {
|
||||
expect(
|
||||
refusalOf(() =>
|
||||
|
||||
@@ -66,45 +66,6 @@ describe('spawnDetachedWithoutInheritance', () => {
|
||||
spawnProcess.mockReset()
|
||||
})
|
||||
|
||||
it('uses a detached, ignoring libuv spawn off Windows', () => {
|
||||
onPlatform('linux')
|
||||
const spawner = vi.fn<IsolatedSpawner>()
|
||||
setIsolatedSpawner(spawner)
|
||||
spawnProcess.mockReturnValue({ pid: 1 })
|
||||
|
||||
spawnDetachedWithoutInheritance({ program: '/bin/true', args: ['x'], cwd: '/' })
|
||||
|
||||
expect(spawner).not.toHaveBeenCalled()
|
||||
expect(spawnProcess).toHaveBeenCalledWith({
|
||||
program: '/bin/true',
|
||||
args: ['x'],
|
||||
cwd: '/',
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to libuv on Windows when no spawner is installed', () => {
|
||||
onPlatform('win32')
|
||||
spawnProcess.mockReturnValue({ pid: 1 })
|
||||
|
||||
spawnDetachedWithoutInheritance({ program: 'C:\\Windows\\System32\\wsl.exe' })
|
||||
|
||||
expect(spawnProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['C:\\tools\\run.cmd', 'wsl.exe'])('falls back to libuv for %s', (program) => {
|
||||
onPlatform('win32')
|
||||
const spawner = vi.fn<IsolatedSpawner>()
|
||||
setIsolatedSpawner(spawner)
|
||||
spawnProcess.mockReturnValue({ pid: 1 })
|
||||
|
||||
spawnDetachedWithoutInheritance({ program, env: { ComSpec: 'cmd.exe' } })
|
||||
|
||||
expect(spawner).not.toHaveBeenCalled()
|
||||
expect(spawnProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hands the native spawner a libuv-shaped command line and environment block', async () => {
|
||||
onPlatform('win32')
|
||||
let exit: (code: number) => void = () => {}
|
||||
@@ -161,24 +122,6 @@ describe('spawnDetachedWithoutInheritance', () => {
|
||||
expect(killed.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('logs a child whose breakaway from our job was denied, and still starts it', async () => {
|
||||
onPlatform('win32')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
setIsolatedSpawner(() => ({ pid: 7, exitWatchError: null, breakawayDenied: true }))
|
||||
|
||||
const child = spawnDetachedWithoutInheritance({ program: 'C:\\wsl.exe' })
|
||||
await once(child, 'spawn')
|
||||
|
||||
expect(child.pid).toBe(7)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[process-host] pid 7 could not break away from this process's job"
|
||||
)
|
||||
warn.mockClear()
|
||||
setIsolatedSpawner(() => ({ pid: 8, exitWatchError: null, breakawayDenied: false }))
|
||||
spawnDetachedWithoutInheritance({ program: 'C:\\wsl.exe' })
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to kill a child whose exit it cannot watch, since its pid may be reused', async () => {
|
||||
onPlatform('win32')
|
||||
setIsolatedSpawner(() => ({ pid: 77, exitWatchError: 5, breakawayDenied: false }))
|
||||
|
||||
@@ -46,19 +46,6 @@ describe('runProcess termination failure', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reports ordinary child close exactly once', async () => {
|
||||
const child = mockChild()
|
||||
const onChildTerminated = vi.fn()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const pending = runProcess({ program: 'git', timeoutMs: null, onChildTerminated })
|
||||
|
||||
child.emit('close', 0, null)
|
||||
child.emit('close', 0, null)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ code: 0 })
|
||||
expect(onChildTerminated).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not report a live-child error before its eventual close', async () => {
|
||||
const child = mockChild()
|
||||
const onChildTerminated = vi.fn()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
notifySpawnObserver,
|
||||
setSpawnObserver
|
||||
} from './spawn-observer'
|
||||
import { runProcess, runProcessSync, spawnProcess } from './run-process'
|
||||
|
||||
afterEach(() => {
|
||||
setSpawnObserver(null)
|
||||
@@ -19,87 +18,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('spawn observer', () => {
|
||||
it('reports the resolved program for a spawnProcess child', async () => {
|
||||
const seen: { command: string; args: readonly string[]; blockMs: number }[] = []
|
||||
setSpawnObserver((command, args, blockMs) => seen.push({ command, args, blockMs }))
|
||||
const child = spawnProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] })
|
||||
await new Promise((resolve) => child.on('exit', resolve))
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0].command).toBe(process.execPath)
|
||||
expect(seen[0].args).toEqual(['-e', 'process.exit(0)'])
|
||||
expect(seen[0].blockMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('reports exactly once for runProcess, which spawns through spawnProcess', async () => {
|
||||
let calls = 0
|
||||
setSpawnObserver(() => {
|
||||
calls += 1
|
||||
})
|
||||
await runProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] })
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('reports the whole blocking run of runProcessSync', () => {
|
||||
const seen: { command: string; blockMs: number }[] = []
|
||||
setSpawnObserver((command, _args, blockMs) => seen.push({ command, blockMs }))
|
||||
const result = runProcessSync({
|
||||
program: process.execPath,
|
||||
args: ['-e', 'setTimeout(() => process.exit(3), 50)']
|
||||
})
|
||||
expect(result.code).toBe(3)
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0].command).toBe(process.execPath)
|
||||
expect(seen[0].blockMs).toBeGreaterThanOrEqual(40)
|
||||
})
|
||||
|
||||
it('reports a runProcessSync launch that throws or times out', () => {
|
||||
const seen: string[] = []
|
||||
setSpawnObserver((command) => seen.push(command))
|
||||
const missing = path.join(tmpdir(), 'orca-spawn-observer-missing-program')
|
||||
expect(() => runProcessSync({ program: missing, args: [] })).toThrow()
|
||||
const timedOut = runProcessSync({
|
||||
program: process.execPath,
|
||||
args: ['-e', 'setTimeout(() => {}, 10_000)'],
|
||||
timeoutMs: 50
|
||||
})
|
||||
expect(timedOut.timedOut).toBe(true)
|
||||
expect(seen).toEqual([missing, process.execPath])
|
||||
})
|
||||
|
||||
it('stops reporting after the observer is cleared', async () => {
|
||||
let calls = 0
|
||||
setSpawnObserver(() => {
|
||||
calls += 1
|
||||
})
|
||||
setSpawnObserver(null)
|
||||
await runProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] })
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('logs every spawn, sync or async, to the e2e spawn log when one is named', async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'orca-spawn-log-'))
|
||||
const log = path.join(dir, 'spawns.jsonl')
|
||||
process.env[E2E_SPAWN_LOG_ENV] = log
|
||||
process.env[E2E_USER_DATA_DIR_ENV] = dir
|
||||
try {
|
||||
await runProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] })
|
||||
runProcessSync({ program: process.execPath, args: ['-e', 'process.exit(0)'] })
|
||||
const lines = readFileSync(log, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line))
|
||||
const spawned = {
|
||||
file: process.execPath,
|
||||
args: ['-e', 'process.exit(0)'],
|
||||
at: expect.any(Number),
|
||||
stack: expect.arrayContaining([expect.stringContaining('spawn-observer.test')])
|
||||
}
|
||||
expect(lines).toEqual([spawned, spawned])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('outside e2e mode writes nothing and captures no stack, even with the log variable set', () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'orca-spawn-log-'))
|
||||
const log = path.join(dir, 'spawns.jsonl')
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({ ipcRenderer: { invoke: vi.fn(), on: vi.fn() } }))
|
||||
|
||||
import { getWslHostsApi } from './wsl-hosts-api'
|
||||
|
||||
describe('WSL hosts preload API', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('MODE', 'e2e')
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('ignores the e2e fake outside an e2e build', () => {
|
||||
vi.stubEnv('MODE', 'production')
|
||||
const env = { ORCA_INTERNAL_WSL_HOSTS: '1', ORCA_E2E_FAKE_WSL_HOSTS: '{}' }
|
||||
expect(getWslHostsApi(env, 'linux')).toBeUndefined()
|
||||
expect(getWslHostsApi(env, 'win32')).toBeDefined()
|
||||
})
|
||||
|
||||
it('is absent on Windows and under the e2e fake while the internal gate is closed', () => {
|
||||
expect(getWslHostsApi({}, 'win32', [])).toBeUndefined()
|
||||
expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: 'true' }, 'win32', [])).toBeUndefined()
|
||||
expect(getWslHostsApi({ ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'darwin', [])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is present behind the open gate only where WSL hosts can run', () => {
|
||||
expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: '1' }, 'win32')).toBeDefined()
|
||||
expect(
|
||||
getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: '1', ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'linux')
|
||||
).toBeDefined()
|
||||
expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: '1' }, 'darwin')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is present behind a closed gate when main stamped serve-only, still only where hosts run', () => {
|
||||
const argv = ['--orca-wsl-hosts-serve-only']
|
||||
expect(getWslHostsApi({}, 'win32', argv)).toBeDefined()
|
||||
expect(getWslHostsApi({ ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'linux', argv)).toBeDefined()
|
||||
expect(getWslHostsApi({}, 'darwin', argv)).toBeUndefined()
|
||||
expect(getWslHostsApi({}, 'win32', [])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is absent behind an open gate a remote policy blocked, unless main stamped serve-only', () => {
|
||||
const env = { ORCA_INTERNAL_WSL_HOSTS: '1' }
|
||||
const blocked = '--orca-wsl-hosts-policy-blocked'
|
||||
expect(getWslHostsApi(env, 'win32', [blocked])).toBeUndefined()
|
||||
expect(getWslHostsApi(env, 'win32', [blocked, '--orca-wsl-hosts-serve-only'])).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from './browser-extension-document-targets'
|
||||
|
||||
const DISCARDED_TAB = PRELOAD_FIRST_DISCARDED_TAB_ID + 3
|
||||
const REPLACED_TAB = PRELOAD_FIRST_DISCARDED_TAB_ID + 4
|
||||
|
||||
const ORCA_TOP = 'A'.repeat(32)
|
||||
const ORCA_AD = 'B'.repeat(32)
|
||||
@@ -354,24 +353,6 @@ describe('Orca documentIds in native scripting targets', () => {
|
||||
expect(chrome.runtime.lastError).toBeUndefined()
|
||||
expect(nativeListeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps native result identities for untargeted executions', async () => {
|
||||
const { chrome, calls } = install(['webNavigation', 'scripting'])
|
||||
|
||||
await expect(chrome.scripting.executeScript({ target: { tabId: 3 } })).resolves.toEqual([
|
||||
{ frameId: 0, documentId: CHROMIUM_TOP, result: 0 }
|
||||
])
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('calls the native functions directly when there is nothing to map', () => {
|
||||
const { chrome, executeScript, sendMessage } = install(['scripting'])
|
||||
void chrome.scripting.executeScript({ target: { tabId: 3 } })
|
||||
void chrome.tabs.sendMessage(3, 'ping')
|
||||
expect(executeScript).toHaveBeenCalledTimes(1)
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calls to a discarded tab', () => {
|
||||
@@ -393,10 +374,4 @@ describe('calls to a discarded tab', () => {
|
||||
expect(executeScript).not.toHaveBeenCalled()
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leave a retired synthetic id to Electron, which reports no such tab', async () => {
|
||||
const { chrome, executeScript } = install(['scripting', 'webNavigation'])
|
||||
await chrome.scripting.executeScript({ target: { tabId: REPLACED_TAB } })
|
||||
expect(executeScript).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DOC_PREVIEW_LINK_CLICK_CHANNEL,
|
||||
type DocPreviewLinkModifiers
|
||||
} from '../shared/doc-preview-scheme'
|
||||
import type { DocPreviewLinkModifiers } from '../shared/doc-preview-scheme'
|
||||
import {
|
||||
handleDocPreviewLinkAuxClick,
|
||||
handleDocPreviewLinkClick,
|
||||
installDocPreviewLinkInterception,
|
||||
PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL
|
||||
installDocPreviewLinkInterception
|
||||
} from './doc-preview-link-interception'
|
||||
|
||||
const NO_MODIFIERS = { metaKey: false, ctrlKey: false, shiftKey: false }
|
||||
@@ -70,10 +66,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('doc preview link interception', () => {
|
||||
it('sends on exactly the channel main listens on', () => {
|
||||
expect(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL).toBe(DOC_PREVIEW_LINK_CLICK_CHANNEL)
|
||||
})
|
||||
|
||||
it('listens for both press kinds in the capture phase, so the document cannot consume them first', () => {
|
||||
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
|
||||
const addEventListener = vi.spyOn(document, 'addEventListener')
|
||||
@@ -127,15 +119,6 @@ describe('doc preview link interception', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a trusted press on an external anchor exactly once and stops the navigation', () => {
|
||||
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
|
||||
|
||||
const event = pressTrusted('#external')
|
||||
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/docs', NO_MODIFIERS)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('reports the anchor even when the press landed on what it wraps', () => {
|
||||
loadPreviewDocument('<a href="https://example.com/docs"><span id="inner">go</span></a>')
|
||||
|
||||
@@ -175,16 +158,6 @@ describe('doc preview link interception', () => {
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('scrolls to the top for a bare # on an SVG anchor', () => {
|
||||
loadSvgAnchorDocument('#')
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
|
||||
pressTrusted('#svg-hit')
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a rooted SVG link inside the preview rather than reading it as a filesystem path', () => {
|
||||
loadSvgAnchorDocument('/assets/a.html')
|
||||
|
||||
@@ -249,17 +222,6 @@ describe('doc preview link interception', () => {
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/page#section', NO_MODIFIERS)
|
||||
})
|
||||
|
||||
it('opens nothing for a middle click on a link', () => {
|
||||
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
|
||||
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
|
||||
Object.defineProperty(event, 'isTrusted', { configurable: true, value: true })
|
||||
|
||||
dispatch('#external', event)
|
||||
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a middle click the document dispatched itself alone', () => {
|
||||
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
|
||||
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PLUGIN_DECLARATIVE_MESSAGES } from '../shared/plugins/declarative/plugin-declarative-message-names'
|
||||
import { PLUGIN_DECLARATIVE_RENDER_LANE } from '../shared/plugins/declarative/plugin-declarative-limits'
|
||||
import {
|
||||
createPluginFrameSendBudget,
|
||||
PRELOAD_PLUGIN_FRAME_LANES
|
||||
} from './plugin-frame-guest-send-budget'
|
||||
import { createPluginFrameSendBudget } from './plugin-frame-guest-send-budget'
|
||||
|
||||
function chain(depth: number): unknown {
|
||||
let tree: unknown = { type: 'Text', key: '#0', props: {}, children: ['leaf'] }
|
||||
@@ -15,18 +10,6 @@ function chain(depth: number): unknown {
|
||||
}
|
||||
|
||||
describe('guest preload render lane', () => {
|
||||
it("pins its written-out caps to the host's render lane", () => {
|
||||
const { render } = PRELOAD_PLUGIN_FRAME_LANES
|
||||
expect(render.events).toEqual([
|
||||
PLUGIN_DECLARATIVE_MESSAGES.render,
|
||||
PLUGIN_DECLARATIVE_MESSAGES.persist,
|
||||
PLUGIN_DECLARATIVE_MESSAGES.retain
|
||||
])
|
||||
expect(render.maxBytes).toBe(PLUGIN_DECLARATIVE_RENDER_LANE.maxBytes)
|
||||
expect(render.maxNodes).toBe(PLUGIN_DECLARATIVE_RENDER_LANE.maxObjects)
|
||||
expect(render.maxDepth).toBe(PLUGIN_DECLARATIVE_RENDER_LANE.maxDepth)
|
||||
})
|
||||
|
||||
it('sends trees the host accepts, past the shared 64 KiB and depth-100 caps', () => {
|
||||
const send = createPluginFrameSendBudget()
|
||||
const render = (tree: unknown) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Isolated-world budget for what a guest may send to the host. Why written out: the sandboxed
|
||||
* preload can't import shared chunks; plugin-frame-guest.test.ts pins these numbers to the host's
|
||||
* lanes. Sizes are measured like the host measures them (UTF-8 strings, buffer bytes), so a
|
||||
* preload can't import shared chunks. Sizes are measured like the host measures them
|
||||
* (UTF-8 strings, buffer bytes), so a
|
||||
* message this admits is one the host can admit too.
|
||||
*/
|
||||
export const PRELOAD_PLUGIN_FRAME_LANES = {
|
||||
|
||||
@@ -7,19 +7,10 @@ import {
|
||||
PLUGIN_FRAME_KEY_CHANNEL,
|
||||
PLUGIN_FRAME_TRANSPORT_GLOBAL
|
||||
} from '../shared/plugins/frames/plugin-frame-guest'
|
||||
import { PLUGIN_SAVE_AS_MAX_BYTES } from '../shared/plugins/workers/plugin-host-api'
|
||||
import { PANEL_MESSAGE_MAX_BYTES } from '../shared/plugins/frames/plugin-frame-bridge'
|
||||
import { CUSTOM_EDITOR_DOCUMENT_MAX_BYTES } from '../shared/plugins/frames/plugin-custom-editor-bridge'
|
||||
import {
|
||||
PRELOAD_PLUGIN_FRAME_IPC_CHANNEL,
|
||||
PRELOAD_PLUGIN_FRAME_KEY_CHANNEL,
|
||||
PRELOAD_PLUGIN_FRAME_TRANSPORT_GLOBAL
|
||||
} from './plugin-frame-guest-transport'
|
||||
import {
|
||||
createPluginFrameGestureTracker,
|
||||
createPluginFrameSendBudget,
|
||||
measurePluginFrameMessageBytes,
|
||||
PRELOAD_PLUGIN_FRAME_LANES
|
||||
measurePluginFrameMessageBytes
|
||||
} from './plugin-frame-guest-send-budget'
|
||||
|
||||
type Transport = { send: (message: unknown) => void; subscribe: (fn: (m: unknown) => void) => void }
|
||||
@@ -82,17 +73,6 @@ function runPreload(isMainFrame: boolean, contentType = 'text/html') {
|
||||
}
|
||||
|
||||
describe('plugin frame guest preload', () => {
|
||||
it('pins its written-out constants to the shared ones', () => {
|
||||
expect(PRELOAD_PLUGIN_FRAME_IPC_CHANNEL).toBe(PLUGIN_FRAME_IPC_CHANNEL)
|
||||
expect(PRELOAD_PLUGIN_FRAME_KEY_CHANNEL).toBe(PLUGIN_FRAME_KEY_CHANNEL)
|
||||
expect(PRELOAD_PLUGIN_FRAME_TRANSPORT_GLOBAL).toBe(PLUGIN_FRAME_TRANSPORT_GLOBAL)
|
||||
const { shared, saveAs, edit } = PRELOAD_PLUGIN_FRAME_LANES
|
||||
expect(shared.maxBytes).toBeGreaterThanOrEqual(PANEL_MESSAGE_MAX_BYTES)
|
||||
expect(shared.maxBytes).toBeLessThan(PANEL_MESSAGE_MAX_BYTES * 2)
|
||||
expect(saveAs.maxBytes).toBe(PLUGIN_SAVE_AS_MAX_BYTES + 4096)
|
||||
expect(edit.maxBytes).toBe(CUSTOM_EDITOR_DOCUMENT_MAX_BYTES + 4096)
|
||||
})
|
||||
|
||||
it('hands the shell a deletable transport and stamps each send with a gesture serial', () => {
|
||||
const { sendToHost, ipcListeners, pageWindow, input, transport } = runPreload(true)
|
||||
expect(
|
||||
|
||||
@@ -44,7 +44,6 @@ vi.mock('../main/pty/shell-startup/git-bash', () => ({
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
import {
|
||||
buildCommandLookupSpecs,
|
||||
hasAbsoluteCommandPath,
|
||||
isCommandOnPathForRelay,
|
||||
PreflightHandler
|
||||
@@ -75,29 +74,6 @@ beforeEach(() => {
|
||||
isGitBashAvailableMock.mockReset()
|
||||
})
|
||||
|
||||
describe('buildCommandLookupSpecs', () => {
|
||||
it('allows a custom shell path only when the account login shell matches', () => {
|
||||
expect(
|
||||
buildCommandLookupSpecs(
|
||||
'codex',
|
||||
'darwin',
|
||||
{ SHELL: '/opt/homebrew/bin/zsh' },
|
||||
'/opt/homebrew/bin/zsh'
|
||||
)
|
||||
).toEqual([
|
||||
{ file: '/opt/homebrew/bin/zsh', args: lookupArgs('codex', '-ilc') },
|
||||
{ file: '/bin/sh', args: lookupArgs('codex') }
|
||||
])
|
||||
})
|
||||
|
||||
it('allows conservative system shell paths when account lookup is unavailable', () => {
|
||||
expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({
|
||||
file: '/usr/bin/bash',
|
||||
args: lookupArgs('codex', '-ilc')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCommandOnPathForRelay', () => {
|
||||
it('falls back to inherited PATH when shell startup returns no absolute command path', async () => {
|
||||
execFileAsyncMock
|
||||
@@ -280,44 +256,4 @@ describe('PreflightHandler', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('reports remote Windows shell capabilities through the SSH preflight path', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
isWslAvailableAsyncMock.mockResolvedValue(true)
|
||||
listWslDistrosAsyncMock.mockResolvedValue(['Ubuntu'])
|
||||
isPwshAvailableAsyncMock.mockResolvedValue(true)
|
||||
isGitBashAvailableMock.mockReturnValue(true)
|
||||
|
||||
const requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
|
||||
const dispatcher = {
|
||||
onRequest: vi.fn(
|
||||
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
|
||||
requestHandlers.set(method, handler)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
new PreflightHandler(dispatcher as never)
|
||||
|
||||
try {
|
||||
const handler = requestHandlers.get('preflight.detectWindowsTerminalCapabilities')
|
||||
expect(handler).toBeDefined()
|
||||
await expect(handler!({})).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user