feat: unify execution and add workspace file imports
Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content. Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation. Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit. Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
This commit is contained in:
@@ -6,7 +6,7 @@ import { join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { build } from 'esbuild'
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest'
|
||||
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../src/main/assistant/assistant-database'
|
||||
import { getPendingAssistantStorageUpgrade } from '../src/main/assistant/assistant-storage-upgrade'
|
||||
import { assistantStorageProgressSchema } from '../src/shared/assistant-storage-contracts'
|
||||
@@ -25,6 +25,22 @@ afterAll(async () => {
|
||||
if (directory) await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function runWorker(databasePath: string, upgrade: NonNullable<ReturnType<typeof getPendingAssistantStorageUpgrade>>, cancelled = false) {
|
||||
const cancellation = new SharedArrayBuffer(4)
|
||||
if (cancelled) Atomics.store(new Int32Array(cancellation), 0, 1)
|
||||
const worker = new Worker(workerPath, { workerData: { databasePath, cancellation, upgrade } })
|
||||
const messages: Array<{ progress?: unknown; done?: boolean; cancelled?: boolean; error?: string }> = []
|
||||
worker.on('message', message => messages.push(message))
|
||||
try {
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
worker.once('error', reject)
|
||||
worker.once('exit', resolve)
|
||||
})
|
||||
expect(code).toBe(0)
|
||||
return messages
|
||||
} finally { await worker.terminate() }
|
||||
}
|
||||
|
||||
it.each([false, true])('runs the real startup worker with legacy notes=%s without touching unrelated data', async (legacyNotes) => {
|
||||
const root = await mkdtemp(join(directory, 'case-'))
|
||||
const databasePath = join(root, 'assistant.sqlite')
|
||||
@@ -46,6 +62,9 @@ it.each([false, true])('runs the real startup worker with legacy notes=%s withou
|
||||
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
|
||||
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;
|
||||
ALTER TABLE magic_note_entries DROP COLUMN source_json;
|
||||
ALTER TABLE projects ADD COLUMN default_work_mode TEXT NOT NULL DEFAULT 'ask';
|
||||
ALTER TABLE conversations ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'ask';
|
||||
ALTER TABLE tasks ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'execute';
|
||||
PRAGMA user_version = 46;`)
|
||||
if (legacyNotes) sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify(note.entries[0]!.content), note.entries[0]!.id)
|
||||
@@ -54,24 +73,9 @@ it.each([false, true])('runs the real startup worker with legacy notes=%s withou
|
||||
const bytesBefore = (await stat(databasePath)).size
|
||||
const upgrade = getPendingAssistantStorageUpgrade(databasePath)!
|
||||
expect(upgrade).toEqual({ migrateNotes: legacyNotes, reclaimSpace: legacyNotes })
|
||||
const run = async (cancelled: boolean) => {
|
||||
const cancellation = new SharedArrayBuffer(4)
|
||||
if (cancelled) Atomics.store(new Int32Array(cancellation), 0, 1)
|
||||
const worker = new Worker(workerPath, { workerData: { databasePath, cancellation, upgrade } })
|
||||
const messages: Array<{ progress?: unknown; done?: boolean; cancelled?: boolean; error?: string }> = []
|
||||
worker.on('message', message => messages.push(message))
|
||||
try {
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
worker.once('error', reject)
|
||||
worker.once('exit', resolve)
|
||||
})
|
||||
expect(code).toBe(0)
|
||||
return messages
|
||||
} finally { await worker.terminate() }
|
||||
}
|
||||
expect((await run(true)).at(-1)).toMatchObject({ cancelled: true })
|
||||
expect((await runWorker(databasePath, upgrade, true)).at(-1)).toMatchObject({ cancelled: true })
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(46)
|
||||
const messages = await run(false)
|
||||
const messages = await runWorker(databasePath, upgrade)
|
||||
expect(messages.at(-1)).toEqual({ done: true })
|
||||
expect(messages.some(message => message.error)).toBe(false)
|
||||
const stages = messages.flatMap(message => message.progress
|
||||
@@ -81,7 +85,8 @@ it.each([false, true])('runs the real startup worker with legacy notes=%s withou
|
||||
expect(stages).toContain('converting')
|
||||
expect(stages).toContain('compacting')
|
||||
} else {
|
||||
expect(stages).toEqual(['upgrading'])
|
||||
expect(stages).toContain('converting')
|
||||
expect(stages).not.toContain('compacting')
|
||||
expect((await stat(databasePath)).size).toBeGreaterThanOrEqual(bytesBefore)
|
||||
}
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
@@ -95,3 +100,81 @@ it.each([false, true])('runs the real startup worker with legacy notes=%s withou
|
||||
sql.close()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('upgrades a fresh historical schema 59 through the real worker without reconstructing dropped columns', async () => {
|
||||
const root = await mkdtemp(join(directory, 'schema59-'))
|
||||
const databasePath = join(root, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
const exec = DatabaseSync.prototype.exec
|
||||
// Build the actual historical schema from an empty file. Reject only the final
|
||||
// schema 60 DDL so its transaction rolls back; never reset user_version.
|
||||
const stop = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, sql: string) {
|
||||
if (sql.includes('ALTER TABLE projects DROP COLUMN default_work_mode')) throw new Error('Stop at historical schema 59')
|
||||
return exec.call(this, sql)
|
||||
})
|
||||
try {
|
||||
expect(() => database.initialize(root)).toThrow('Stop at historical schema 59')
|
||||
} finally {
|
||||
stop.mockRestore()
|
||||
database.close()
|
||||
}
|
||||
const sql = new DatabaseSync(databasePath)
|
||||
const projectId = randomUUID()
|
||||
const conversationId = randomUUID()
|
||||
const taskId = randomUUID()
|
||||
const messageId = randomUUID()
|
||||
const createdAt = '2026-01-01T00:00:00.000Z'
|
||||
const content = '/ask keep user text and workMode: execute'
|
||||
const userInput = { workMode: 'user-owned', toolApproval: 'user-owned' }
|
||||
try {
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(59)
|
||||
for (const [table, column] of [['projects', 'default_work_mode'], ['conversations', 'work_mode'], ['tasks', 'work_mode']] as const) {
|
||||
const definition = sql.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)!.sql as string
|
||||
expect(definition).toContain(`CHECK(${column} IN ('ask', 'execute'))`)
|
||||
}
|
||||
sql.prepare(`INSERT INTO projects (id, name, description, root_path, default_work_mode, status, created_at, updated_at)
|
||||
VALUES (?, 'Historical project', '', ?, 'execute', 'active', ?, ?)`)
|
||||
.run(projectId, root, createdAt, createdAt)
|
||||
sql.prepare("INSERT INTO project_execution_spaces (project_id, kind, root_path) VALUES (?, 'local', ?)")
|
||||
.run(projectId, root)
|
||||
sql.prepare(`INSERT INTO conversations (id, project_id, work_mode, context_state_json, title, created_at, updated_at)
|
||||
VALUES (?, ?, 'ask', ?, 'Historical chat', ?, ?)`)
|
||||
.run(conversationId, projectId, JSON.stringify({ workMode: 'ask', knowledgeLibraryIds: [], storyGraphEnabled: true }), createdAt, createdAt)
|
||||
sql.prepare(`INSERT INTO tasks (id, project_id, conversation_id, title, instructions, origin, status, work_mode, created_at)
|
||||
VALUES (?, ?, ?, 'Historical task', ?, 'user', 'completed', 'execute', ?)`)
|
||||
.run(taskId, projectId, conversationId, content, createdAt)
|
||||
const activity = { childTaskId: randomUUID(), actor: { kind: 'direct-model', label: 'Child' }, routingMode: 'native', state: 'completed' }
|
||||
sql.prepare(`INSERT INTO messages (id, conversation_id, role, content, state, sequence, metadata_json, created_at)
|
||||
VALUES (?, ?, 'assistant', ?, 'complete', 0, ?, ?)`)
|
||||
.run(messageId, conversationId, content, JSON.stringify({ subagents: [{ ...activity, workMode: 'execute' }] }), createdAt)
|
||||
sql.prepare(`INSERT INTO task_events (task_id, kind, payload_json, created_at,
|
||||
remote_binding_id, remote_operation_id, remote_semantic_sequence, remote_event_index)
|
||||
VALUES (?, 'status', ?, ?, 'binding', 'operation', '7', 0)`)
|
||||
.run(taskId, JSON.stringify({ workMode: 'ask', status: 'completed', input: userInput }), createdAt)
|
||||
const eventBefore = sql.prepare('SELECT * FROM task_events WHERE task_id = ?').get(taskId)!
|
||||
sql.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
const upgrade = getPendingAssistantStorageUpgrade(databasePath)!
|
||||
expect(upgrade).toEqual({ migrateNotes: false, reclaimSpace: false })
|
||||
expect((await runWorker(databasePath, upgrade)).at(-1)).toEqual({ done: true })
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
|
||||
for (const [table, column] of [['projects', 'default_work_mode'], ['conversations', 'work_mode'], ['tasks', 'work_mode']] as const) {
|
||||
expect(sql.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name)).not.toContain(column)
|
||||
}
|
||||
expect(JSON.parse(sql.prepare('SELECT context_state_json FROM conversations WHERE id = ?').get(conversationId)!.context_state_json as string))
|
||||
.toEqual({ knowledgeLibraryIds: [], storyGraphEnabled: true })
|
||||
expect(JSON.parse(sql.prepare('SELECT metadata_json FROM messages WHERE id = ?').get(messageId)!.metadata_json as string))
|
||||
.toEqual({ subagents: [activity] })
|
||||
expect(sql.prepare('SELECT * FROM task_events WHERE task_id = ?').get(taskId)).toEqual({
|
||||
...eventBefore, payload_json: JSON.stringify({ status: 'completed', input: userInput })
|
||||
})
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
|
||||
expect(sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
expect(getPendingAssistantStorageUpgrade(databasePath)).toBeUndefined()
|
||||
database.initialize(root)
|
||||
expect(database.getConversation(conversationId).messages[0]).toMatchObject({ id: messageId, content, subagents: [activity] })
|
||||
expect(database.getTask(taskId).instructions).toBe(content)
|
||||
} finally {
|
||||
database.close()
|
||||
sql.close()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
@@ -29,7 +29,7 @@ async function runAndCancel(runtime: AgentRuntime, conversationId: string): Prom
|
||||
let partial = ''
|
||||
try {
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: firstPrompt
|
||||
requestId: crypto.randomUUID(), conversationId, prompt: firstPrompt
|
||||
}, controller.signal)) {
|
||||
if (event.type === 'text') partial += event.delta
|
||||
if (partial.length >= 60 && !controller.signal.aborted) controller.abort(new Error('用户取消了请求'))
|
||||
@@ -87,7 +87,7 @@ describe.skipIf(!envPath)('cancelled turn handling with a real model', () => {
|
||||
])
|
||||
expect(history).toHaveLength(2)
|
||||
expectOnlyNewAnswer(await runText(runtime, {
|
||||
requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: secondPrompt,
|
||||
requestId: crypto.randomUUID(), conversationId, prompt: secondPrompt,
|
||||
history: history.map(({ role, content }) => ({ role, content })),
|
||||
historyMessageIds: history.map(({ id }) => id)
|
||||
}))
|
||||
@@ -107,7 +107,7 @@ describe.skipIf(!envPath)('cancelled turn handling with a real model', () => {
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '', state: 'error', terminalStatus: 'cancelled' }
|
||||
])
|
||||
expectOnlyNewAnswer(await runText(runtime, {
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), workMode: 'ask', prompt: secondPrompt,
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: secondPrompt,
|
||||
history: history.map(({ role, content }) => ({ role, content })),
|
||||
historyMessageIds: history.map(({ id }) => id)
|
||||
}))
|
||||
@@ -130,12 +130,12 @@ describe.skipIf(!envPath)('cancelled turn handling with a real model', () => {
|
||||
const partial = await runAndCancel(runtime, conversationId)
|
||||
console.info(`OPENCODE_PARTIAL_CHARS=${partial.length}`)
|
||||
expectOnlyNewAnswer(await runText(runtime, {
|
||||
requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: secondPrompt
|
||||
requestId: crypto.randomUUID(), conversationId, prompt: secondPrompt
|
||||
}))
|
||||
// The cancelled request stays in the native transcript, so the model
|
||||
// still knows what was asked before.
|
||||
const recall = await runText(runtime, {
|
||||
requestId: crypto.randomUUID(), conversationId, workMode: 'ask',
|
||||
requestId: crypto.randomUUID(), conversationId,
|
||||
prompt: 'In one short phrase, what did I ask for in my very first message of this conversation? Do not list anything.'
|
||||
})
|
||||
console.info(`OPENCODE_RECALL=${JSON.stringify(recall)}`)
|
||||
|
||||
+11
-11
@@ -88,11 +88,11 @@
|
||||
},
|
||||
{
|
||||
"id": "doc-runtime",
|
||||
"title": "Runtime modes",
|
||||
"title": "Runtime execution",
|
||||
"chunks": [
|
||||
{
|
||||
"id": "chunk-runtime-main",
|
||||
"content": "Ask mode is read-only at the runtime boundary. Execute mode can use tools only after the configured approval check."
|
||||
"content": "Requests use the selected Runtime and enabled capabilities. Tools run with the execution account permissions without a product work mode or general approval gate."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -137,12 +137,12 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "doc-approval-secondary",
|
||||
"title": "审批记录",
|
||||
"id": "doc-permissions-secondary",
|
||||
"title": "执行权限",
|
||||
"chunks": [
|
||||
{
|
||||
"id": "chunk-approval-secondary",
|
||||
"content": "执行敏感工具前应显示审批提示,并记录本次允许的操作范围。"
|
||||
"id": "chunk-permissions-secondary",
|
||||
"content": "工具使用执行账户的权限,仍须遵循用户指令,并保留取消和超时控制。"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -315,7 +315,7 @@
|
||||
{
|
||||
"id": "query-runtime-zh",
|
||||
"language": "zh-CN",
|
||||
"query": "询问模式是否允许写入,工具何时需要审批",
|
||||
"query": "请求使用哪个运行时,工具执行由什么权限决定",
|
||||
"noAnswer": false,
|
||||
"judgments": [
|
||||
{
|
||||
@@ -323,19 +323,19 @@
|
||||
"relevance": 3,
|
||||
"spans": [
|
||||
{
|
||||
"text": "Ask mode is read-only at the runtime boundary."
|
||||
"text": "Requests use the selected Runtime and enabled capabilities."
|
||||
},
|
||||
{
|
||||
"text": "Execute mode can use tools only after the configured approval check."
|
||||
"text": "Tools run with the execution account permissions without a product work mode or general approval gate."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"chunkId": "chunk-approval-secondary",
|
||||
"chunkId": "chunk-permissions-secondary",
|
||||
"relevance": 1,
|
||||
"spans": [
|
||||
{
|
||||
"text": "执行敏感工具前应显示审批提示"
|
||||
"text": "工具使用执行账户的权限"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function run(root: string, snapshot: CapabilitySnapshot): Promise<v
|
||||
try {
|
||||
let output = ''
|
||||
const tools: string[] = []
|
||||
for await (const event of runtime.run({ requestId, conversationId: crypto.randomUUID(), workMode: 'execute',
|
||||
for await (const event of runtime.run({ requestId, conversationId: crypto.randomUUID(),
|
||||
knowledgeCapabilityToken: token,
|
||||
prompt: `Use only the assigned Obsidian tools. Call obsidian_write_note to write ${provider}.md with content ${marker}, then obsidian_read_note to read it. Reply with the content read. Do not use shell or other tools.`
|
||||
}, controller.signal)) {
|
||||
|
||||
@@ -40,6 +40,7 @@ it('keeps menus and notifications interactive above real Electron modals', async
|
||||
}, 120000)
|
||||
|
||||
it.each([
|
||||
['omits product modes and Security and clears local data only after General confirmation', 'unified-execution-main.ts'],
|
||||
['saves the conversation story graph switch from App through production IPC and revokes existing tools', 'story-graph-switch-main.ts'],
|
||||
['opens the MCP editor from App settings with native browser isolation and real persistence', 'mcp-overlay-main.ts'],
|
||||
['keeps nested previews, sandbox keyboard, project modals and native overlays usable', 'overlay-paths-main.ts']
|
||||
@@ -96,6 +97,9 @@ it.each([
|
||||
} else if (entry === 'story-graph-switch-main.ts') {
|
||||
expect(result).toMatchObject({ persistedAfterReload: true, revokedExistingBinding: true, hiddenWhenDisabled: true })
|
||||
expect(result.scenarios).toHaveLength(4)
|
||||
} else if (entry === 'unified-execution-main.ts') {
|
||||
expect(result).toMatchObject({ composer: true, project: true, securityAbsent: true,
|
||||
generalClearData: true, cancelPreservedData: true, confirmedClear: true, persistedAfterReload: true })
|
||||
} else {
|
||||
expect(result).toMatchObject({ image: true, iframe: true, project: true, tooltip: true, citation: true })
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ void app.whenReady().then(async () => {
|
||||
const conversationId = `review-probe:${randomUUID()}`
|
||||
let text = ''
|
||||
try {
|
||||
for await (const event of runtime.run({ requestId: randomUUID(), conversationId, workMode: 'ask', prompt },
|
||||
for await (const event of runtime.run({ requestId: randomUUID(), conversationId, prompt },
|
||||
AbortSignal.timeout(90000), async () => 'deny')) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
if (event.type === 'error') throw new Error(event.message)
|
||||
|
||||
@@ -36,7 +36,7 @@ app.whenReady().then(async () => {
|
||||
modelProfiles: runtimeSettings.modelProfiles.map(profile => ({ id: profile.id, supportsImageInput: profile.supportsImageInput }))
|
||||
} : runtimeSettings } as never,
|
||||
{} as never, { clear() {}, cancelImport() {} } as never, {} as never, database,
|
||||
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined,
|
||||
{} as never, async () => {}, undefined, undefined, undefined, undefined,
|
||||
applicationSettings)
|
||||
if (!live) win.webContents.on('console-message', event => console.log(event.message))
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,7 @@ app.whenReady().then(async () => {
|
||||
{} as never, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never, database,
|
||||
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings,
|
||||
{} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
{ getPending: async () => undefined } as never)
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') console.error(event.message) })
|
||||
|
||||
@@ -60,7 +60,7 @@ void app.whenReady().then(async () => {
|
||||
{} as never, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never, database,
|
||||
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings,
|
||||
{} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
{ getPending: async () => undefined } as never)
|
||||
const run = <T = unknown>(script: string): Promise<T> => win.webContents.executeJavaScript(script, true)
|
||||
|
||||
@@ -38,7 +38,7 @@ app.whenReady().then(async () => {
|
||||
capabilities, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
|
||||
database, { clear() {} } as never, {} as never, async () => {}, undefined, browser,
|
||||
database, {} as never, async () => {}, undefined, browser,
|
||||
undefined, undefined, applicationSettings, undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
|
||||
const errors: string[] = []
|
||||
|
||||
@@ -25,6 +25,11 @@ app.whenReady().then(async () => {
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
database.initialize(directory)
|
||||
const project = database.listProjects()[0]!
|
||||
const archivedProject = database.createProject({ name: 'Archived workspace', description: '', rootPath: directory })
|
||||
const archivedConversationId = randomUUID()
|
||||
database.saveLocalConversations([{ header: { id: archivedConversationId, projectId: archivedProject.id, title: 'Archived history', updatedAt: 1 },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', state: 'complete', createdAt: 1, content: 'Preserved archived message' }] }])
|
||||
database.setProjectArchived(archivedProject.id, true)
|
||||
const conversationId = randomUUID()
|
||||
const resultId = randomUUID()
|
||||
const html = '<html><body><a href="#end">Jump to end</a>' + '<p>Long preview text</p>'.repeat(80) +
|
||||
@@ -36,6 +41,10 @@ app.whenReady().then(async () => {
|
||||
snippet: 'Saved citation', rank: 1, external: { kind: 'external', provider: 'fastgpt', instanceId: randomUUID(), remoteKnowledgeBaseId: 'review' } }]
|
||||
}, { id: randomUUID(), role: 'assistant', state: 'complete', createdAt: Date.now(),
|
||||
content: '```html\n<html><body><h1>Plain keyboard preview</h1>' + '<p>Long plain content</p>'.repeat(80) + '</body></html>\n```' }] }])
|
||||
database.saveLocalConversations(Array.from({ length: 14 }, (_, i) => ({
|
||||
header: { id: randomUUID(), projectId: project.id, title: `Earlier workspace conversation with a long title ${i}`,
|
||||
updatedAt: new Date(2025, 8, 2, 10, i).getTime() }, messages: []
|
||||
})))
|
||||
const settings = { ...defaultRuntimeSettings, workspacePath: directory, modelProfiles: [], embeddingConnections: [] }
|
||||
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
|
||||
await applicationSettings.update({ checkUpdatesOnStartup: false })
|
||||
@@ -49,7 +58,7 @@ app.whenReady().then(async () => {
|
||||
capabilities, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
|
||||
database, { clear() {} } as never, {} as never, async () => {}, undefined, browser,
|
||||
database, {} as never, async () => {}, undefined, browser,
|
||||
undefined, undefined, applicationSettings, undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
|
||||
// Parsing payloads are fixtures; App, preload, window keyboard IPC and browser services are production.
|
||||
@@ -161,13 +170,13 @@ app.whenReady().then(async () => {
|
||||
assert(await js('document.querySelector(".project-switcher__control").matches("button") && document.querySelectorAll(".project-switcher__picker button").length === 1 && !!document.querySelector(".project-switcher__trigger .project-switcher__activity")'))
|
||||
await screenshot('workspace-idle-trigger')
|
||||
database.createTask({ id: randomUUID(), projectId: project.id, conversationId,
|
||||
title: 'Review workspace layout', instructions: 'Layout fixture only', workMode: 'ask', status: 'running' })
|
||||
title: 'Review workspace layout', instructions: 'Layout fixture only', status: 'running' })
|
||||
win.webContents.send(ipcChannels.conversationsChanged)
|
||||
await wait('!!document.querySelector(".project-switcher__control .project-activity__running")')
|
||||
assert.equal(await js('document.querySelector(".project-switcher__control").getBoundingClientRect().height'), idleTrigger)
|
||||
await screenshot('workspace-active-trigger')
|
||||
const workspaceLayouts = []
|
||||
for (const [width, height] of [[1280, 800], [1280, 480], [560, 640]]) {
|
||||
for (const [width, height] of [[1280, 800], [1280, 480], [560, 640], [375, 480]]) {
|
||||
win.setContentSize(width!, height!)
|
||||
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
|
||||
if (width! < 900) {
|
||||
@@ -181,7 +190,7 @@ app.whenReady().then(async () => {
|
||||
await click('.project-switcher__activity')
|
||||
await wait('!!document.querySelector(".workspace-menu")')
|
||||
await js('Promise.all(document.getAnimations().filter(a => a instanceof CSSTransition).map(a => a.finished.catch(() => {})))')
|
||||
const layout = await js<{ bounded: boolean; divider: boolean; compact: boolean; rows: boolean }>(`(() => {
|
||||
const layout = await js<{ bounded: boolean; divider: boolean; compact: boolean; rows: boolean; gap: number; triggerHit: boolean; times: boolean; enter: boolean }>(`(() => {
|
||||
const rect = selector => document.querySelector(selector).getBoundingClientRect();
|
||||
const left = rect('.workspace-menu__projects'), input = rect('.workspace-menu__search');
|
||||
const menu = rect('.workspace-menu'), toolbar = rect('.workspace-menu__toolbar');
|
||||
@@ -189,21 +198,87 @@ app.whenReady().then(async () => {
|
||||
const border = getComputedStyle(document.querySelector('.workspace-menu__projects'));
|
||||
const row = document.querySelector('.workspace-menu [role="menuitemradio"]');
|
||||
const parts = [...row.querySelector('span').children].map(e => e.getBoundingClientRect());
|
||||
const trigger = document.querySelector('.project-switcher__control'), anchor = trigger.getBoundingClientRect();
|
||||
const times = [...document.querySelectorAll('.workspace-menu__conversation time')];
|
||||
const enter = rect('.workspace-menu__enter button'), right = rect('.workspace-menu__activity');
|
||||
return { bounded: input.left >= left.left && input.right <= left.right && menu.left >= 16 && menu.right <= innerWidth - 16 && menu.top >= 16 && menu.bottom <= innerHeight - 16,
|
||||
divider: border.borderRightWidth === '1px' && border.borderRightStyle === 'solid' && border.borderRightColor === getComputedStyle(document.querySelector('.workspace-menu__search')).borderRightColor,
|
||||
compact: toolbar.height < 70 && filters.right <= action.left && Math.abs(filters.top + filters.height / 2 - action.top - action.height / 2) < 2,
|
||||
rows: parts.length >= 3 && parts[0].bottom <= parts[1].top && parts[1].bottom <= parts[2].top };
|
||||
rows: parts.length >= 3 && parts[0].bottom <= parts[1].top && parts[1].bottom <= parts[2].top,
|
||||
gap: menu.top - anchor.bottom,
|
||||
triggerHit: [anchor.top + 8, anchor.bottom - 8].every(y => trigger.contains(document.elementFromPoint(anchor.left + anchor.width / 2, y))),
|
||||
enter: enter.left >= right.left && Math.abs(enter.right - right.right + 8) <= 1 && Math.abs(enter.bottom - right.bottom + 8) <= 1 && enter.top >= toolbar.bottom,
|
||||
times: times.length === 11 && times.every(time => {
|
||||
const button = time.closest('button'), title = button.querySelector('strong').getBoundingClientRect(), r = time.getBoundingClientRect(), status = button.querySelector('.workspace-menu__metadata small');
|
||||
return time.dateTime && time.title && title.width > 0 && title.right <= r.left && r.right <= button.getBoundingClientRect().right && (!status || status.getBoundingClientRect().top >= r.bottom);
|
||||
}) };
|
||||
})()`)
|
||||
assert.deepEqual(layout, { bounded: true, divider: true, compact: true, rows: true }, `${width}x${height} ${theme}: ${JSON.stringify(layout)}`)
|
||||
assert.deepEqual(layout, { bounded: true, divider: true, compact: true, rows: true, gap: 8, triggerHit: true, times: true, enter: true }, `${width}x${height} ${theme}: ${JSON.stringify(layout)}`)
|
||||
await screenshot(`workspace-${width}x${height}-${theme}`)
|
||||
await js('document.querySelector(".workspace-menu__conversation").focus()')
|
||||
await key('End')
|
||||
await wait('!!document.activeElement.closest(".workspace-menu__conversations [aria-posinset=\\"11\\"]")')
|
||||
const wheelPoint = await js<{ x: number; y: number }>(`(() => {
|
||||
const r = document.querySelector('.workspace-menu__conversations').getBoundingClientRect();
|
||||
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
|
||||
})()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseWheel', ...wheelPoint, deltaX: 0, deltaY: -500, canScroll: true })
|
||||
await wait('document.querySelector(".workspace-menu__conversations").scrollTop > 0')
|
||||
const scrolling = await js<{ visible: boolean; scrolled: boolean; noOverflow: boolean; geometry: number[] }>(`(() => {
|
||||
const list = document.querySelector('.workspace-menu__conversations'), row = document.activeElement;
|
||||
const r = row.getBoundingClientRect(), bounds = list.getBoundingClientRect();
|
||||
return { visible: r.top >= bounds.top - 1 && r.bottom <= bounds.bottom + 1,
|
||||
scrolled: list.scrollTop > 0, noOverflow: list.scrollWidth === list.clientWidth,
|
||||
geometry: [r.top, r.bottom, bounds.top, bounds.bottom, list.scrollTop, list.scrollHeight, list.clientHeight, list.scrollWidth, list.clientWidth] };
|
||||
})()`)
|
||||
assert(scrolling.visible && scrolling.scrolled && scrolling.noOverflow, JSON.stringify(scrolling))
|
||||
const stable = await js(`new Promise(resolve => {
|
||||
const samples = [];
|
||||
const sample = () => { const r = document.querySelector('.workspace-menu').getBoundingClientRect(); samples.push([r.top, r.height]);
|
||||
if (samples.length < 12) requestAnimationFrame(sample); else resolve(samples.every(r => r[0] === samples[0][0] && r[1] === samples[0][1])); };
|
||||
requestAnimationFrame(sample);
|
||||
})`)
|
||||
assert(stable, 'Menu geometry must remain stable during internal scroll')
|
||||
if (width === 1280 && height === 800 && theme === 'light') {
|
||||
win.setContentSize(1280, 480)
|
||||
await wait(`(() => {
|
||||
const menu = document.querySelector('.workspace-menu').getBoundingClientRect();
|
||||
const trigger = document.querySelector('.project-switcher__control').getBoundingClientRect();
|
||||
return innerHeight === 480 && menu.top === trigger.bottom + 8 && menu.bottom <= innerHeight - 16;
|
||||
})()`)
|
||||
win.setContentSize(width, height)
|
||||
await wait('innerHeight === 800 && document.querySelector(".workspace-menu").getBoundingClientRect().height > 600')
|
||||
}
|
||||
await key('Escape')
|
||||
await wait('!document.querySelector(".workspace-menu") && document.activeElement.matches(".project-switcher__trigger")')
|
||||
workspaceLayouts.push({ width, height, theme, ...layout })
|
||||
workspaceLayouts.push({ width, height, theme, ...layout, internalScroll: true, stable })
|
||||
}
|
||||
}
|
||||
evidence.workspaceLayouts = workspaceLayouts
|
||||
evidence.workspaceTriggerHeight = idleTrigger
|
||||
await key('Escape')
|
||||
win.setContentSize(1280, 800)
|
||||
await wait('innerWidth === 1280')
|
||||
await click('.sidebar-toggle')
|
||||
await wait('!document.querySelector(".sidebar").inert')
|
||||
await click('.project-switcher__trigger')
|
||||
await click('.workspace-menu__archived')
|
||||
await click('.workspace-menu__archived-project button')
|
||||
await wait('!document.querySelector(".workspace-menu__archived-project")')
|
||||
assert.equal(database.getProject(archivedProject.id).status, 'active')
|
||||
assert(await js(`!document.querySelector('.project-switcher__trigger').textContent.includes('Archived workspace')`), 'Restore must not switch project')
|
||||
await click('.workspace-menu__archive-heading button')
|
||||
const archivedRow = `[data-list-window-row="${archivedProject.id}"] [role="menuitemradio"]`
|
||||
await js(`document.querySelector(${JSON.stringify(archivedRow)}).focus()`)
|
||||
await wait('document.querySelector(".workspace-menu__conversations").textContent.includes("Archived history")')
|
||||
await click('.workspace-menu__enter button')
|
||||
await wait('document.querySelector(".project-switcher__trigger").textContent.includes("Archived workspace") && document.body.innerText.includes("Preserved archived message")')
|
||||
assert.equal(database.listConversationSummaries().filter(item => item.projectId === archivedProject.id).length, 1, 'Entry must not create a replacement conversation')
|
||||
await click('.project-switcher__trigger')
|
||||
await js(`document.querySelector('[data-list-window-row="${project.id}"] [role="menuitemradio"]').focus()`)
|
||||
await click('.workspace-menu__enter button')
|
||||
await wait('!!document.querySelector(".message-html-preview")')
|
||||
evidence.archiveRestored = true
|
||||
win.setContentSize(760, 700)
|
||||
await wait('document.querySelector(".sidebar").inert')
|
||||
await click('.sidebar-toggle')
|
||||
@@ -221,7 +296,7 @@ app.whenReady().then(async () => {
|
||||
const tabs = [...document.querySelectorAll('.workspace-menu__categories [role="tab"]')];
|
||||
return tabs.length === 3 && tabs[0].getAttribute('aria-selected') === 'true' &&
|
||||
tabs.every(tab => tab.getBoundingClientRect().top === tabs[0].getBoundingClientRect().top) &&
|
||||
document.querySelectorAll('.workspace-menu [role="menuitemradio"]').length === 1 &&
|
||||
document.querySelectorAll('.workspace-menu [role="menuitemradio"]').length === 2 &&
|
||||
!document.querySelector('.workspace-menu__scope');
|
||||
})()`))
|
||||
evidence.workspaceCategories = true
|
||||
@@ -312,7 +387,23 @@ app.whenReady().then(async () => {
|
||||
await waitNative(true)
|
||||
assert.equal(native()!.webContents.id, nativeId)
|
||||
assert.equal(await native()!.webContents.executeJavaScript('window.overlayToken'), 'same-document')
|
||||
if (await js('document.querySelector(".sidebar").inert')) await click('.sidebar-toggle')
|
||||
await wait('!document.querySelector(".sidebar").inert')
|
||||
await js('Promise.all(document.getAnimations().filter(a => a instanceof CSSTransition).map(a => a.finished.catch(() => {})))')
|
||||
await click('.project-switcher__trigger')
|
||||
await wait('!!document.querySelector(".workspace-menu")')
|
||||
const menuOverlaps = await js<boolean>(`(() => {
|
||||
const a = document.querySelector('.workspace-menu').getBoundingClientRect(), b = document.querySelector('.assistant-sidebar__browser-viewport').getBoundingClientRect();
|
||||
return a.right > b.left && a.left < b.right && a.top < b.bottom && a.bottom > b.top;
|
||||
})()`)
|
||||
assert.equal(menuOverlaps, width === 1000)
|
||||
await waitNative(!menuOverlaps)
|
||||
await key('Escape')
|
||||
await waitNative(true)
|
||||
assert.equal(native()!.webContents.id, nativeId)
|
||||
assert.equal(await native()!.webContents.executeJavaScript('window.overlayToken'), 'same-document')
|
||||
}
|
||||
evidence.workspaceBrowserOcclusion = true
|
||||
evidence.tooltip = true
|
||||
evidence.modelAttempts = (globalThis as typeof globalThis & { overlayModelAttempts?: number }).overlayModelAttempts ?? 0
|
||||
assert.equal(evidence.modelAttempts, 0)
|
||||
|
||||
@@ -43,7 +43,7 @@ app.whenReady().then(async () => {
|
||||
{ capability: 'text', getStatus: async () => ({ available: true, name: 'Switch test', capability: 'text' }) } as never,
|
||||
'CommandOrControl+Shift+Space', settings, capabilities,
|
||||
{ clear() {}, cancelImport() {}, getDraft: () => [] } as never, knowledge,
|
||||
database, { clear() {} } as never, {} as never, async () => {}, undefined, undefined,
|
||||
database, {} as never, async () => {}, undefined, undefined,
|
||||
undefined, undefined, application, undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
|
||||
const js = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
|
||||
|
||||
@@ -150,7 +150,7 @@ app
|
||||
await wait('document.querySelectorAll(".supervisor-activity__item").length === 3')
|
||||
} else if (view === 'pending' || view === 'failure') {
|
||||
await js('document.querySelector(".supervisor-workspace__toolbar .primary-button").click()')
|
||||
await wait(view === 'pending' ? '!!document.querySelector(".supervisor-workspace [role=status]")' : '!!document.querySelector(".supervisor-workspace [role=alert]")')
|
||||
await wait(`document.documentElement.dataset.reviewNoticeTone === '${view === 'pending' ? 'info' : 'error'}'`)
|
||||
}
|
||||
await js('document.fonts.ready')
|
||||
for (const theme of ['light', 'dark']) {
|
||||
@@ -171,7 +171,7 @@ app
|
||||
tail:document.querySelector('.supervisor-workspace__prose')?.textContent.includes('长摘要末尾'),
|
||||
controls:[...document.querySelectorAll('.supervisor-activity > .supervisor-workspace__action-bar > *')].map(e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top};}),
|
||||
toolbarControls:[...document.querySelectorAll('.supervisor-workspace__toolbar select, .supervisor-workspace__toolbar button')].map(e=>({disabled:e.disabled,text:e.textContent,top:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom})),
|
||||
notice:box('.supervisor-workspace__run-status'), dismiss:box('.supervisor-workspace__run-status .icon-button'),
|
||||
notice:box('.supervisor-workspace__run-status'),
|
||||
cancel:box('.supervisor-activity__item .danger-ghost') };
|
||||
})()`)
|
||||
assert(report.pageWidth <= width, 'No page overflow')
|
||||
@@ -188,10 +188,8 @@ app
|
||||
assert(report.prose.left - report.recap.left <= 25, 'No separately centered inner body')
|
||||
if (view === 'pending') {
|
||||
assert(report.toolbarControls.every(control => !control.disabled), 'Long review keeps toolbar enabled')
|
||||
assert.equal(report.toolbarControls[2].text, '回顾当前进展', 'Start name remains stable')
|
||||
assert(report.dismiss.width >= 28 && report.dismiss.height >= 28, 'Notice close target remains usable')
|
||||
assert(report.dismiss.right <= report.notice.right && report.dismiss.bottom <= report.notice.bottom, 'Close remains inside notice')
|
||||
assert(report.notice.right - report.dismiss.right <= 20, 'Notice close stays at the right edge on narrow screens')
|
||||
assert.equal(report.toolbarControls[2].text, '回顾', 'Start name remains stable')
|
||||
assert.equal(report.notice, undefined, 'Operation feedback does not occupy workspace layout')
|
||||
if (width >= 1024) assert(Math.max(...report.toolbarControls.map(control => control.top)) - Math.min(...report.toolbarControls.map(control => control.top)) <= 4, 'Toolbar stays on one aligned row')
|
||||
}
|
||||
} else {
|
||||
@@ -399,9 +397,9 @@ app
|
||||
await js('document.querySelector(".supervisor-workspace__toolbar .primary-button").click(); document.querySelector(".page-shell").scrollTop = 0')
|
||||
await settle()
|
||||
assert(await js('document.querySelector(".supervisor-workspace__recap").textContent.includes("上期结果")'))
|
||||
assert(await js('document.querySelector(".supervisor-workspace [role=status]").textContent.includes("新回顾正在整理")'))
|
||||
assert(await js('!document.querySelector(".supervisor-workspace__run-status")'))
|
||||
await writeFile(join(artifacts, 'recap-running-history-390.png'), (await win.webContents.capturePage()).toPNG())
|
||||
await js('document.querySelector(".supervisor-workspace [role=status] button").click()')
|
||||
await js('document.querySelector("#supervisor-tab-activity").click()')
|
||||
await wait('document.querySelector("#supervisor-tab-activity").getAttribute("aria-selected") === "true"')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
/>
|
||||
</div> : <PageShell variant="supervisor">
|
||||
<HeartbeatCenter
|
||||
onNotify={(notice) => { document.documentElement.dataset.reviewNoticeTone = notice.tone }}
|
||||
applicationSettings={applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })}
|
||||
onUpdateApplicationSettings={async () => true}
|
||||
configs={menu || params.has('plan-only') ? [{ id: 'plan', name: '模拟每日回顾', scope: { kind: 'global' }, timezone: 'Asia/Shanghai', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 48, retentionDays: 90, nextRunAt: '2026-09-24T01:00:00.000Z', createdAt, updatedAt: createdAt }] : []}
|
||||
|
||||
@@ -37,7 +37,7 @@ app.whenReady().then(async () => {
|
||||
{ getEnabledBuiltinMcpServerIds: async () => [] } as never, new ContextManager(),
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
|
||||
database, { clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, appSettings,
|
||||
database, {} as never, async () => {}, undefined, undefined, undefined, undefined, appSettings,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
{ getPending: async () => undefined } as never)
|
||||
const run = <T = unknown>(script: string): Promise<T> => win.webContents.executeJavaScript(script, true)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { app, BrowserWindow, safeStorage } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpcHandlers } from '../../src/main/ipc'
|
||||
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
||||
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
|
||||
import { CapabilityService } from '../../src/main/capabilities/capability-service'
|
||||
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
|
||||
|
||||
const directory = process.env.GB_MCP_DIRECTORY!
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1280, height: 900, useContentSize: true,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false,
|
||||
backgroundThrottling: false, preload: join(directory, 'preload.cjs') } })
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
database.initialize(directory)
|
||||
const conversationId = randomUUID()
|
||||
database.saveLocalConversations([{ header: { id: conversationId, projectId: database.listProjects()[0]!.id,
|
||||
title: 'Confirmation sentinel', updatedAt: Date.now() }, messages: [
|
||||
{ id: randomUUID(), role: 'assistant', state: 'complete', createdAt: Date.now(), content: 'Keep until confirmed' }
|
||||
] }])
|
||||
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
|
||||
await applicationSettings.update({ checkUpdatesOnStartup: false })
|
||||
const cipher = { isAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: (value: string) => safeStorage.encryptString(value), decrypt: (value: Buffer) => safeStorage.decryptString(value) }
|
||||
const settings = new RuntimeSettingsStore(join(directory, 'runtime.json'), cipher, {})
|
||||
const capabilities = new CapabilityService(join(directory, 'capabilities.json'),
|
||||
join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher)
|
||||
// App, preload, IPC, settings and SQLite are production; unrelated startup services are isolated.
|
||||
const dispose = registerIpcHandlers(win,
|
||||
{ capability: 'text', getStatus: async () => ({ available: true, name: 'UI regression', capability: 'text' }) } as never,
|
||||
'CommandOrControl+Shift+Space', settings, capabilities,
|
||||
{ clear() {}, cancelImport() {}, getDraft: () => [] } as never,
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
|
||||
database, {} as never, async () => {}, undefined, undefined,
|
||||
undefined, undefined, applicationSettings, undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
|
||||
const js = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
|
||||
const wait = async (code: string): Promise<void> => {
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (await js(code)) return
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
throw new Error(`Timeout: ${code}\n${await js('document.body.innerText')}`)
|
||||
}
|
||||
const click = async (selector: string): Promise<void> => {
|
||||
await wait(`!!document.querySelector(${JSON.stringify(selector)})`)
|
||||
await js(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'center'})`)
|
||||
await js('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
|
||||
const point = await js<{ x: number; y: number; hit: boolean }>(`(() => {
|
||||
const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect(),x=Math.round(r.x+r.width/2),y=Math.round(r.y+r.height/2);
|
||||
return {x,y,hit:e.contains(document.elementFromPoint(x,y))}; })()`)
|
||||
win.focus(); win.webContents.focus()
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
|
||||
await wait(`(() => { const e=document.querySelector(${JSON.stringify(selector)}); return e.contains(document.elementFromPoint(${point.x},${point.y})); })()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
|
||||
}
|
||||
const escape = async (): Promise<void> => {
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
}
|
||||
const preserved = (): boolean => database.listConversationSummaries().some(item => item.id === conversationId)
|
||||
const assertNoMode = async (selector: string): Promise<void> => {
|
||||
assert(await js(`(() => {
|
||||
const root=document.querySelector(${JSON.stringify(selector)});
|
||||
return !!root && !root.querySelector('.composer-picker--mode, [name="workMode"], [name="defaultWorkMode"]') &&
|
||||
![...root.querySelectorAll('button, label, [role="group"]')].some(e => /^(Ask|Execute|Plan|Default mode|Work mode)$/.test((e.getAttribute('aria-label') || e.textContent).trim()));
|
||||
})()`), `Product work mode must be absent in ${selector}`)
|
||||
}
|
||||
try {
|
||||
await win.loadURL(process.env.GB_MCP_URL!)
|
||||
await wait('!!document.querySelector(".composer textarea") && document.body.innerText.includes("Keep until confirmed")')
|
||||
await assertNoMode('.composer')
|
||||
await click('.composer__options-trigger')
|
||||
await wait('!!document.querySelector(".composer__options")')
|
||||
await assertNoMode('.composer__options')
|
||||
await escape()
|
||||
await click('.project-switcher__trigger')
|
||||
await click('.project-switcher__menu-settings')
|
||||
await wait('!!document.querySelector(".project-create-card")')
|
||||
await assertNoMode('.project-create-card')
|
||||
await escape()
|
||||
await wait('!document.querySelector(".project-create-card")')
|
||||
await click('button[aria-label="Settings"]')
|
||||
await wait('!!document.querySelector(".settings-tabs")')
|
||||
assert(await js('!document.querySelector("#settings-tab-security")'))
|
||||
await click('#settings-tab-platform-features')
|
||||
const danger = '.settings-section--danger'
|
||||
await wait(`!!document.querySelector('${danger} > .danger-button')`)
|
||||
assert.equal(await js(`(() => {
|
||||
let e=document.querySelector('#settings-tab-platform-features').previousElementSibling;
|
||||
while (e && !e.matches('.settings-tabs__group-label')) e=e.previousElementSibling;
|
||||
return e?.textContent.trim();
|
||||
})()`), 'General')
|
||||
assert(preserved())
|
||||
await click(`${danger} > .danger-button`)
|
||||
await wait(`!!document.querySelector('${danger} .danger-actions')`)
|
||||
assert(preserved(), 'First click must not delete data')
|
||||
await click(`${danger} .danger-actions .secondary-button`)
|
||||
await wait(`!document.querySelector('${danger} .danger-actions')`)
|
||||
assert(preserved(), 'Cancel must preserve data')
|
||||
await click(`${danger} > .danger-button`)
|
||||
await click(`${danger} .danger-actions .danger-button`)
|
||||
await wait(`!document.querySelector('${danger} .danger-actions')`)
|
||||
assert.equal(preserved(), false, 'Confirmed clear must reach production IPC and SQLite')
|
||||
await win.loadURL(process.env.GB_MCP_URL!)
|
||||
await wait('!!document.querySelector(".composer textarea")')
|
||||
assert.equal(preserved(), false)
|
||||
assert(await js('!document.body.innerText.includes("Keep until confirmed")'))
|
||||
const modelAttempts = (globalThis as typeof globalThis & { overlayModelAttempts?: number }).overlayModelAttempts ?? 0
|
||||
assert.equal(modelAttempts, 0)
|
||||
await writeFile(join(directory, 'result.json'), JSON.stringify({ composer: true, project: true,
|
||||
securityAbsent: true, generalClearData: true, cancelPreservedData: true, confirmedClear: true,
|
||||
persistedAfterReload: true, modelAttempts }))
|
||||
} finally { await dispose(); win.destroy(); database.close() }
|
||||
app.exit(0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -128,7 +128,7 @@ app.whenReady().then(async () => {
|
||||
await settle()
|
||||
const scroll = "document.querySelector('.workbar-shell__tab-scroll')"
|
||||
for (const many of [false, true]) {
|
||||
if (many) { await click('#many-tabs'); await wait('document.querySelectorAll("[role=tab]").length===16') }
|
||||
if (many) { await click('#many-tabs'); await wait('document.querySelectorAll("[role=tab]").length===15') }
|
||||
await addVisible()
|
||||
assert(await js(`(() => {
|
||||
const s=${scroll}.getBoundingClientRect(),buttons=[...document.querySelectorAll('.workbar-shell__scroll-button')];
|
||||
@@ -162,7 +162,7 @@ app.whenReady().then(async () => {
|
||||
await click('.workbar-shell__add')
|
||||
await wait('!!document.querySelector(".workbar-shell__catalog")')
|
||||
await click('.workbar-shell__catalog-choice:has(.workbar-shell__app-icon--browser)')
|
||||
await wait('document.querySelectorAll("[role=tab]").length===17 && !document.querySelector(".workbar-shell__catalog")')
|
||||
await wait('document.querySelectorAll("[role=tab]").length===16 && !document.querySelector(".workbar-shell__catalog")')
|
||||
await addVisible()
|
||||
await key('Left')
|
||||
await wait('document.querySelector(".workbar-shell__tab-item:nth-last-child(2) [role=tab]").getAttribute("aria-selected")==="true"')
|
||||
@@ -175,8 +175,8 @@ app.whenReady().then(async () => {
|
||||
await wait('!document.querySelector(".workbar-shell__scroll-button:last-child").disabled')
|
||||
const closingId = await js('document.querySelector(".workbar-shell__tab-item--active [role=tab]").id')
|
||||
await click('.workbar-shell__tab-item--active .workbar-shell__tab-close')
|
||||
await wait(`document.querySelectorAll('[role=tab]').length===16 && !document.getElementById(${JSON.stringify(closingId)})`)
|
||||
observations.push({ locale, theme, workbar: 'default and 16 long tabs; native add/catalog/create, arrows, Home/End, close at right viewport edge with right arrow enabled' })
|
||||
await wait(`document.querySelectorAll('[role=tab]').length===15 && !document.getElementById(${JSON.stringify(closingId)})`)
|
||||
observations.push({ locale, theme, workbar: 'default and 15 long tabs; native add/catalog/create, arrows, Home/End, close at right viewport edge with right arrow enabled' })
|
||||
|
||||
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
|
||||
await wait('!!document.querySelector(".model-connection-detail select")')
|
||||
@@ -189,7 +189,7 @@ app.whenReady().then(async () => {
|
||||
font: getComputedStyle(nav.querySelector('strong')).fontSize };
|
||||
})()`)
|
||||
assert.deepEqual(navigation.ids, ['appearance', 'platform-features', 'model', 'context-control', 'runtime',
|
||||
'document-parsing', 'channels', 'roles', 'capabilities', 'security', 'about'].map(id => `settings-tab-${id}`))
|
||||
'document-parsing', 'channels', 'roles', 'capabilities', 'about'].map(id => `settings-tab-${id}`))
|
||||
assert.equal(navigation.groups, 4)
|
||||
assert.equal(navigation.descriptions, 0)
|
||||
assert.equal(navigation.font, '13px')
|
||||
|
||||
@@ -41,6 +41,8 @@ app.whenReady().then(async () => {
|
||||
const click = async selector => {
|
||||
win.focus(); win.webContents.focus()
|
||||
await wait('document.hasFocus()')
|
||||
await js(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'nearest',inline:'nearest'})`)
|
||||
await settle()
|
||||
const p = await point(selector)
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: p.x, y: p.y })
|
||||
await settle()
|
||||
@@ -70,7 +72,7 @@ app.whenReady().then(async () => {
|
||||
assert(after.controls.every(c => c.hit && c.outside && c.height > 0), 'Docked controls must remain visible and hit-testable')
|
||||
assert.equal(after.bodyScroll, 0, 'Sidebar body must not scroll with files')
|
||||
assert(after.bodyOverflow <= 1, 'Files must not overflow the sidebar body')
|
||||
assert(after.horizontalOverflow.every(value => value <= 1), 'Unexpected horizontal overflow')
|
||||
assert(after.horizontalOverflow.slice(0, 3).every(value => value <= 1), 'Files must scroll inside their viewport without widening the sidebar')
|
||||
assert(after.viewport.bottom <= after.nav.bottom + 1, 'Files viewport exceeds navigation bounds')
|
||||
assert(after.viewport.y >= Math.max(...after.fixed.filter(Boolean).map(r => r.bottom)) - 1, 'Controls overlap files viewport')
|
||||
}
|
||||
@@ -138,8 +140,9 @@ app.whenReady().then(async () => {
|
||||
assert.equal(await js(`${viewport}.scrollTop`), after.scrollTop, 'Git view return must retain files scroll')
|
||||
docked(before, await geometry())
|
||||
}
|
||||
for (const index of [1, 2]) {
|
||||
await click(`.workspace-files__actions button:nth-child(${index})`)
|
||||
await js(`${viewport}.scrollLeft=0`)
|
||||
for (const label of ['New file', 'New directory']) {
|
||||
await click(`.workspace-files__actions button[aria-label="${label}"]`)
|
||||
await wait('!!document.querySelector("[role=dialog]")')
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
|
||||
@@ -15,7 +15,7 @@ const listDirectory = async (path: string): Promise<WorkspaceDirectoryListing> =
|
||||
...(!path ? [{ name: 'documents', path: 'documents', type: 'directory' as const }] : []),
|
||||
...Array.from({ length: 120 }, (_, index) => {
|
||||
const name = `file-${String(index + 1).padStart(3, '0')}-workspace-notes.md`
|
||||
return { name, path: path ? `${path}/${name}` : name, type: 'file' as const }
|
||||
return { name, path: path ? `${path}/${name}` : name, type: 'file' as const, modifiedAt: '2026-10-04T10:00:00.000Z', createdAt: '2026-10-01T10:00:00.000Z' }
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpcHandlers } from '../../src/main/ipc'
|
||||
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
||||
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
|
||||
import { defaultRuntimeSettings } from '../../src/shared/contracts'
|
||||
|
||||
const directory = process.env.GB_IMPORT_DIRECTORY!
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1400, height: 900, webPreferences: {
|
||||
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, preload: join(directory, 'preload.cjs')
|
||||
} })
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
const workspace = join(directory, 'workspace'); await mkdir(workspace)
|
||||
database.initialize(workspace)
|
||||
const project = database.listProjects()[0]!
|
||||
await mkdir(join(project.rootPath, 'selected'))
|
||||
const source = join(directory, 'source'); await mkdir(source)
|
||||
const bytes = Buffer.alloc(700001).map((_, index) => index % 256)
|
||||
await writeFile(join(source, 'import.bin'), bytes)
|
||||
const image = database.createImageArtifact({ projectId: project.id, title: 'Existing conversation image', mimeType: 'image/png', base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' })
|
||||
database.saveLocalConversations([{ header: { id: randomUUID(), projectId: project.id, title: 'Workspace import', updatedAt: Date.now() },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', state: 'complete', content: 'Existing conversation image', createdAt: Date.now(), artifactIds: [image.id] }] }])
|
||||
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
|
||||
await applicationSettings.update({ checkUpdatesOnStartup: false })
|
||||
const settings = { ...defaultRuntimeSettings, workspacePath: project.rootPath, modelProfiles: [], embeddingConnections: [] }
|
||||
const dispose = registerIpcHandlers(win, { capability: 'text', getStatus: async () => ({ available: true, name: 'Import test', capability: 'text' }) } as never,
|
||||
'CommandOrControl+Shift+Space', { getResolvedSettings: async () => settings, getPublicSettings: async () => settings } as never,
|
||||
{} as never, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }), database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
|
||||
database, {} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
|
||||
let picks = 0
|
||||
// Only the native chooser is deterministic; App, preload, IPC and filesystem are production.
|
||||
dialog.showOpenDialog = (async () => { picks++; return { canceled: picks === 3, filePaths: picks === 3 ? [] : [join(source, 'import.bin')] } }) as typeof dialog.showOpenDialog
|
||||
const run = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code, true)
|
||||
const wait = async (code: string): Promise<void> => {
|
||||
for (let i = 0; i < 300; i++) { if (await run(code)) return; await new Promise(resolve => setTimeout(resolve, 50)) }
|
||||
throw new Error(`Timed out: ${code}`)
|
||||
}
|
||||
const button = (label: string): string => `[...document.querySelectorAll('button')].find(e=>(e.getAttribute('aria-label')||e.textContent.trim())===${JSON.stringify(label)}&&e.getClientRects().length)`
|
||||
const click = async (label: string): Promise<void> => { await wait(`Boolean(${button(label)})`); await run(`${button(label)}.click()`) }
|
||||
try {
|
||||
await win.loadURL(process.env.GB_IMPORT_URL!)
|
||||
await wait('Boolean(document.querySelector(".message-generated-image img"))')
|
||||
await wait('document.querySelector(".message-generated-image img").naturalWidth===1')
|
||||
assert.equal(await run('[...document.querySelectorAll("[role=tab]")].some(e=>e.textContent==="Results")'), false)
|
||||
if (await run('document.querySelector(".assistant-sidebar-toggle").getAttribute("aria-expanded")==="false"')) await click('Toggle assistant workspace')
|
||||
await click('Files')
|
||||
await click('selected')
|
||||
await wait('document.body.innerText.includes("Destination: selected")')
|
||||
await click('Import files')
|
||||
await wait('document.body.innerText.includes("Imported 1 files into selected.")')
|
||||
await wait('Boolean([...document.querySelectorAll(".workspace-files__row")].find(e=>e.title==="selected/import.bin"))')
|
||||
assert.deepEqual(await readFile(join(project.rootPath, 'selected/import.bin')), bytes)
|
||||
assert.deepEqual(await readdir(project.rootPath), ['selected'])
|
||||
assert.equal(database.listArtifacts().length, 1)
|
||||
assert.equal(database.getArtifact(image.id).content, image.content)
|
||||
await click('Import files')
|
||||
await wait('document.body.innerText.includes("EEXIST")')
|
||||
assert.deepEqual(await readFile(join(project.rootPath, 'selected/import.bin')), bytes)
|
||||
await click('Import files')
|
||||
await wait(`!${button('Import files')}.disabled`)
|
||||
await win.reload()
|
||||
await wait('Boolean(document.querySelector(".message-generated-image img")) && document.querySelector(".message-generated-image img").naturalWidth===1')
|
||||
await writeFile(join(directory, 'result.json'), JSON.stringify({ passed: true, picks, bytes: bytes.length, imageAfterReload: true, artifactCount: database.listArtifacts().length, modelCalls: 0 }))
|
||||
await dispose(); database.close(); win.destroy(); app.exit(0)
|
||||
} catch (error) {
|
||||
await writeFile(join(directory, 'result.json'), JSON.stringify({ error: String(error) }))
|
||||
console.error(error); app.exit(1)
|
||||
}
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('imports from App through production preload and IPC without copying conversation images into the project', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-workspace-import-app-'))
|
||||
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
plugins: [react(), { name: 'workspace-import', configureServer(server) {
|
||||
server.middlewares.use('/import.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end(await server.transformIndexHtml('/import.html', '<html><body><div id="root"></div><script type="module" src="/tests/support/magic-notes-capture-renderer.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], server: { host: '127.0.0.1', port: 0, watch: null },
|
||||
optimizeDeps: { entries: ['tests/support/magic-notes-capture-renderer.tsx'], include: ['fabric', 'quill', 'quill/blots/block', 'quill-delta', 'jspdf', 'pdfjs-dist', 'react', 'react-dom/client', 'react-i18next'] }
|
||||
})
|
||||
try {
|
||||
const driver = join(directory, 'main.mjs')
|
||||
await build({ entryPoints: ['tests/support/workspace-import-main.ts'], outfile: driver, bundle: true, platform: 'node', format: 'esm', external: ['electron'], plugins: [
|
||||
{ name: 'isolated-external-services', setup(build) {
|
||||
build.onResolve({ filter: /channels\/channel-env$/ }, () => ({ path: 'channels', namespace: 'import-test' }))
|
||||
build.onResolve({ filter: /agent\/create-runtime$/ }, () => ({ path: 'runtime', namespace: 'import-test' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'import-test' }, ({ path }) => ({ contents: path === 'channels' ? 'export const startEnvironmentChannels=()=>[];' : 'export const createDefaultModelRuntime=()=>{throw new Error("Model calls forbidden")}; export const createModelProfileRuntime=createDefaultModelRuntime;' }))
|
||||
} },
|
||||
{ name: 'external-packages', setup(build) {
|
||||
build.onResolve({ filter: /^[^./]/ }, async args => {
|
||||
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
|
||||
const result = await build.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
|
||||
return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true }
|
||||
})
|
||||
} }
|
||||
] })
|
||||
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||
await server.listen()
|
||||
const bootstrap = join(directory, 'bootstrap.cjs')
|
||||
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(error=>{console.error(error);require('electron').app.exit(1)})`)
|
||||
const env = { ...process.env, GB_IMPORT_DIRECTORY: directory, GB_IMPORT_URL: server.resolvedUrls!.local[0] + 'import.html' }
|
||||
delete (env as NodeJS.ProcessEnv).ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data }); child.stderr.on('data', data => { output += data })
|
||||
const timeout = setTimeout(() => child.kill(), 90000)
|
||||
try { expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0) }
|
||||
finally { clearTimeout(timeout) }
|
||||
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
|
||||
expect(result).toMatchObject({ passed: true, picks: 3, bytes: 700001, imageAfterReload: true, artifactCount: 1, modelCalls: 0 })
|
||||
process.stdout.write(`Workspace import App evidence: ${JSON.stringify(result)}\n`)
|
||||
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
|
||||
}, 120000)
|
||||
Reference in New Issue
Block a user