diff --git a/BUILD.md b/BUILD.md index 151cec4a..eb725f66 100644 --- a/BUILD.md +++ b/BUILD.md @@ -43,6 +43,22 @@ npm run typecheck npm run lint ``` +Vitest runs two ordered projects from `vitest.config.ts`. The `unit` project runs +first with up to four workers, bounded by Node's available CPU parallelism. Once +it finishes, the `integration` project runs with one worker. The serial group +contains all test files under `tests/` (including `*.electron.test.ts`), +`src/main/agent/**/*runtime*.test.ts`, and +`src/agent-daemon/private-endpoint.test.ts`. Other tests remain in the unit group. +Both projects inherit the React plugin, jsdom environment, renderer setup, and +existing exclusions; test-file isolation remains enabled. + +This preserves serialization for the process/socket tests that previously timed +out on Linux with two workers (commit `93721d6`). `sequence.groupOrder` keeps the +two phases from overlapping. Existing file filters still work, for example +`npm test -- src/main/agent/opencode-runtime.test.ts`. To select a whole phase, +use `npm test -- --project unit` or `npm test -- --project integration`. To inspect +file selection without running tests, use `npx vitest list --filesOnly`. + 监听模式: ```bash diff --git a/src/main/agent/opencode-selection-isolation.test.ts b/src/main/agent/opencode-selection-isolation.test.ts new file mode 100644 index 00000000..9561a182 --- /dev/null +++ b/src/main/agent/opencode-selection-isolation.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment node +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import spawn from 'cross-spawn' +import { describe, expect, it } from 'vitest' +import { runtimeSettingsInputSchema } from '../../shared/contracts' +import { RuntimeSettingsStore } from '../runtime-settings-store' +import { applyRuntimeSelection } from './runtime-selection' +import { OpenCodeRuntime } from './opencode-runtime' + +const cipher = { + isAvailable: () => false, + encrypt: (): never => { throw new Error('No credentials in this test') }, + decrypt: (): never => { throw new Error('No credentials in this test') } +} +const newId = '00000000-0000-4000-8000-000000000123' +const denial = "OpenCode's free tier can only be used from within OpenCode" + +describe('OpenCode isolated configuration paths', () => { + it.each(['default', 'profile', 'platform', 'external'] as const)( + 'resolves persisted %s source independently of the global default', async (source) => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-source-matrix-')) + try { + const path = join(root, 'runtime-settings.json') + const store = new RuntimeSettingsStore(path, cipher, {}) + const fresh = await store.getPublicSettings() + await store.update(runtimeSettingsInputSchema.parse({ + ...Object.fromEntries(Object.entries(fresh).filter(([key]) => key in runtimeSettingsInputSchema.shape)), + embeddingConnections: undefined, + apiKey: { action: 'keep' }, + modelProfiles: [...fresh.modelProfiles, { + ...fresh.modelProfiles[0], id: newId, name: 'Local probe', + modelName: 'probe-model', baseUrl: 'http://127.0.0.1:12345/v1' + }].map((profile) => ({ + id: profile.id, name: profile.name, baseUrl: profile.baseUrl, + modelName: profile.modelName, protocol: profile.protocol, + authentication: profile.authentication, imageGenerationQuality: profile.imageGenerationQuality, + apiKey: { action: 'keep' } + })), + opencodeModelSource: source === 'profile' + ? { kind: 'profile', profileId: newId } + : { kind: source === 'external' ? 'default' : source }, + opencodeBaseUrl: source === 'external' ? 'http://127.0.0.1:12346' : '' + })) + const reloaded = new RuntimeSettingsStore(path, cipher, {}) + const selected = applyRuntimeSelection(await reloaded.getResolvedSettings(), { provider: 'opencode' }) + expect(selected.settings.opencodeEmbedded).toBe(source !== 'external') + expect(selected.settings.opencodeModelProfile?.modelName).toBe( + source === 'default' ? 'qwen3' : source === 'profile' ? 'probe-model' : undefined + ) + expect((await reloaded.getPublicSettings()).opencodeModelSource.kind).toBe( + source === 'external' ? 'platform' : source + ) + if (source === 'platform') { + expect(applyRuntimeSelection(await reloaded.getResolvedSettings(), { + provider: 'opencode', profileId: newId + }).settings.opencodeModelProfile?.id).toBe(newId) + } + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) + + it.each(['profile', 'native-file', 'native-empty'] as const)( + 'runs bundled OpenCode with isolated %s configuration and loopback-only model calls', async (source) => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-isolation-')) + const requests: Array<{ path: string; model: string }> = [] + const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) body += String(chunk) + requests.push({ path: request.url ?? '', model: JSON.parse(body).model }) + response.writeHead(403, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: denial, type: 'forbidden' } })) + }) + await new Promise((done) => server.listen(0, '127.0.0.1', done)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('No loopback address') + const baseURL = `http://127.0.0.1:${address.port}/v1` + const configPath = join(root, 'native.json') + await writeFile(configPath, JSON.stringify({ + model: 'local-native/probe-model', + provider: { 'local-native': { + npm: '@ai-sdk/openai-compatible', + options: { baseURL }, models: { 'probe-model': {} } + } } + })) + let launchEnvironment: NodeJS.ProcessEnv = {} + let serverUrl = '' + const binaryPath = join(process.cwd(), 'node_modules', 'opencode-ai', 'bin', + process.platform === 'win32' ? 'opencode.exe' : 'opencode') + const runtime = new OpenCodeRuntime({ + embedded: true, binaryPath, defaultWorkspace: root, + configPath: source === 'native-empty' ? '' : configPath, + sharedCacheRoot: join(root, 'runtime-cache'), + bundledConfigPath: join(process.cwd(), '.runtime-resources', 'opencode-config'), + ...(source === 'profile' ? { modelProfile: { + id: newId, name: 'Local mock', baseUrl: baseURL, modelName: 'probe-model', + protocol: 'openai-chat-completions' as const, authentication: 'none' as const + } } : {}) + }, { + startupTimeoutMs: 20_000, + validateBinary: async () => ({ path: binaryPath, detail: 'Isolated bundled binary' }), + spawn: ((command, args, options) => { + // Allow only OS plumbing and the configuration generated by this runtime. + const env: NodeJS.ProcessEnv = {} + for (const [key, value] of Object.entries(options?.env ?? {})) { + if (/^(PATH|SYSTEMROOT|WINDIR|COMSPEC|PATHEXT)$/i.test(key) || + /^OPENCODE_(CONFIG|CONFIG_DIR|CONFIG_CONTENT|SERVER_USERNAME|SERVER_PASSWORD|DISABLE_[A-Z_]+|EXPERIMENTAL_DISABLE_FILEWATCHER)$/.test(key) || + key.startsWith('XDG_')) env[key] = value + } + Object.assign(env, { HOME: root, USERPROFILE: root, APPDATA: join(root, 'appdata'), + LOCALAPPDATA: join(root, 'localappdata'), TEMP: root, TMP: root, + OPENCODE_DISABLE_DEFAULT_PLUGINS: '1' }) + launchEnvironment = env + serverUrl = `http://127.0.0.1:${args!.find((arg) => arg.startsWith('--port='))!.split('=')[1]}` + return spawn(command, args!, { ...options, env }) + }) as typeof spawn + }) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 25_000) + try { + expect(await runtime.testConnection()).toMatchObject({ available: true }) + const config = JSON.parse(launchEnvironment.OPENCODE_CONFIG_CONTENT!) + expect(config.model).toBe(source === 'profile' ? 'goodbuddy-openai-chat/probe-model' : undefined) + expect(launchEnvironment.OPENCODE_CONFIG).toBe(source === 'native-file' ? configPath : undefined) + expect(launchEnvironment.XDG_CONFIG_HOME).toBe(join(root, 'runtime-cache', 'config')) + if (source === 'native-empty') { + // Inspect the bundled catalog without issuing any inference request. + const requestOptions = { + headers: { authorization: `Basic ${Buffer.from( + `${launchEnvironment.OPENCODE_SERVER_USERNAME}:${launchEnvironment.OPENCODE_SERVER_PASSWORD}` + ).toString('base64')}` }, signal: controller.signal + } + const health = await (await fetch(`${serverUrl}/global/health`, requestOptions)).json() as { version: string } + const installed = JSON.parse(await readFile(join(process.cwd(), 'node_modules', 'opencode-ai', 'package.json'), 'utf8')) + expect(health.version).toBe(installed.version) + const response = await fetch(`${serverUrl}/config/providers`, requestOptions) + expect(response.status).toBe(200) + const catalog = await response.json() as { + providers: Array<{ id: string; models: Record }>; + default: Record + } + const native = catalog.providers.find((provider) => provider.id === 'opencode') + expect(native).toBeDefined() + expect(Object.values(native!.models).every((model) => model.cost.input === 0)).toBe(true) + expect(native!.models[catalog.default.opencode!]).toBeDefined() + process.stdout.write('Isolated native catalog: ' + JSON.stringify({ + version: health.version, + providers: catalog.providers.map((provider) => provider.id), + default: catalog.default, freeModels: Object.keys(native!.models) + }) + '\n') + expect(requests).toEqual([]) + } else { + let failure = '' + try { + for await (const event of runtime.run({ requestId: '403-isolation', + conversationId: '403-isolation', prompt: 'Reply OK', workMode: 'ask' + }, controller.signal)) void event + } catch (error) { + failure = error instanceof Error ? error.message : String(error) + } + expect(requests.length).toBeGreaterThan(0) + expect(requests.every((request) => request.path === '/v1/chat/completions' && + request.model === 'probe-model')).toBe(true) + expect(failure).toContain(denial) + expect(failure).toContain('statusCode: 403') + process.stdout.write('Isolated 403 result: ' + JSON.stringify({ source, requests: requests.length, failure }) + '\n') + } + expect(JSON.parse(await readFile(configPath, 'utf8')).model).toBe('local-native/probe-model') + } finally { + clearTimeout(timeout) + controller.abort() + await runtime.dispose() + server.closeAllConnections() + await new Promise((done) => server.close(() => done())) + await rm(root, { recursive: true, force: true }) + } + }, 40_000 + ) +}) diff --git a/src/main/assistant/activity-history-io.test.ts b/src/main/assistant/activity-history-io.test.ts new file mode 100644 index 00000000..ae7d6ac4 --- /dev/null +++ b/src/main/assistant/activity-history-io.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node +import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { DatabaseSync } from 'node:sqlite' +import { expect, it } from 'vitest' +import { AssistantDatabase } from './assistant-database' +import { upgradeAssistantStorage } from './assistant-storage-upgrade' + +it('migrates activity history and writes only changed records during streaming', () => { + const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-save-io-')) + const path = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(path) + try { + database.initialize(directory) + const raw = (database as unknown as { database: DatabaseSync }).database + const records = Array.from({ length: 10_000 }, (_, index) => ({ + id: String(index), conversationId: randomUUID(), requestId: randomUUID(), + scope: { kind: 'global' as const }, kind: 'tool' as const, + title: 'Tool', detail: 'h'.repeat(280), + status: 'running' as const, createdAt: Date.now() + })) + // Build a released schema-43 snapshot, including an allowed duplicate ID. + records[1]!.id = records[0]!.id + raw.exec(`DROP TABLE activity_history_records; + ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; + PRAGMA user_version=43`) + const legacySave = raw.prepare('UPDATE activity_history SET records_json = ?, legacy_history_may_be_incomplete = 1') + legacySave.run(JSON.stringify(records)) + raw.exec('PRAGMA wal_checkpoint(TRUNCATE); PRAGMA wal_autocheckpoint=0') + for (let index = 0; index < 10; index++) { + records[0]!.detail += 'x' + legacySave.run(JSON.stringify(records)) + } + const beforeBytes = statSync(`${path}-wal`).size + database.close() + upgradeAssistantStorage(path, () => undefined) + database.initialize(directory) + expect(database.getActivityHistory()).toEqual({ records, legacyHistoryMayBeIncomplete: true }) + const migrated = (database as unknown as { database: DatabaseSync }).database + migrated.exec('PRAGMA wal_checkpoint(TRUNCATE); PRAGMA wal_autocheckpoint=0') + for (let index = 0; index < 10; index++) { + records[0]!.detail += 'x' + database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: true }) + } + const afterBytes = statSync(`${path}-wal`).size + console.log({ beforeBytes, afterBytes }) + expect(afterBytes).toBeLessThan(beforeBytes / 100) + database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: true }) + expect(statSync(`${path}-wal`).size).toBe(afterBytes) + records.unshift(records.pop()!) + records.splice(2, 1) + database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false }) + database.close() + database.initialize(directory) + expect(database.getActivityHistory()).toEqual({ records, legacyHistoryMayBeIncomplete: false }) + } finally { + database.close() + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index a9f36884..c628783a 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -118,7 +118,7 @@ import { SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION } from './subagent-progress-storage' -export const ASSISTANT_DATABASE_SCHEMA_VERSION = 43 +export const ASSISTANT_DATABASE_SCHEMA_VERSION = 44 export type RemoteTaskEventInput = { taskId: string @@ -1838,7 +1838,7 @@ export class AssistantDatabase { const database = new DatabaseSync(this.databasePath, { timeout: 5_000 }) try { database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;') - this.migrate(database) + this.migrate(database, onProgress, isCancelled) this.database = database this.repairMagicNoteStorage(onProgress, isCancelled) if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError') @@ -2254,13 +2254,14 @@ export class AssistantDatabase { 'channel_events', 'tasks', 'messages', - 'conversations' + 'conversations', + 'activity_history_records' ]) { database.exec(`DELETE FROM ${table}`) } database.exec( `UPDATE activity_history - SET records_json = '[]', legacy_history_may_be_incomplete = 0 + SET record_order_json = '[]', legacy_history_may_be_incomplete = 0 WHERE singleton = 1` ) database.exec('COMMIT') @@ -4838,20 +4839,27 @@ export class AssistantDatabase { } getActivityHistory(): ActivityHistorySnapshot { - const row = this.requireDatabase() + const database = this.requireDatabase() + const row = database .prepare( - `SELECT records_json, legacy_history_may_be_incomplete + `SELECT record_order_json, legacy_history_may_be_incomplete FROM activity_history WHERE singleton = 1` ) .get() as | { - records_json: string + record_order_json: string legacy_history_may_be_incomplete: number } | undefined + const records = database.prepare( + `SELECT records.record_json + FROM json_each(?) AS ordering + JOIN activity_history_records AS records ON records.record_key = ordering.value + ORDER BY CAST(ordering.key AS INTEGER)` + ).all(row?.record_order_json ?? '[]') as Array<{ record_json: string }> return activityHistorySnapshotSchema.parse({ - records: row ? JSON.parse(row.records_json) : [], + records: records.map((record) => JSON.parse(record.record_json)), legacyHistoryMayBeIncomplete: row?.legacy_history_may_be_incomplete === 1 }) @@ -4859,16 +4867,53 @@ export class AssistantDatabase { replaceActivityHistory(input: ActivityHistorySnapshot): void { const snapshot = activityHistorySnapshotSchema.parse(input) - this.requireDatabase() - .prepare( - `UPDATE activity_history - SET records_json = ?, legacy_history_may_be_incomplete = ? - WHERE singleton = 1` - ) - .run( - JSON.stringify(snapshot.records), - Number(snapshot.legacyHistoryMayBeIncomplete) - ) + const database = this.requireDatabase() + database.exec('BEGIN IMMEDIATE') + try { + this.writeActivityHistory(database, snapshot) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } + + private writeActivityHistory( + database: DatabaseSync, + snapshot: ActivityHistorySnapshot, + onProgress?: (processed: number) => void + ): void { + const rows = database.prepare( + 'SELECT record_key, record_json FROM activity_history_records' + ).all() as Array<{ record_key: string; record_json: string }> + const previous = new Map(rows.map((row) => [row.record_key, row.record_json])) + const occurrences = new Map() + const order: string[] = [] + const upsert = database.prepare( + `INSERT INTO activity_history_records (record_key, record_json) VALUES (?, ?) + ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json` + ) + for (const record of snapshot.records) { + if (order.length % 128 === 0) onProgress?.(order.length) + // IDs were never unique in the snapshot schema; retain every occurrence. + const occurrence = occurrences.get(record.id) ?? 0 + occurrences.set(record.id, occurrence + 1) + const key = JSON.stringify([record.id, occurrence]) + const json = JSON.stringify(record) + order.push(key) + if (previous.get(key) !== json) upsert.run(key, json) + previous.delete(key) + } + const remove = database.prepare('DELETE FROM activity_history_records WHERE record_key = ?') + for (const key of previous.keys()) remove.run(key) + const orderJson = JSON.stringify(order) + const incomplete = Number(snapshot.legacyHistoryMayBeIncomplete) + database.prepare( + `UPDATE activity_history + SET record_order_json = ?, legacy_history_may_be_incomplete = ? + WHERE singleton = 1 + AND (record_order_json != ? OR legacy_history_may_be_incomplete != ?)` + ).run(orderJson, incomplete, orderJson, incomplete) } createTask(input: { @@ -9131,7 +9176,11 @@ export class AssistantDatabase { } } - private migrate(database: DatabaseSync): void { + private migrate( + database: DatabaseSync, + onProgress?: (progress: AssistantStorageProgress) => void, + isCancelled?: () => boolean + ): void { const version = database .prepare('PRAGMA user_version') .get() as { user_version: number } @@ -10992,6 +11041,42 @@ export class AssistantDatabase { throw error } } + if (version.user_version < 44) { + database.exec('BEGIN IMMEDIATE') + try { + const bytesBefore = onProgress && this.databasePath !== ':memory:' + ? statSync(this.databasePath).size + : 0 + onProgress?.({ stage: 'scanning', processed: 0, total: 0, bytesBefore }) + if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError') + const row = database.prepare( + 'SELECT records_json, legacy_history_may_be_incomplete FROM activity_history WHERE singleton = 1' + ).get() as { records_json: string; legacy_history_may_be_incomplete: number } + onProgress?.({ stage: 'converting', processed: 0, total: 0, bytesBefore }) + if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError') + const snapshot = activityHistorySnapshotSchema.parse({ + records: JSON.parse(row.records_json), + legacyHistoryMayBeIncomplete: row.legacy_history_may_be_incomplete === 1 + }) + database.exec(` + CREATE TABLE activity_history_records ( + record_key TEXT PRIMARY KEY NOT NULL, + record_json TEXT NOT NULL + ); + ALTER TABLE activity_history RENAME COLUMN records_json TO record_order_json; + `) + this.writeActivityHistory(database, snapshot, onProgress || isCancelled ? (processed) => { + onProgress?.({ stage: 'converting', processed, total: snapshot.records.length, bytesBefore }) + if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError') + } : undefined) + onProgress?.({ stage: 'converting', processed: snapshot.records.length, total: snapshot.records.length, bytesBefore }) + if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError') + database.exec('PRAGMA user_version = 44; COMMIT;') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } } private requireDatabase(): DatabaseSync { diff --git a/src/main/assistant/assistant-storage-upgrade.test.ts b/src/main/assistant/assistant-storage-upgrade.test.ts index 4f6bd702..16ebb2b5 100644 --- a/src/main/assistant/assistant-storage-upgrade.test.ts +++ b/src/main/assistant/assistant-storage-upgrade.test.ts @@ -96,7 +96,10 @@ describe('subagent progress storage', () => { try { legacy.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?') .run(JSON.stringify(note.entries[0]!.content), note.entries[0]!.id) - legacy.exec('PRAGMA user_version = 37') + legacy.exec(`ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; + DROP TABLE activity_history_records; + ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; + PRAGMA user_version = 37`) } finally { legacy.close() } expect(hasPendingAssistantStorageUpgrade(path)).toBe(true) upgradeAssistantStorage(path, () => undefined) @@ -408,6 +411,9 @@ describe('subagent progress storage', () => { const expected: SubagentEvent[] = [] try { legacy.exec(`ALTER TABLE conversations DROP COLUMN pinned; + ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; + DROP TABLE activity_history_records; + ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; PRAGMA user_version = ${sourceVersion}; BEGIN`) const insert = legacy.prepare( `INSERT INTO task_events( diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index ef6a9f43..8db645e3 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -4403,6 +4403,57 @@ describe('SettingsPanel runtime files', () => { expect(headers).toHaveValue('{"Authorization":"replacement"}') }) + it.each([false, true])('resolves a fresh persisted store after adding a text connection (default: %s)', async (makeDefault) => { + const { mkdtemp, rm } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + // Load main-process code only in this integration test, outside the web TS project. + const storeModule = '../../main/runtime-settings-store' + const selectionModule = '../../main/agent/runtime-selection' + const { RuntimeSettingsStore } = await import(storeModule) + const { applyRuntimeSelection } = await import(selectionModule) + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-fresh-selection-')) + const cipher = { + isAvailable: () => false, + encrypt: () => { throw new Error('No credentials in this test') }, + decrypt: () => { throw new Error('No credentials in this test') } + } + const path = join(root, 'runtime-settings.json') + const store = new RuntimeSettingsStore(path, cipher, {}) + try { + const fresh = await store.getPublicSettings() + expect(fresh.opencodeModelSource).toEqual({ kind: 'default' }) + expect(fresh.modelName).toBe('qwen3') + getRuntime.mockResolvedValueOnce(fresh) + updateRuntime.mockImplementationOnce((input) => store.update(input)) + render( {})} onClose={vi.fn()} onSaved={vi.fn()} />) + fireEvent.click(screen.getByRole('tab', { name: '模型连接' })) + await screen.findByDisplayValue('qwen3') + fireEvent.click(screen.getByRole('button', { name: '添加自定义' })) + fireEvent.change(screen.getByLabelText('模型接口 URL'), { + target: { value: 'http://127.0.0.1:12345/v1' } + }) + fireEvent.change(screen.getByLabelText('模型'), { target: { value: 'isolated-new-model' } }) + if (makeDefault) fireEvent.click(screen.getByRole('radio', { name: '默认连接' })) + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + await waitFor(() => expect(updateRuntime).toHaveBeenCalled()) + await updateRuntime.mock.results[0]!.value + const reloaded = new RuntimeSettingsStore(path, cipher, {}) + const selected = applyRuntimeSelection(await reloaded.getResolvedSettings(), { provider: 'opencode' }) + expect(selected.settings.opencodeEmbedded).toBe(true) + expect(selected.settings.opencodeModelProfile).toMatchObject({ + modelName: makeDefault ? 'isolated-new-model' : 'qwen3', + baseUrl: makeDefault ? 'http://127.0.0.1:12345/v1' : 'http://127.0.0.1:11434/v1', + authentication: 'none' + }) + expect((await reloaded.getPublicSettings()).opencodeModelSource).toEqual({ kind: 'default' }) + } finally { + cleanup() + await rm(root, { recursive: true, force: true }) + } + }) + it('keeps saved Runtime sources valid when defaulting a new text profile', async () => { render( `tests/${pattern}`), + 'src/main/agent/**/*runtime*.test.ts', + 'src/agent-daemon/private-endpoint.test.ts' +] export default defineConfig({ plugins: [react()], @@ -14,7 +21,27 @@ export default defineConfig({ '**/build-server/**', '**/.git/**' ], - maxWorkers: 1, + maxWorkers: Math.min(4, availableParallelism()), + projects: [ + { + extends: true, + test: { + name: 'unit', + exclude: serialTests, + sequence: { groupOrder: 0 } + } + }, + { + extends: true, + test: { + name: 'integration', + include: serialTests, + maxWorkers: 1, + // Keep process/socket tests separate from the parallel unit phase. + sequence: { groupOrder: 1 } + } + } + ], coverage: { reporter: ['text', 'html'] }