mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
test: remove 31 low-value interaction and state tests (#38629)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -425,7 +425,6 @@
|
||||
"src/cli/format.test.ts": 446,
|
||||
"src/cli/handler-group-manifest.test.ts": 3610,
|
||||
"src/cli/handlers/account.test.ts": 849,
|
||||
"src/cli/handlers/agent-hooks-load-boundary.test.ts": 457,
|
||||
"src/cli/handlers/agent-hooks-prepare-codex-isolated-home.test.ts": 854,
|
||||
"src/cli/handlers/agent-hooks.test.ts": 1702,
|
||||
"src/cli/handlers/artifacts.test.ts": 471,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { MobileNativeChatOpenFileContext } from '../mobile-native-chat-open-file-context'
|
||||
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: () => Promise.resolve() },
|
||||
Platform: { OS: 'ios', select: (o: Record<string, unknown>) => o.ios },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronDown: 'ChevronDown',
|
||||
ShieldQuestion: 'ShieldQuestion',
|
||||
X: 'X'
|
||||
}))
|
||||
vi.mock('../../../components/pr-sidebar/MermaidDiagram', () => ({
|
||||
MermaidDiagram: 'MermaidDiagram'
|
||||
}))
|
||||
vi.mock('../../../components/MobileSourceCopyButton', () => ({
|
||||
MobileSourceCopyButton: 'MobileSourceCopyButton'
|
||||
}))
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
const flatten = (node: ReactTestInstance | string): string =>
|
||||
typeof node === 'string' ? node : node.children.map(flatten).join('')
|
||||
|
||||
// A terminal-backed plan review reaches the phone as a permission with a plan subject.
|
||||
it('opens a file named in a plan approval through the chat', () => {
|
||||
const openFile = vi.fn()
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(
|
||||
MobileNativeChatOpenFileContext,
|
||||
{ value: openFile },
|
||||
createElement(MobileNativeChatPermission, {
|
||||
permission: {
|
||||
title: 'Review proposed plan',
|
||||
subject: { kind: 'plan', text: 'First edit src/app.ts, then run tests.' },
|
||||
options: [{ label: 'Approve', send: 'approve' }]
|
||||
},
|
||||
onRespond: async () => true
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
const link = renderer!.root.find(
|
||||
(node) =>
|
||||
String(node.type) === 'Text' &&
|
||||
typeof node.props.onPress === 'function' &&
|
||||
flatten(node) === 'src/app.ts'
|
||||
)
|
||||
act(() => link.props.onPress())
|
||||
expect(openFile).toHaveBeenCalledExactlyOnceWith('src/app.ts')
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import './mobile-agent-message-renderer.test-fixture'
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { messages, Transcript } from './mobile-native-chat-turn-diff.test-fixture'
|
||||
import { MobileNativeChatTurnDiffRollup } from './MobileNativeChatTurnDiffRollup'
|
||||
import type * as TurnDiffRollupModule from './MobileNativeChatTurnDiffRollup'
|
||||
|
||||
vi.mock('./MobileNativeChatTurnDiffRollup', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TurnDiffRollupModule>()
|
||||
return { ...actual, MobileNativeChatTurnDiffRollup: vi.fn(actual.MobileNativeChatTurnDiffRollup) }
|
||||
})
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it.each([10, 100])(
|
||||
'does not repaint unrelated outcome rows while another turn streams (%i turns)',
|
||||
(count) => {
|
||||
const rows = Array.from({ length: count }, (_, index) =>
|
||||
messages.map((message) => ({
|
||||
...message,
|
||||
id: `${index}:${message.id}`
|
||||
}))
|
||||
).flat()
|
||||
act(() => {
|
||||
renderer = create(createElement(Transcript, { rows }))
|
||||
})
|
||||
const outcome = vi.mocked(MobileNativeChatTurnDiffRollup)
|
||||
const before = outcome.mock.calls.length
|
||||
const last = rows.at(-1)!
|
||||
const updated = [
|
||||
...rows.slice(0, -1),
|
||||
{ ...last, blocks: [{ type: 'text' as const, text: 'Updated again.' }] }
|
||||
]
|
||||
act(() => {
|
||||
renderer!.update(createElement(Transcript, { rows: updated }))
|
||||
})
|
||||
expect(outcome.mock.calls.length - before).toBe(0)
|
||||
const changed = updated.map((message) =>
|
||||
message.id === `${count - 1}:edit`
|
||||
? {
|
||||
...message,
|
||||
blocks: message.blocks.map((block) =>
|
||||
block.type === 'tool-call' && block.name === 'Edit'
|
||||
? {
|
||||
...block,
|
||||
input: {
|
||||
file_path: '/repo/greeting.ts',
|
||||
old_string: 'helloOld()',
|
||||
new_string: 'helloNewest()'
|
||||
}
|
||||
}
|
||||
: block
|
||||
)
|
||||
}
|
||||
: message
|
||||
)
|
||||
act(() => {
|
||||
renderer!.update(createElement(Transcript, { rows: changed }))
|
||||
})
|
||||
expect(outcome.mock.calls.length - before).toBe(1)
|
||||
const latest = outcome.mock.calls.at(-1)![0].diff
|
||||
expect(latest.files.map((file) => file.path)).toContain('/repo/greeting.ts')
|
||||
}
|
||||
)
|
||||
@@ -1,48 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { main } from '../index'
|
||||
|
||||
const { call } = vi.hoisted(() => ({ call: vi.fn(async () => ({ result: null })) }))
|
||||
vi.mock('../runtime-client', () => ({
|
||||
RuntimeClient: class {
|
||||
call = call
|
||||
},
|
||||
RuntimeClientError: Error,
|
||||
getDefaultUserDataPath: () => '/unused/user-data'
|
||||
}))
|
||||
vi.mock('../../main/persistence/profile-state/profile-state-offline-settings', () => {
|
||||
throw new Error('Offline profile settings loaded during online preparation')
|
||||
})
|
||||
vi.mock('../../main/persistence/profile-state/profile-state-access', () => {
|
||||
throw new Error('Profile admission loaded during online preparation')
|
||||
})
|
||||
vi.mock('../profile-state-location', () => {
|
||||
throw new Error('Profile location loaded during online preparation')
|
||||
})
|
||||
vi.mock('../../main/codex/hooks/installation/codex-hook-local-install', () => {
|
||||
throw new Error('The Codex installer loaded in the pane CLI')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
call.mockClear()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
it('prepares a WSL pane through the runtime without loading profile storage or an installer', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await main(['agent', 'hooks', 'prepare-codex'])
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
expect(call).toHaveBeenCalledWith('agentHooks.prepareCodexForWslPane', expect.any(Object), {
|
||||
timeoutMs: 50_000
|
||||
})
|
||||
})
|
||||
|
||||
it('loads nothing and asks nothing for a native pane', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', '')
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await main(['agent', 'hooks', 'prepare-codex'])
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -42,22 +42,6 @@ describe('terminal split --retry-split', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends the retry to a host that can resume the split', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { split: { handle: 'term_2', tabId: 'tab-1', paneRuntimeId: 1, leafId: 'leaf-1' } }
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await retrySplit(splitClient(call, [TERMINAL_SPLIT_RETRY_RUNTIME_CAPABILITY]))
|
||||
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('terminal.split', {
|
||||
terminal: 'term_1',
|
||||
direction: undefined,
|
||||
command: 'agent --task 1',
|
||||
retrySplit: 'leaf-1'
|
||||
})
|
||||
})
|
||||
|
||||
// An older host strips `retrySplit` and splits again, starting a second copy of the command.
|
||||
it('refuses rather than splitting again on a host without the capability', async () => {
|
||||
const call = vi.fn()
|
||||
|
||||
@@ -80,32 +80,4 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
|
||||
})
|
||||
|
||||
it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => {
|
||||
process.env.ORCA_ENVIRONMENT = 'stale-env'
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
|
||||
name: 'desk',
|
||||
pairingCode: 'orca://pair#abc'
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
environment: {
|
||||
id: 'env-1',
|
||||
name: 'desk',
|
||||
endpoints: [{ id: 'ws-env-1', kind: 'websocket', endpoint: 'ws://127.0.0.1:6768' }]
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
[
|
||||
{
|
||||
"method": "item/started",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "sleep",
|
||||
"id": "call_000ed8ed8f1b4530bf32cd6ca8fd5c15",
|
||||
"durationMs": 1000
|
||||
},
|
||||
"threadId": "01a1a381-0622-7153-af21-ae6389d8c1b3",
|
||||
"turnId": "01a1a381-06ec-74b1-89f1-843749197dac",
|
||||
"startedAtMs": 1793744507474
|
||||
},
|
||||
"emittedAtMs": 1793744507477
|
||||
},
|
||||
{
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "sleep",
|
||||
"id": "call_000ed8ed8f1b4530bf32cd6ca8fd5c15",
|
||||
"durationMs": 1000
|
||||
},
|
||||
"threadId": "01a1a381-0622-7153-af21-ae6389d8c1b3",
|
||||
"turnId": "01a1a381-06ec-74b1-89f1-843749197dac",
|
||||
"completedAtMs": 1793744508479
|
||||
},
|
||||
"emittedAtMs": 1793744508480
|
||||
}
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import sleepFrames from './__fixtures__/codex-app-server-sleep.json'
|
||||
import { createCodexJournalTranslator } from './structured/journal/codex-structured-journal-translation'
|
||||
|
||||
// Recorded from Codex 0.162.1 app-server calling clock.sleep on 2026-11-03.
|
||||
it('publishes the requested sleep duration and clears it before the next activity', () => {
|
||||
const setActivity = vi.fn()
|
||||
const threadId = sleepFrames[0]!.params.threadId
|
||||
const turnId = sleepFrames[0]!.params.turnId
|
||||
const translator = createCodexJournalTranslator({
|
||||
sink: { appendItem: () => {}, appendTombstone: () => {}, publish: () => {}, setActivity },
|
||||
primaryThreadId: () => threadId
|
||||
})
|
||||
const notify = (method: string, params: unknown) =>
|
||||
translator.handle({
|
||||
type: 'notification',
|
||||
sessionId: 'sleep-session',
|
||||
threadId,
|
||||
method,
|
||||
params
|
||||
})
|
||||
notify('turn/started', { turn: { id: turnId } })
|
||||
for (const frame of sleepFrames) {
|
||||
notify(frame.method, frame.params)
|
||||
}
|
||||
expect(setActivity.mock.calls.slice(-2)).toEqual([[{ turnId, text: 'Waiting for 1s' }], [null]])
|
||||
notify('item/started', { turnId, item: { id: 'response', type: 'agentMessage', text: '' } })
|
||||
expect(setActivity).toHaveBeenLastCalledWith({ turnId, text: 'Drafting a response' })
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetPtyEscapeBoundaryForTest,
|
||||
advanceEscapeBoundary,
|
||||
advancePtyEscapeBoundary,
|
||||
markPtyEscapeBoundaryUnknown,
|
||||
escapeBoundaryFields
|
||||
} from './pty-escape-boundary'
|
||||
|
||||
afterEach(() => _resetPtyEscapeBoundaryForTest())
|
||||
|
||||
describe('pty escape boundary', () => {
|
||||
it('tracks an escape split across payloads', () => {
|
||||
expect(advancePtyEscapeBoundary('pty', 'a\x1b[4')).toBe('')
|
||||
expect(advancePtyEscapeBoundary('pty', '2Bb')).toBe('\x1b[')
|
||||
expect(advancePtyEscapeBoundary('pty', 'c')).toBe('')
|
||||
})
|
||||
|
||||
it('stays unknown after a provider gap until an ESC, CAN or SUB resynchronizes it', () => {
|
||||
markPtyEscapeBoundaryUnknown('pty')
|
||||
expect(advancePtyEscapeBoundary('pty', '42B text')).toBeNull()
|
||||
expect(advancePtyEscapeBoundary('pty', 'x\x1b[K')).toBeNull()
|
||||
expect(advancePtyEscapeBoundary('pty', 'y')).toBe('')
|
||||
expect(advanceEscapeBoundary(null, 'osc payload\x18text')).toBe('')
|
||||
})
|
||||
|
||||
it('marks every start but ground, and says when the boundary is unknown', () => {
|
||||
expect(escapeBoundaryFields('')).toEqual({})
|
||||
expect(escapeBoundaryFields(undefined)).toEqual({})
|
||||
expect(escapeBoundaryFields('\x1b[')).toEqual({ midEscape: true })
|
||||
expect(escapeBoundaryFields(null)).toEqual({ midEscape: 'unknown' })
|
||||
})
|
||||
|
||||
it('treats a C1 control as an unknown boundary, since xterm parses it and the scanner does not', () => {
|
||||
// U+009B is an 8-bit CSI introducer: xterm would read `42B` after it as cursor-down.
|
||||
expect(advanceEscapeBoundary('', 'text\u009b42')).toBeNull()
|
||||
expect(advanceEscapeBoundary('', 'a\u009bb\x1b[0m')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseLinuxDmi,
|
||||
parseMacHardwareProfile,
|
||||
parseWindowsComputerSystem,
|
||||
type LinuxDmiFields
|
||||
} from './host-machine-probe-parsing'
|
||||
|
||||
function macProfile(fields: Record<string, string>): string {
|
||||
return JSON.stringify({ SPHardwareDataType: [{ _name: 'hardware_overview', ...fields }] })
|
||||
}
|
||||
|
||||
describe('parseMacHardwareProfile', () => {
|
||||
it('reads the marketing name and treats MacBooks as laptops', () => {
|
||||
expect(
|
||||
parseMacHardwareProfile(
|
||||
macProfile({ machine_name: 'MacBook Pro', machine_model: 'Mac16,5', serial_number: 'X' })
|
||||
)
|
||||
).toEqual({ kind: 'laptop', model: 'MacBook Pro' })
|
||||
expect(
|
||||
parseMacHardwareProfile(macProfile({ machine_name: 'Mac mini', machine_model: 'Mac16,10' }))
|
||||
).toEqual({ kind: 'desktop', model: 'Mac mini' })
|
||||
})
|
||||
|
||||
it('falls back to the Intel model identifier for the kind when the name is missing', () => {
|
||||
expect(parseMacHardwareProfile(macProfile({ machine_model: 'MacBookAir9,1' }))).toEqual({
|
||||
kind: 'laptop',
|
||||
model: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unreadable output', () => {
|
||||
expect(parseMacHardwareProfile('not json')).toBeNull()
|
||||
expect(parseMacHardwareProfile('{"SPHardwareDataType":[]}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWindowsComputerSystem', () => {
|
||||
const row = (fields: Record<string, unknown>): string => JSON.stringify(fields)
|
||||
|
||||
it('reads mobile PCs as laptops and prefers the family over a Lenovo type code', () => {
|
||||
expect(
|
||||
parseWindowsComputerSystem(
|
||||
row({
|
||||
PCSystemType: 2,
|
||||
Model: '20XWCTO1WW',
|
||||
Manufacturer: 'LENOVO',
|
||||
SystemFamily: 'ThinkPad X1 Carbon Gen 9',
|
||||
ProductType: 1
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'laptop', model: 'ThinkPad X1 Carbon Gen 9' })
|
||||
})
|
||||
|
||||
it('drops firmware placeholders', () => {
|
||||
expect(
|
||||
parseWindowsComputerSystem(
|
||||
row({
|
||||
PCSystemType: 1,
|
||||
Model: 'System Product Name',
|
||||
Manufacturer: 'System manufacturer',
|
||||
SystemFamily: 'To Be Filled By O.E.M.',
|
||||
ProductType: 1
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'desktop', model: null })
|
||||
})
|
||||
|
||||
it('reads Windows Server and virtual machines as servers', () => {
|
||||
expect(
|
||||
parseWindowsComputerSystem(
|
||||
row({ PCSystemType: 1, Model: 'PowerEdge R740', Manufacturer: 'Dell Inc.', ProductType: 3 })
|
||||
)
|
||||
).toEqual({ kind: 'server', model: 'PowerEdge R740' })
|
||||
expect(
|
||||
parseWindowsComputerSystem(
|
||||
row({ PCSystemType: 1, Model: 'Virtual Machine', Manufacturer: 'Microsoft Corporation' })
|
||||
)
|
||||
).toEqual({ kind: 'server', model: null })
|
||||
})
|
||||
|
||||
it('rejects unreadable output', () => {
|
||||
expect(parseWindowsComputerSystem('')).toBeNull()
|
||||
expect(parseWindowsComputerSystem('[1]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLinuxDmi', () => {
|
||||
const dmi = (fields: Partial<LinuxDmiFields>): LinuxDmiFields => ({
|
||||
chassisType: null,
|
||||
productName: null,
|
||||
productFamily: null,
|
||||
sysVendor: null,
|
||||
...fields
|
||||
})
|
||||
|
||||
it('maps chassis types to laptop, desktop and server', () => {
|
||||
expect(parseLinuxDmi(dmi({ chassisType: '10', productName: 'XPS 13 9310' }))).toEqual({
|
||||
kind: 'laptop',
|
||||
model: 'XPS 13 9310'
|
||||
})
|
||||
expect(parseLinuxDmi(dmi({ chassisType: '3', productName: 'Default string' }))).toEqual({
|
||||
kind: 'desktop',
|
||||
model: null
|
||||
})
|
||||
expect(parseLinuxDmi(dmi({ chassisType: '23', productName: 'ProLiant DL380 Gen10' }))).toEqual({
|
||||
kind: 'server',
|
||||
model: 'ProLiant DL380 Gen10'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads missing DMI and virtual machines as servers without a model', () => {
|
||||
expect(parseLinuxDmi(dmi({}))).toEqual({ kind: 'server', model: null })
|
||||
expect(
|
||||
parseLinuxDmi(
|
||||
dmi({ chassisType: '1', productName: 'Standard PC (Q35 + ICH9, 2009)', sysVendor: 'QEMU' })
|
||||
)
|
||||
).toEqual({ kind: 'server', model: null })
|
||||
expect(
|
||||
parseLinuxDmi(
|
||||
dmi({
|
||||
chassisType: '3',
|
||||
productName: 'Virtual Machine',
|
||||
sysVendor: 'Microsoft Corporation'
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'server', model: null })
|
||||
})
|
||||
})
|
||||
@@ -99,52 +99,6 @@ describe('a worker after its worker_done', () => {
|
||||
).rejects.toMatchObject({ code: 'run_required' })
|
||||
})
|
||||
|
||||
it('resumes its own question after a follow-up starts on the same worker', async () => {
|
||||
const { db, runtime, ctx } = harness.setup()
|
||||
const task = db.createTask({ spec: 'work' })
|
||||
const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker')
|
||||
vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) =>
|
||||
handle === 'term_worker'
|
||||
? 'tab_worker:leaf_worker'
|
||||
: handle === 'term_coord'
|
||||
? harness.coordinatorPaneKey
|
||||
: null
|
||||
)
|
||||
await harness.call(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: 'term_worker',
|
||||
type: 'worker_done',
|
||||
subject: 'Done',
|
||||
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' })
|
||||
},
|
||||
ctx
|
||||
)
|
||||
const asked = await harness.call(
|
||||
'orchestration.ask',
|
||||
{ from: 'term_worker', question: 'Anything else?', timeoutMs: 1 },
|
||||
ctx
|
||||
)
|
||||
expect(asked).toMatchObject({ timedOut: true })
|
||||
const questionId =
|
||||
asked && typeof asked === 'object' && 'messageId' in asked ? asked.messageId : ''
|
||||
createRootDispatch(
|
||||
db,
|
||||
db.createTask({ spec: 'more' }).id,
|
||||
'term_worker',
|
||||
'tab_worker:leaf_worker'
|
||||
)
|
||||
await harness.call(
|
||||
'orchestration.reply',
|
||||
{ from: 'term_coord', id: questionId, body: 'Nothing else.' },
|
||||
ctx
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.ask', { from: 'term_worker', resume: questionId }, ctx)
|
||||
).resolves.toMatchObject({ answer: 'Nothing else.' })
|
||||
})
|
||||
|
||||
it('tells a finished worker on a paired host that its coordinator is out of reach', async () => {
|
||||
const { db, runtime, ctx } = harness.setup(false)
|
||||
db.createRemoteDispatchAttachment({
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { usePRCheckRefreshOwner } from './use-pr-check-refresh-owner'
|
||||
import { acceptPRCheckObservation } from '../../../shared/github/complete-pr-check-owner'
|
||||
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
|
||||
afterEach(cleanup)
|
||||
const item: GitHubWorkItem = {
|
||||
id: 'pr42',
|
||||
repoId: 'repo',
|
||||
number: 42,
|
||||
type: 'pr',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
title: 'PR',
|
||||
state: 'open',
|
||||
headSha: 'h1',
|
||||
updatedAt: '',
|
||||
labels: [],
|
||||
author: null
|
||||
}
|
||||
const summary = {
|
||||
state: 'failure' as const,
|
||||
total: 101,
|
||||
passed: 100,
|
||||
failed: 1,
|
||||
pending: 0,
|
||||
neutral: 0
|
||||
}
|
||||
const context = { scope: 'source:r1', complete: true, current: true }
|
||||
it('retains a refreshed head across parent allocation and sequential refresh, then honors a newer parent observation', () => {
|
||||
const view = renderHook(({ parent }) => usePRCheckRefreshOwner(parent), {
|
||||
initialProps: { parent: item }
|
||||
})
|
||||
act(() =>
|
||||
view.result.current.onOwnerUpdated(
|
||||
acceptPRCheckObservation({ ...item, headSha: 'h2', checksSummary: summary }, null, context)!
|
||||
)
|
||||
)
|
||||
view.rerender({ parent: { ...item } })
|
||||
expect(view.result.current.checkOwner.headSha).toBe('h2')
|
||||
act(() =>
|
||||
view.result.current.onOwnerUpdated(
|
||||
acceptPRCheckObservation(
|
||||
{ ...view.result.current.checkOwner, checksSummary: undefined },
|
||||
null,
|
||||
context
|
||||
)!
|
||||
)
|
||||
)
|
||||
expect(view.result.current.checkOwner.checksSummary).toEqual(summary)
|
||||
const newer = acceptPRCheckObservation(
|
||||
{
|
||||
...item,
|
||||
headSha: 'h2',
|
||||
checksSummary: { ...summary, state: 'success', failed: 0, passed: 101 }
|
||||
},
|
||||
null,
|
||||
context
|
||||
)!
|
||||
view.rerender({ parent: newer })
|
||||
expect(view.result.current.checkOwner.checksSummary?.state).toBe('success')
|
||||
})
|
||||
it('does not reuse last-good after the producing runtime changes', () => {
|
||||
const view = renderHook(() => usePRCheckRefreshOwner(item))
|
||||
act(() =>
|
||||
view.result.current.onOwnerUpdated(
|
||||
acceptPRCheckObservation({ ...item, checksSummary: summary }, null, context)!
|
||||
)
|
||||
)
|
||||
act(() =>
|
||||
view.result.current.onOwnerUpdated(
|
||||
acceptPRCheckObservation(item, null, { ...context, scope: 'source:r2' })!
|
||||
)
|
||||
)
|
||||
expect(view.result.current.checkOwner.checksSummary).toBeUndefined()
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
refreshLocalRuntimeCapabilities,
|
||||
readLocalRuntimeCapabilitiesOrUnknown,
|
||||
setLocalRuntimeCapabilitiesForTests
|
||||
} from './local-runtime-capabilities'
|
||||
import { GITHUB_COMPLETE_PR_CHECK_OBSERVATION_CAPABILITY as complete } from '../../../shared/source-control/runtime/source-control-runtime-capabilities'
|
||||
afterEach(() => {
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
it('keeps a declared local capability across transient probe failure and retries an initially unknown probe', async () => {
|
||||
const getStatus = vi.fn().mockRejectedValue(new Error('temporary'))
|
||||
vi.stubGlobal('window', { api: { runtime: { getStatus } } })
|
||||
setLocalRuntimeCapabilitiesForTests([complete])
|
||||
expect(await refreshLocalRuntimeCapabilities()).toEqual([complete])
|
||||
expect(readLocalRuntimeCapabilitiesOrUnknown()).toEqual([complete])
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
expect(await refreshLocalRuntimeCapabilities()).toEqual([])
|
||||
expect(readLocalRuntimeCapabilitiesOrUnknown()).toBeNull()
|
||||
getStatus.mockResolvedValueOnce({ capabilities: [complete] })
|
||||
expect(await refreshLocalRuntimeCapabilities()).toEqual([complete])
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION } from '../../../../shared/workspace-cleanup/workspace-cleanup'
|
||||
import {
|
||||
NOW,
|
||||
WORKTREE_ID,
|
||||
createCleanupTestStore,
|
||||
installWorkspaceCleanupApi,
|
||||
makeCandidate
|
||||
} from './workspace-cleanup-slice-test-harness'
|
||||
|
||||
const LEGACY_DISMISSAL = {
|
||||
worktreeId: WORKTREE_ID,
|
||||
dismissedAt: NOW,
|
||||
fingerprint: 'fingerprint-1',
|
||||
classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION
|
||||
}
|
||||
|
||||
function seedLegacyDismissalOnTwoHosts() {
|
||||
installWorkspaceCleanupApi(vi.fn())
|
||||
const store = createCleanupTestStore()
|
||||
const local = makeCandidate({ blockers: ['dismissed'] })
|
||||
const ssh = makeCandidate({
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
connectionId: 'ssh-1',
|
||||
blockers: ['dismissed']
|
||||
})
|
||||
store.setState({
|
||||
workspaceCleanupDismissals: { [WORKTREE_ID]: LEGACY_DISMISSAL },
|
||||
workspaceCleanupScan: { scannedAt: NOW, candidates: [local, ssh], errors: [] }
|
||||
})
|
||||
return { store, local }
|
||||
}
|
||||
|
||||
function dismissedByHost(store: ReturnType<typeof createCleanupTestStore>) {
|
||||
return Object.fromEntries(
|
||||
(store.getState().workspaceCleanupScan?.candidates ?? []).map((candidate) => [
|
||||
candidate.executionHostId,
|
||||
candidate.blockers.includes('dismissed')
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
describe('restoreWorkspaceCleanupCandidates', () => {
|
||||
it('keeps a legacy dismissal on the other host when one host stops ignoring', async () => {
|
||||
const { store, local } = seedLegacyDismissalOnTwoHosts()
|
||||
|
||||
await store.getState().restoreWorkspaceCleanupCandidates([local])
|
||||
|
||||
expect(window.api.workspaceCleanup.dismiss).toHaveBeenCalledWith({
|
||||
dismissals: [{ ...LEGACY_DISMISSAL, executionHostId: 'ssh:ssh-1' }],
|
||||
restoredDismissalKeys: [`local\0${WORKTREE_ID}`, WORKTREE_ID]
|
||||
})
|
||||
expect(Object.keys(store.getState().workspaceCleanupDismissals)).toEqual([
|
||||
`ssh:ssh-1\0${WORKTREE_ID}`
|
||||
])
|
||||
expect(dismissedByHost(store)).toEqual({ local: false, 'ssh:ssh-1': true })
|
||||
})
|
||||
|
||||
it('leaves the row ignored when the dismissal store rejects', async () => {
|
||||
const { store, local } = seedLegacyDismissalOnTwoHosts()
|
||||
vi.mocked(window.api.workspaceCleanup.dismiss).mockRejectedValueOnce(new Error('disk full'))
|
||||
|
||||
await expect(store.getState().restoreWorkspaceCleanupCandidates([local])).rejects.toThrow(
|
||||
'disk full'
|
||||
)
|
||||
|
||||
expect(Object.keys(store.getState().workspaceCleanupDismissals)).toEqual([WORKTREE_ID])
|
||||
expect(dismissedByHost(store)).toEqual({ local: true, 'ssh:ssh-1': true })
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../native-chat-types'
|
||||
import { nativeChatTurnDiffs, sameNativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
|
||||
// Count source normalization separately from rendered patches: unopened summaries still consume input.
|
||||
describe('landed edit summary work', () => {
|
||||
it.each([100, 1000])('normalizes %i Claude edits once and only new input on append', (count) => {
|
||||
let contentReads = 0
|
||||
const edit = (index: number): NativeChatMessage => ({
|
||||
id: `edit-${index}`,
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: index,
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
state: 'completed',
|
||||
input: {
|
||||
file_path: `/repo/file-${index}.ts`,
|
||||
old_string: 'old-line\n'.repeat(40),
|
||||
get new_string() {
|
||||
contentReads += 1
|
||||
return 'new-line\n'.repeat(40)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'tool-result', output: 'The file was updated successfully.' }
|
||||
]
|
||||
})
|
||||
const messages = Array.from({ length: count }, (_, index) => edit(index))
|
||||
const keys = messages.map(() => 'u')
|
||||
const first = nativeChatTurnDiffs(messages, keys).get('u')!
|
||||
expect(first.files).toHaveLength(count)
|
||||
const coldReads = contentReads
|
||||
expect(coldReads).toBeGreaterThanOrEqual(count)
|
||||
expect(coldReads).toBeLessThanOrEqual(count * 4)
|
||||
const same = nativeChatTurnDiffs([...messages], [...keys]).get('u')!
|
||||
expect(contentReads).toBe(coldReads)
|
||||
expect(sameNativeChatTurnDiff(first, same)).toBe(true)
|
||||
const appended = nativeChatTurnDiffs([...messages, edit(count)], [...keys, 'u']).get('u')!
|
||||
expect(appended.files).toHaveLength(count + 1)
|
||||
expect(contentReads - coldReads).toBe(coldReads / count)
|
||||
expect(sameNativeChatTurnDiff(first, appended)).toBe(false)
|
||||
const refreshed = [...messages]
|
||||
const last = refreshed[count - 1]!
|
||||
refreshed[count - 1] = {
|
||||
...last,
|
||||
blocks: [last.blocks[0]!, { type: 'tool-result', output: 'Updated successfully.' }]
|
||||
}
|
||||
const beforeResult = contentReads
|
||||
nativeChatTurnDiffs(refreshed, keys)
|
||||
expect(contentReads - beforeResult).toBe(coldReads / count)
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Repo } from '../repo-types'
|
||||
import type { DetectedWorktree } from '../worktree/types'
|
||||
import { appendImportedWorktreeLines } from './imported-worktree-lines'
|
||||
import type { ImportedWorktreesCardCandidate, Row } from './row-types'
|
||||
|
||||
const local: Repo = {
|
||||
id: 'repo-a',
|
||||
displayName: 'alpha',
|
||||
path: '/a',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const localTwo: Repo = { ...local, id: 'repo-b', displayName: 'beta', path: '/b' }
|
||||
const remote: Repo = { ...local, id: 'repo-c', path: '/c', executionHostId: 'runtime:env-1' }
|
||||
|
||||
function candidate(repo: Repo): ImportedWorktreesCardCandidate {
|
||||
const hidden: DetectedWorktree = {
|
||||
id: `hidden-${repo.id}`,
|
||||
repoId: repo.id,
|
||||
path: `/wt/${repo.id}`,
|
||||
branch: 'refs/heads/hidden',
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
comment: '',
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
displayName: 'hidden',
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
visible: false,
|
||||
selectedCheckout: false,
|
||||
ownership: 'external'
|
||||
}
|
||||
return { repo, hiddenWorktrees: [hidden] }
|
||||
}
|
||||
|
||||
function append(repos: Repo[]): Row[] {
|
||||
return appendImportedWorktreeLines([], {
|
||||
importedWorktreesByRepo: new Map(repos.map((repo) => [repo.id, candidate(repo)])),
|
||||
defaultHostId: 'local',
|
||||
hostLabelById: new Map([['runtime:env-1', 'Build box']])
|
||||
})
|
||||
}
|
||||
|
||||
describe('appendImportedWorktreeLines', () => {
|
||||
it('covers every project on a host with one unlabelled line', () => {
|
||||
expect(append([local, localTwo])).toMatchObject([
|
||||
{
|
||||
placement: 'all-projects',
|
||||
hostId: 'local',
|
||||
projects: [{ repo: { id: 'repo-a' } }, { repo: { id: 'repo-b' } }]
|
||||
}
|
||||
])
|
||||
expect(append([local, localTwo])[0]).not.toHaveProperty('hostContextLabel')
|
||||
})
|
||||
|
||||
it('gives each host its own line, named, when projects span hosts', () => {
|
||||
expect(append([local, remote])).toMatchObject([
|
||||
{ hostId: 'local', hostContextHostId: 'local', projects: [{ repo: { id: 'repo-a' } }] },
|
||||
{
|
||||
hostId: 'runtime:env-1',
|
||||
hostContextLabel: 'Build box',
|
||||
projects: [{ repo: { id: 'repo-c' } }]
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../generation/commit-message-host-key'
|
||||
import { savedDiscoveredModels, withDiscoveredModels } from './source-control-ai-model-selection'
|
||||
import { normalizeSourceControlAiSettings } from './source-control-ai-settings'
|
||||
|
||||
it('saves discovered Claude rows without the resolved model an alias row carries', () => {
|
||||
const discovered = [
|
||||
{ id: 'claude-opus-5-5', label: 'Claude Opus 5.5', resolvedModel: 'claude-opus-5-5' },
|
||||
{ id: 'opus', label: 'Opus alias', resolvedModel: 'claude-opus-5-5' }
|
||||
]
|
||||
const saved = withDiscoveredModels(
|
||||
normalizeSourceControlAiSettings(undefined),
|
||||
'claude',
|
||||
discovered,
|
||||
LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
)
|
||||
const rows = [
|
||||
{ id: 'claude-opus-5-5', label: 'Claude Opus 5.5' },
|
||||
{ id: 'opus', label: 'Opus alias' }
|
||||
]
|
||||
expect(saved.discoveredModelsByAgent?.claude).toEqual(rows)
|
||||
expect(saved.discoveredModelsByAgentByHost?.[LOCAL_COMMIT_MESSAGE_HOST_KEY]?.claude).toEqual(rows)
|
||||
// The refresh compares the same saved shape, so an unchanged listing writes nothing.
|
||||
expect(savedDiscoveredModels(discovered)).toEqual(rows)
|
||||
})
|
||||
@@ -1,145 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
|
||||
const FILE_COUNT = 8
|
||||
|
||||
declare global {
|
||||
var __colouredWhenLaidOut: Record<string, boolean> | undefined
|
||||
}
|
||||
|
||||
function runGit(repoPath: string, args: string[]): void {
|
||||
execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
function createRepo(): string {
|
||||
const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-diff-colour-')))
|
||||
runGit(repoPath, ['init'])
|
||||
runGit(repoPath, ['config', 'user.email', 'e2e@test.local'])
|
||||
runGit(repoPath, ['config', 'user.name', 'E2E Test'])
|
||||
mkdirSync(path.join(repoPath, 'src'), { recursive: true })
|
||||
const file = (index: number): string => path.join(repoPath, 'src', `colour-${index}.ts`)
|
||||
for (let index = 0; index < FILE_COUNT; index += 1) {
|
||||
writeFileSync(file(index), `export const base${index} = ${index}\n`)
|
||||
}
|
||||
runGit(repoPath, ['add', '-A'])
|
||||
runGit(repoPath, ['commit', '-m', 'base'])
|
||||
for (let index = 0; index < FILE_COUNT; index += 1) {
|
||||
const added = Array.from(
|
||||
{ length: 40 },
|
||||
(_, line) => `export function changed${index}_${line}(): string { return 'v${line}' }`
|
||||
).join('\n')
|
||||
writeFileSync(file(index), `export const base${index} = ${index}\n${added}\n`)
|
||||
}
|
||||
return repoPath
|
||||
}
|
||||
|
||||
async function openCombinedDiff(page: Page, repoPath: string): Promise<void> {
|
||||
const repoId = await page.evaluate(async (p) => {
|
||||
const repo = await window.__store!.getState().addRepoPath(p)
|
||||
if (!repo) {
|
||||
throw new Error('repo not added')
|
||||
}
|
||||
return repo.id
|
||||
}, repoPath)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async (id) => {
|
||||
const store = window.__store!
|
||||
await store.getState().fetchWorktrees(id)
|
||||
return store.getState().worktreesByRepo[id]?.length ?? 0
|
||||
}, repoId)
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
await page.evaluate(
|
||||
async ({ id, p }) => {
|
||||
const store = window.__store!
|
||||
const state = store.getState()
|
||||
const worktree = state.worktreesByRepo[id]!.find((w) => w.path === p)!
|
||||
state.setActiveRepo(id)
|
||||
state.setActiveWorktree(worktree.id)
|
||||
const status = await window.api.git.status({ worktreePath: p })
|
||||
const entries = status.entries.filter((entry) => entry.area === 'unstaged')
|
||||
state.setGitStatus(worktree.id, status)
|
||||
state.openAllDiffs(worktree.id, p, undefined, 'unstaged', entries)
|
||||
},
|
||||
{ id: repoId, p: repoPath }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Combined diff syntax colour', () => {
|
||||
test.use({ seedTestRepo: false })
|
||||
|
||||
test('a section scrolled into view already has syntax colours when its lines are laid out', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const repoPath = createRepo()
|
||||
try {
|
||||
await openCombinedDiff(orcaPage, repoPath)
|
||||
// Monaco loads a language's tokenizer on first use; start once the first section is coloured.
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate(() =>
|
||||
[...document.querySelectorAll('.combined-diff-scroll-container .view-line')].some(
|
||||
(line) =>
|
||||
new Set(
|
||||
[...line.querySelectorAll('span span')]
|
||||
// Bracket-pair colours appear without syntax tokens.
|
||||
.filter((t) => !/^[(){}]$/.test(t.textContent ?? ''))
|
||||
.map((t) => getComputedStyle(t).color)
|
||||
).size > 1
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await orcaPage.evaluate(() => {
|
||||
const seen: Record<string, boolean> = {}
|
||||
globalThis.__colouredWhenLaidOut = seen
|
||||
// Record each section once, at the first DOM mutation where its first added line exists.
|
||||
new MutationObserver(() => {
|
||||
for (const row of document.querySelectorAll<HTMLElement>(
|
||||
'[data-combined-diff-section-row]'
|
||||
)) {
|
||||
const key = row.dataset.combinedDiffSectionKey ?? ''
|
||||
const line = [...row.querySelectorAll('.editor.modified .view-line')].find(
|
||||
(candidate) => candidate.textContent?.startsWith('export\u00a0function')
|
||||
)
|
||||
if (key in seen || !line) {
|
||||
continue
|
||||
}
|
||||
const colours = new Set(
|
||||
[...line.querySelectorAll('span span')]
|
||||
// Bracket-pair colours appear without syntax tokens.
|
||||
.filter((t) => !/^[(){}]$/.test(t.textContent ?? ''))
|
||||
.map((t) => getComputedStyle(t).color)
|
||||
)
|
||||
seen[key] = colours.size > 1
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(() => {
|
||||
const container = document.querySelector<HTMLElement>(
|
||||
'.combined-diff-scroll-container'
|
||||
)!
|
||||
container.scrollTop += 400
|
||||
return Object.keys(globalThis.__colouredWhenLaidOut ?? {}).length
|
||||
}),
|
||||
{ intervals: [50], timeout: 15_000 }
|
||||
)
|
||||
.toBe(FILE_COUNT)
|
||||
const coloured = await orcaPage.evaluate(() => globalThis.__colouredWhenLaidOut)
|
||||
expect(Object.entries(coloured ?? {}).filter(([, value]) => !value)).toEqual([])
|
||||
} finally {
|
||||
rmSync(repoPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user