@aiworker/web 1.23.0 → 1.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-COGJG0Iv.js} +1 -1
- package/dist/assets/DiffView-BviYY3zL.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-C26DvfVJ.js} +1 -1
- package/dist/assets/FilesView-D0ZU_nCt.js +13 -0
- package/dist/assets/{GitView-BXsS3jG6.js → GitView-CYgiDRH-.js} +4 -4
- package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-GsV9UScz.js} +1 -1
- package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-shqUS8nl.js} +1 -1
- package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-CQSTFp6m.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DJWP3A8X.js} +2 -2
- package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-CTuu4lgo.js} +1 -1
- package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-Dp53v2pl.js} +1 -1
- package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-B5l0djPS.js} +1 -1
- package/dist/assets/PlanView-CLUWkGiG.js +5 -0
- package/dist/assets/SettingsView-CojPQVHx.js +28 -0
- package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-DbgEnZ6T.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-Du-RZj-a.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-HBLSokoe.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-Bzp3hClU.js} +4 -4
- package/dist/assets/WalkthroughView-CpgA4s9w.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-CI1PC_z2.js} +2 -2
- package/dist/assets/de-BX8b4hDD.js +10 -0
- package/dist/assets/es-e98cS-yM.js +16 -0
- package/dist/assets/fr-BOiqxJAs.js +14 -0
- package/dist/assets/index-BD8ILbxF.js +40 -0
- package/dist/assets/{index-jXSK5RrB.js → index-Cmn74sw-.js} +1 -1
- package/dist/assets/index-zhgg-SDz.css +1 -0
- package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-DVralkU-.js} +1 -1
- package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-CgTREsRX.js} +1 -1
- package/dist/assets/ja-aoO55hGu.js +16 -0
- package/dist/assets/{ko-idhXGF8j.js → ko-BGzFLogk.js} +3 -3
- package/dist/assets/{main-CzdPuL0s.js → main-BgfXQ-4g.js} +2 -2
- package/dist/assets/main-DGYUWmIt.js +930 -0
- package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-BesaNs_G.js} +3 -3
- package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-DHKXp-_I.js} +2 -2
- package/dist/assets/{mobile-mvxTo055.js → mobile-lXQ08VnX.js} +2 -2
- package/dist/assets/patchFileDiff-i1lTtNbn.js +13 -0
- package/dist/assets/{pl-CGZWUqZJ.js → pl-B5x19laq.js} +3 -3
- package/dist/assets/pt-BR-C3oU51Il.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-CuO4ue6B.js +2 -0
- package/dist/assets/renderMobileApp-B3zFlHy4.js +25 -0
- package/dist/assets/runtimeEndpointReset-BLzrtrmj.js +41 -0
- package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
- package/dist/assets/{shikiHighlight-CRl0SUC9.js → shikiHighlight-ctlcPvvx.js} +1 -1
- package/dist/assets/surface-COKb77Ua.js +1 -0
- package/dist/assets/tr-C0QQa7et.js +7 -0
- package/dist/assets/uk-CqLSe9wT.js +16 -0
- package/dist/assets/{useAppFontEffects-CPKeOpUo.js → useAppFontEffects-B6OfEypo.js} +153 -155
- package/dist/assets/useGitComparison-BXZXEAW6.js +1 -0
- package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-CgbrpEWu.js} +2 -2
- package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-Me41OjBT.js} +1 -1
- package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-SJuFp0a0.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
- package/dist/assets/{web-components-z7hfnS8n.js → web-components-D8GS8Ork.js} +1 -1
- package/dist/assets/zh-CN-iJEie4d6.js +16 -0
- package/dist/assets/zh-TW-CyslEwlK.js +16 -0
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +1 -1
- package/server/index.js +3 -13
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/event-stream/DOCUMENTATION.md +3 -0
- package/server/lib/event-stream/global-hub.js +29 -4
- package/server/lib/event-stream/global-hub.test.js +22 -0
- package/server/lib/event-stream/global-ws-bridge.js +15 -18
- package/server/lib/event-stream/protocol.js +21 -7
- package/server/lib/event-stream/runtime.js +9 -6
- package/server/lib/event-stream/runtime.test.js +60 -0
- package/server/lib/git/DOCUMENTATION.md +10 -1
- package/server/lib/git/routes.js +21 -8
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/service.js +159 -4
- package/server/lib/git/service.test.js +74 -0
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/routes.js +2 -2
- package/server/lib/message-queue/DOCUMENTATION.md +7 -0
- package/server/lib/message-queue/runtime.js +13 -0
- package/server/lib/message-queue/runtime.test.js +31 -1
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/opencode/DOCUMENTATION.md +18 -4
- package/server/lib/opencode/env-runtime.js +5 -1
- package/server/lib/opencode/env-runtime.test.js +72 -0
- package/server/lib/opencode/feature-routes-runtime.js +15 -1
- package/server/lib/opencode/lifecycle.js +4 -3
- package/server/lib/opencode/lifecycle.test.js +29 -0
- package/server/lib/opencode/managed-plugin-config.js +1 -1
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/settings-helpers.js +3 -0
- package/server/lib/opencode/settings-helpers.test.js +11 -1
- package/server/lib/opencode/settings-registry.json +3 -0
- package/server/lib/opencode/settings-runtime.js +36 -9
- package/server/lib/opencode/settings-runtime.test.js +158 -2
- package/server/lib/project-context/DOCUMENTATION.md +13 -3
- package/server/lib/project-context/runtime.js +8 -2
- package/server/lib/project-context/runtime.test.js +54 -0
- package/server/lib/projects/DOCUMENTATION.md +2 -1
- package/server/lib/projects/project-config.js +38 -9
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +26 -0
- package/server/lib/relay/DOCUMENTATION.md +20 -0
- package/server/lib/relay/host-client.js +7 -0
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
- package/server/lib/scheduled-tasks/runtime.js +12 -2
- package/server/lib/scheduled-tasks/runtime.test.js +53 -0
- package/server/lib/session-assist/DOCUMENTATION.md +131 -66
- package/server/lib/session-assist/context.js +127 -0
- package/server/lib/session-assist/context.test.js +132 -0
- package/server/lib/session-assist/prompt.js +54 -0
- package/server/lib/session-assist/runtime.js +111 -257
- package/server/lib/session-assist/runtime.test.js +203 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/pull-request.js +7 -4
- package/server/lib/walkthrough/pull-request.test.js +16 -0
- package/server/lib/walkthrough/routes.js +19 -0
- package/server/lib/walkthrough/routes.test.js +26 -0
- package/server/lib/walkthrough/sources.js +13 -2
- package/server/opencode-worktree-readiness.test.js +130 -0
- package/dist/assets/DiffView-DXjVsEOw.js +0 -4
- package/dist/assets/FilesView-CJdG0-wk.js +0 -17
- package/dist/assets/PlanView-CJTmOF6I.js +0 -5
- package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
- package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
- package/dist/assets/de-DCrsQ5zS.js +0 -10
- package/dist/assets/es-Dwv4YfH6.js +0 -16
- package/dist/assets/fr-BCWg4Cnx.js +0 -14
- package/dist/assets/index-B68h5S5B.css +0 -1
- package/dist/assets/index-DVwMarHI.js +0 -39
- package/dist/assets/ja-Bruw3RYC.js +0 -16
- package/dist/assets/main-BkyaQhJX.js +0 -930
- package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
- package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
- package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
- package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
- package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
- package/dist/assets/surface-oMvhsog3.js +0 -1
- package/dist/assets/tr-BK_KiO2V.js +0 -7
- package/dist/assets/uk-CG74xV8N.js +0 -16
- package/dist/assets/useGitComparison-DH220WmO.js +0 -1
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/zh-CN-T4AXofZI.js +0 -16
- package/dist/assets/zh-TW-Dte40sxp.js +0 -16
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
- package/server/lib/mcp-reconnect/runtime.js +0 -122
- package/server/lib/mcp-reconnect/runtime.test.js +0 -140
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createSessionAssistRuntime } from './runtime.js';
|
|
4
|
+
|
|
5
|
+
const resources = [];
|
|
6
|
+
const message = (id, role, text, extra = {}) => ({
|
|
7
|
+
info: { id, role, parentID: 'user', finish: 'stop', time: { completed: 1 }, providerID: 'test-provider', modelID: 'test-model', ...extra },
|
|
8
|
+
parts: [{ type: 'text', text }],
|
|
9
|
+
});
|
|
10
|
+
const output = (recap = 'Зміни готові', suggestion = '') => ({ text: JSON.stringify({ recap, suggestion }), providerID: 'test-provider', modelID: 'test-model' });
|
|
11
|
+
const pause = () => new Promise((resolve) => setTimeout(resolve, 15));
|
|
12
|
+
|
|
13
|
+
async function fixture(generate = async () => output()) {
|
|
14
|
+
const state = {
|
|
15
|
+
messages: [message('user', 'user', 'Виправ помилку'), message('answer', 'assistant', 'Виправлено')],
|
|
16
|
+
session: { id: 'session', directory: '/project', time: {}, metadata: { external: 'keep', aiworker: { note: 'keep' } } },
|
|
17
|
+
targets: { recap: true, suggestion: true },
|
|
18
|
+
gets: 0, failFresh: false, patches: [], requests: [], calls: [],
|
|
19
|
+
};
|
|
20
|
+
const server = createServer(async (request, response) => {
|
|
21
|
+
const url = new URL(request.url, 'http://localhost');
|
|
22
|
+
state.requests.push({ method: request.method, directory: url.searchParams.get('directory'), limit: url.searchParams.get('limit'), auth: request.headers['x-test-auth'] });
|
|
23
|
+
response.setHeader('content-type', 'application/json');
|
|
24
|
+
if (request.method === 'PATCH') {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
27
|
+
state.patches.push(JSON.parse(Buffer.concat(chunks).toString()));
|
|
28
|
+
response.end(JSON.stringify(state.session));
|
|
29
|
+
} else if (url.pathname.endsWith('/message')) {
|
|
30
|
+
response.end(JSON.stringify(url.searchParams.get('limit') === '1' ? state.messages.slice(-1) : state.messages));
|
|
31
|
+
} else {
|
|
32
|
+
state.gets++;
|
|
33
|
+
response.statusCode = state.failFresh && state.gets > 1 ? 500 : 200;
|
|
34
|
+
response.end(JSON.stringify(state.session));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
38
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
39
|
+
state.base = base;
|
|
40
|
+
const runtime = createSessionAssistRuntime({
|
|
41
|
+
buildOpenCodeUrl: (route) => state.base + route,
|
|
42
|
+
getOpenCodeAuthHeaders: () => ({ 'x-test-auth': 'fixture' }),
|
|
43
|
+
getTargets: () => state.targets,
|
|
44
|
+
quietMs: 1,
|
|
45
|
+
getSmallModelService: async () => ({
|
|
46
|
+
describeSmallModel: async () => ({ inputCharBudget: 64_000 }),
|
|
47
|
+
generateSmallModelText: async (args) => { state.calls.push(args); return generate(args, state); },
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
resources.push({ runtime, server });
|
|
51
|
+
const status = (type) => runtime.processPayload({ type: 'session.status', properties: { sessionID: 'session', status: { type } } }, '/project');
|
|
52
|
+
return { state, runtime, status };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
for (const { runtime, server } of resources.splice(0)) {
|
|
57
|
+
runtime.stop();
|
|
58
|
+
server.closeAllConnections();
|
|
59
|
+
await new Promise((resolve) => server.close(resolve));
|
|
60
|
+
}
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('session assist runtime', () => {
|
|
65
|
+
it('uses bounded authenticated SDK reads and preserves metadata with an empty suggestion', async () => {
|
|
66
|
+
const { state, status } = await fixture(async (_args, current) => {
|
|
67
|
+
current.session.metadata.aiworker.concurrent = 'new';
|
|
68
|
+
return output();
|
|
69
|
+
});
|
|
70
|
+
status('idle');
|
|
71
|
+
await vi.waitFor(() => expect(state.patches).toHaveLength(1));
|
|
72
|
+
expect(state.requests.every((r) => r.directory === '/project' && r.auth === 'fixture')).toBe(true);
|
|
73
|
+
expect(state.requests.filter((r) => r.limit).map((r) => r.limit)).toEqual(['50', '1']);
|
|
74
|
+
expect(state.calls[0]).toMatchObject({ restrictToPreferredProvider: true, onOverflow: 'error', preferredProviderID: 'test-provider', preferredModelID: 'test-model', sessionID: 'session' });
|
|
75
|
+
expect(state.patches[0].metadata).toMatchObject({ external: 'keep', aiworker: {
|
|
76
|
+
note: 'keep', concurrent: 'new', assist: { recap: 'Зміни готові', suggestion: '', forMessageID: 'answer' },
|
|
77
|
+
} });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does no work with both settings off and skips child, archived, or reverted sessions', async () => {
|
|
81
|
+
const { state, status } = await fixture();
|
|
82
|
+
state.targets = { recap: false, suggestion: false };
|
|
83
|
+
status('idle');
|
|
84
|
+
await pause();
|
|
85
|
+
expect(state.requests).toHaveLength(0);
|
|
86
|
+
state.targets.recap = true;
|
|
87
|
+
state.session.parentID = 'parent';
|
|
88
|
+
status('idle');
|
|
89
|
+
await vi.waitFor(() => expect(state.gets).toBe(1));
|
|
90
|
+
delete state.session.parentID;
|
|
91
|
+
state.session.revert = { messageID: 'user' };
|
|
92
|
+
status('idle');
|
|
93
|
+
await vi.waitFor(() => expect(state.gets).toBe(2));
|
|
94
|
+
delete state.session.revert;
|
|
95
|
+
state.session.time.archived = 1;
|
|
96
|
+
status('idle');
|
|
97
|
+
await vi.waitFor(() => expect(state.gets).toBe(3));
|
|
98
|
+
expect(state.calls).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keeps recent context for recap-only and performs no write for an empty suggestion-only result', async () => {
|
|
102
|
+
const { state, status } = await fixture();
|
|
103
|
+
state.targets.suggestion = false;
|
|
104
|
+
state.messages.unshift(message('previous-user', 'user', 'Попередня задача'), message('previous-answer', 'assistant', 'Зміст зробленого', { parentID: 'previous-user' }));
|
|
105
|
+
status('idle');
|
|
106
|
+
await vi.waitFor(() => expect(state.patches).toHaveLength(1));
|
|
107
|
+
expect(state.calls[0].prompt).toContain('Зміст зробленого');
|
|
108
|
+
expect(state.calls[0].system).not.toContain('suggestion');
|
|
109
|
+
state.targets = { recap: false, suggestion: true };
|
|
110
|
+
status('idle');
|
|
111
|
+
await vi.waitFor(() => expect(state.calls).toHaveLength(2));
|
|
112
|
+
await pause();
|
|
113
|
+
expect(state.patches).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('does not write a stale result when the tail moves during generation', async () => {
|
|
117
|
+
const { state, status } = await fixture(async (_args, current) => {
|
|
118
|
+
current.messages.push(message('new-user', 'user', 'Нова задача'));
|
|
119
|
+
return output();
|
|
120
|
+
});
|
|
121
|
+
status('idle');
|
|
122
|
+
await vi.waitFor(() => expect(state.requests.some((r) => r.limit === '1')).toBe(true));
|
|
123
|
+
await pause();
|
|
124
|
+
expect(state.patches).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('does not fall back to stale metadata when the fresh read fails', async () => {
|
|
128
|
+
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
129
|
+
const { state, status } = await fixture();
|
|
130
|
+
state.failFresh = true;
|
|
131
|
+
status('idle');
|
|
132
|
+
await vi.waitFor(() => expect(warning).toHaveBeenCalled());
|
|
133
|
+
expect(state.gets).toBe(2);
|
|
134
|
+
expect(state.patches).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('cancels old work and retains an expired newer idle timer until it can run', async () => {
|
|
138
|
+
const releases = [];
|
|
139
|
+
const { state, status } = await fixture(() => new Promise((resolve) => releases.push(resolve)));
|
|
140
|
+
status('idle');
|
|
141
|
+
await vi.waitFor(() => expect(releases).toHaveLength(1));
|
|
142
|
+
status('busy');
|
|
143
|
+
expect(state.calls[0].signal.aborted).toBe(true);
|
|
144
|
+
state.messages.push(message('next-user', 'user', 'Далі'), message('next-answer', 'assistant', 'Готово', { parentID: 'next-user' }));
|
|
145
|
+
status('idle');
|
|
146
|
+
await pause();
|
|
147
|
+
releases[0](output('Старий результат'));
|
|
148
|
+
await vi.waitFor(() => expect(releases).toHaveLength(2));
|
|
149
|
+
expect(state.patches).toHaveLength(0);
|
|
150
|
+
releases[1](output('Новий результат'));
|
|
151
|
+
await vi.waitFor(() => expect(state.patches).toHaveLength(1));
|
|
152
|
+
expect(state.patches[0].metadata.aiworker.assist).toMatchObject({ recap: 'Новий результат', forMessageID: 'next-answer' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('aborts on stop and honors settings switched off during generation', async () => {
|
|
156
|
+
let release;
|
|
157
|
+
const { state, runtime, status } = await fixture(() => new Promise((resolve) => { release = resolve; }));
|
|
158
|
+
status('idle');
|
|
159
|
+
await vi.waitFor(() => expect(state.calls).toHaveLength(1));
|
|
160
|
+
runtime.stop();
|
|
161
|
+
expect(state.calls[0].signal.aborted).toBe(true);
|
|
162
|
+
release(output());
|
|
163
|
+
await pause();
|
|
164
|
+
expect(state.patches).toHaveLength(0);
|
|
165
|
+
const second = await fixture(async (_args, current) => {
|
|
166
|
+
current.targets = { recap: false, suggestion: false };
|
|
167
|
+
return output();
|
|
168
|
+
});
|
|
169
|
+
second.status('idle');
|
|
170
|
+
await vi.waitFor(() => expect(second.state.gets).toBe(2));
|
|
171
|
+
await pause();
|
|
172
|
+
expect(second.state.patches).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('ignores historical user updates but cancels a new request during generation', async () => {
|
|
176
|
+
let release;
|
|
177
|
+
const { state, runtime, status } = await fixture(() => new Promise((resolve) => { release = resolve; }));
|
|
178
|
+
status('idle');
|
|
179
|
+
await vi.waitFor(() => expect(state.calls).toHaveLength(1));
|
|
180
|
+
const userUpdate = (created) => runtime.processPayload({ type: 'message.updated', properties: {
|
|
181
|
+
info: { id: 'user', sessionID: 'session', role: 'user', time: { created } },
|
|
182
|
+
} });
|
|
183
|
+
userUpdate(1);
|
|
184
|
+
expect(state.calls[0].signal.aborted).toBe(false);
|
|
185
|
+
userUpdate(Date.now());
|
|
186
|
+
expect(state.calls[0].signal.aborted).toBe(true);
|
|
187
|
+
release(output());
|
|
188
|
+
await pause();
|
|
189
|
+
expect(state.patches).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('rejects endpoint changes before writing instead of carrying a session into a new runtime', async () => {
|
|
193
|
+
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
194
|
+
const { state, status } = await fixture(async (_args, current) => {
|
|
195
|
+
current.base = 'http://unreachable.invalid';
|
|
196
|
+
return output();
|
|
197
|
+
});
|
|
198
|
+
status('idle');
|
|
199
|
+
await vi.waitFor(() => expect(warning).toHaveBeenCalled());
|
|
200
|
+
expect(state.patches).toHaveLength(0);
|
|
201
|
+
expect(state.gets).toBe(1);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -86,11 +86,21 @@ its current hunk index while visible; regeneration remains user-initiated. The
|
|
|
86
86
|
content-addressed cache continues to reuse an old review only when its hunks
|
|
87
87
|
match, and otherwise reports stale anchors and uncovered current hunks.
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
PR mode uses the same searchable, paginated selector as Changes. Its list loads
|
|
90
|
+
only while PR mode is visible. Selection ownership and handoff rules are in
|
|
91
|
+
`packages/ui/src/stores/DOCUMENTATION.md`.
|
|
92
|
+
|
|
93
|
+
PR sources may include `sourceRepo: { owner, repo }`. This qualifies both the
|
|
94
|
+
GitHub request and the cache/job key as `pr:<owner>/<repo>:<number>`. Existing
|
|
95
|
+
number-only sources retain `pr:<number>` and resolve the directory's repository.
|
|
96
|
+
The PR panel forwards its resolved repository when opening walkthrough.
|
|
97
|
+
|
|
98
|
+
`GET /api/walkthrough/pr-diff` accepts `directory` and a JSON `source` restricted
|
|
99
|
+
to PRs. It returns GitHub's complete published diff as text, with no model
|
|
100
|
+
readiness checks or generation. Successful empty patches return 200; auth,
|
|
101
|
+
GitHub and malformed-response failures remain errors. Walkthrough generation
|
|
102
|
+
keeps its existing empty-diff refusal. UI comparison behavior is documented in
|
|
103
|
+
`packages/ui/src/components/views/DOCUMENTATION.md`.
|
|
94
104
|
|
|
95
105
|
## No truncation
|
|
96
106
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseSource, sourceKey, loadSourceSections } from './sources.js';
|
|
3
|
+
|
|
4
|
+
describe('repository-qualified PR sources', () => {
|
|
5
|
+
it('keeps old cache keys and separates equal PR numbers in different repositories', () => {
|
|
6
|
+
expect(sourceKey(parseSource({ kind: 'pr', number: 42 }))).toBe('pr:42');
|
|
7
|
+
const upstream = parseSource({ kind: 'pr', number: 42, sourceRepo: { owner: 'upstream', repo: 'project' } });
|
|
8
|
+
const fork = parseSource({ kind: 'pr', number: 42, sourceRepo: { owner: 'fork', repo: 'project' } });
|
|
9
|
+
expect(sourceKey(upstream)).not.toBe(sourceKey(fork));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('hands the selected repository to the walkthrough diff loader', async () => {
|
|
13
|
+
const source = parseSource({ kind: 'pr', number: 42, sourceRepo: { owner: 'upstream', repo: 'project' } });
|
|
14
|
+
let received;
|
|
15
|
+
await loadSourceSections('/repo', source, { getPullRequestDiff: async (...args) => {
|
|
16
|
+
received = args;
|
|
17
|
+
return { patch: 'published patch', meta: {} };
|
|
18
|
+
} });
|
|
19
|
+
expect(received).toEqual(['/repo', 42, source.sourceRepo]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getOctokitOrNull } from '../github/octokit.js';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
2
3
|
import { resolveGitHubRepoFromDirectory } from '../github/repo/index.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -8,7 +9,7 @@ import { resolveGitHubRepoFromDirectory } from '../github/repo/index.js';
|
|
|
8
9
|
* three-dot semantics used for local branch reviews: work merged in from the
|
|
9
10
|
* base branch is not part of it.
|
|
10
11
|
*/
|
|
11
|
-
export async function getPullRequestDiff(directory, number) {
|
|
12
|
+
export async function getPullRequestDiff(directory, number, sourceRepo, { allowEmpty = false } = {}) {
|
|
12
13
|
const octokit = getOctokitOrNull();
|
|
13
14
|
if (!octokit) {
|
|
14
15
|
throw Object.assign(new Error('Connect a GitHub account to review pull requests'), {
|
|
@@ -19,7 +20,8 @@ export async function getPullRequestDiff(directory, number) {
|
|
|
19
20
|
|
|
20
21
|
// The resolver returns `{ repo, remoteUrl }`, not the repo itself. Reading
|
|
21
22
|
// `.owner` off the wrapper made this check fail for every repository.
|
|
22
|
-
const { repo }
|
|
23
|
+
const resolved = sourceRepo ? { repo: sourceRepo } : await resolveGitHubRepoFromDirectory(directory);
|
|
24
|
+
const { repo } = resolved;
|
|
23
25
|
if (!repo?.owner || !repo?.repo) {
|
|
24
26
|
throw Object.assign(new Error('This directory has no GitHub remote'), {
|
|
25
27
|
statusCode: 400,
|
|
@@ -34,8 +36,9 @@ export async function getPullRequestDiff(directory, number) {
|
|
|
34
36
|
headers: { accept: 'application/vnd.github.v3.diff' },
|
|
35
37
|
});
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
assert.match(response.data, /^(?:diff --git |\s*$)/, 'GitHub returned an invalid pull request diff');
|
|
40
|
+
const patch = response.data;
|
|
41
|
+
if (!allowEmpty && !patch.trim()) {
|
|
39
42
|
throw Object.assign(new Error(`Pull request #${number} has no diff`), {
|
|
40
43
|
statusCode: 404,
|
|
41
44
|
code: 'empty-diff',
|
|
@@ -56,6 +56,22 @@ describe('getPullRequestDiff', () => {
|
|
|
56
56
|
expect(request).not.toHaveBeenCalled();
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it('uses the selected upstream repository rather than the local fork', async () => {
|
|
60
|
+
const result = await getPullRequestDiff('/repo', 42, { owner: 'upstream', repo: 'project' });
|
|
61
|
+
expect(result.meta).toEqual({ owner: 'upstream', repo: 'project', number: 42 });
|
|
62
|
+
expect(resolveGitHubRepoFromDirectory).not.toHaveBeenCalled();
|
|
63
|
+
expect(request).toHaveBeenCalledWith('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
|
|
64
|
+
owner: 'upstream', repo: 'project', pull_number: 42, headers: { accept: 'application/vnd.github.v3.diff' },
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('allows an empty comparison but rejects malformed GitHub bodies', async () => {
|
|
69
|
+
request.mockResolvedValue({ data: '' });
|
|
70
|
+
expect((await getPullRequestDiff('/repo', 42, undefined, { allowEmpty: true })).patch).toBe('');
|
|
71
|
+
request.mockResolvedValue({ data: { message: 'Not a diff' } });
|
|
72
|
+
await expect(getPullRequestDiff('/repo', 42)).rejects.toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
59
75
|
it('asks the user to connect GitHub before anything else', async () => {
|
|
60
76
|
getOctokitOrNull.mockReturnValue(null);
|
|
61
77
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseSource } from './sources.js';
|
|
2
|
+
|
|
1
3
|
// `req.destroyed` is true for every healthy request once the body parser has
|
|
2
4
|
// consumed the stream, so using it as a disconnect check silently swallows every
|
|
3
5
|
// response. The response socket is the one that actually reflects whether the
|
|
@@ -51,6 +53,23 @@ export function registerWalkthroughRoutes(app, { getWalkthroughService }) {
|
|
|
51
53
|
}
|
|
52
54
|
});
|
|
53
55
|
|
|
56
|
+
// The comparison view needs the complete published patch, without model
|
|
57
|
+
// readiness checks, generated-file filtering, or local working-tree reads.
|
|
58
|
+
app.get('/api/walkthrough/pr-diff', async (req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
const query = new URL(req.originalUrl, 'http://localhost').searchParams;
|
|
61
|
+
const directory = query.get('directory')?.trim() ?? '';
|
|
62
|
+
if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
|
|
63
|
+
const source = parseSource(readSource(query.get('source')));
|
|
64
|
+
if (source.kind !== 'pr') return res.status(400).json({ error: 'A pull request source is required' });
|
|
65
|
+
const { getPullRequestDiff } = await getWalkthroughService();
|
|
66
|
+
const { patch } = await getPullRequestDiff(directory, source.number, source.sourceRepo, { allowEmpty: true });
|
|
67
|
+
res.type('text/plain').send(patch);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
respondWithError(res, error, 'Failed to load pull request diff');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
54
73
|
// Deliberately not aborted when the client disconnects: generation runs for
|
|
55
74
|
// minutes and a refresh must not throw the work away. Leaving detaches the
|
|
56
75
|
// client; the job finishes and caches its result. Stopping is an explicit
|
|
@@ -18,6 +18,11 @@ describe('walkthrough routes', () => {
|
|
|
18
18
|
let generateCalls = 0;
|
|
19
19
|
|
|
20
20
|
const service = {
|
|
21
|
+
async getPullRequestDiff(directory, number, sourceRepo, options) {
|
|
22
|
+
lastArgs = { directory, number, sourceRepo, options };
|
|
23
|
+
if (number === 99) throw Object.assign(new Error('GitHub unavailable'), { statusCode: 503 });
|
|
24
|
+
return { patch: number === 1 ? '' : 'diff --git a/a.ts b/a.ts\n' };
|
|
25
|
+
},
|
|
21
26
|
async getWalkthrough(args) {
|
|
22
27
|
lastArgs = args;
|
|
23
28
|
return { walkthrough: null, hunks: [], hunkCount: 0, generating: Boolean(job) };
|
|
@@ -84,6 +89,27 @@ describe('walkthrough routes', () => {
|
|
|
84
89
|
expect(body.walkthrough).toEqual({ title: 'DONE' });
|
|
85
90
|
});
|
|
86
91
|
|
|
92
|
+
it('serves the published PR snapshot with its repository, without generating', async () => {
|
|
93
|
+
const source = { kind: 'pr', number: 42, sourceRepo: { owner: 'upstream', repo: 'project' } };
|
|
94
|
+
const before = generateCalls;
|
|
95
|
+
const response = await fetch(`${base}/api/walkthrough/pr-diff?directory=/repo&source=${encodeURIComponent(JSON.stringify(source))}`);
|
|
96
|
+
expect(response.headers.get('content-type')).toContain('text/plain');
|
|
97
|
+
expect(await response.text()).toBe('diff --git a/a.ts b/a.ts\n');
|
|
98
|
+
expect(lastArgs).toEqual({ directory: '/repo', number: 42, sourceRepo: source.sourceRepo, options: { allowEmpty: true } });
|
|
99
|
+
expect(generateCalls).toBe(before);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('distinguishes empty PRs, upstream failure, and invalid sources', async () => {
|
|
103
|
+
const request = (source) => fetch(`${base}/api/walkthrough/pr-diff?directory=/repo&source=${encodeURIComponent(JSON.stringify(source))}`);
|
|
104
|
+
const empty = await request({ kind: 'pr', number: 1 });
|
|
105
|
+
expect(empty.status).toBe(200);
|
|
106
|
+
expect(await empty.text()).toBe('');
|
|
107
|
+
expect((await request({ kind: 'pr', number: 99 })).status).toBe(503);
|
|
108
|
+
for (const source of [{ kind: 'pr', number: -1 }, { kind: 'branch', baseRef: 'main', headRef: 'feature' }, { kind: 'pr', number: 1, sourceRepo: { owner: '../bad', repo: 'repo' } }]) {
|
|
109
|
+
expect((await request(source)).status).toBe(400);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
87
113
|
it('delivers the result to a client that reconnected after a refresh', async () => {
|
|
88
114
|
const controller = new AbortController();
|
|
89
115
|
const seen = generateCalls;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getDiff, getRangeDiff, getCommitDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
2
3
|
|
|
3
4
|
// A walkthrough source resolves to one or more diff *sections*. A section is a
|
|
4
5
|
// patch plus the scope its hunk ids live in; keeping staged and working-tree
|
|
@@ -45,6 +46,16 @@ export function parseSource(raw) {
|
|
|
45
46
|
if (!Number.isInteger(number) || number <= 0) {
|
|
46
47
|
throw new WalkthroughSourceError('pr sources require a positive number');
|
|
47
48
|
}
|
|
49
|
+
if (raw.sourceRepo !== undefined) {
|
|
50
|
+
const { owner, repo } = raw.sourceRepo ?? {};
|
|
51
|
+
try {
|
|
52
|
+
assert.match(owner, /^[a-zA-Z0-9-]+$/);
|
|
53
|
+
assert.match(repo, /^[a-zA-Z0-9_.-]+$/);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new WalkthroughSourceError('pr sources require a valid repository');
|
|
56
|
+
}
|
|
57
|
+
return { kind: 'pr', number, sourceRepo: { owner, repo } };
|
|
58
|
+
}
|
|
48
59
|
return { kind: 'pr', number };
|
|
49
60
|
}
|
|
50
61
|
|
|
@@ -71,7 +82,7 @@ export function sourceKey(source) {
|
|
|
71
82
|
if (source.kind === 'working-tree') return `working-tree:${source.scope}`;
|
|
72
83
|
if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`;
|
|
73
84
|
if (source.kind === 'commit') return `commit:${source.hash}`;
|
|
74
|
-
return `pr:${source.number}`;
|
|
85
|
+
return source.sourceRepo ? `pr:${source.sourceRepo.owner}/${source.sourceRepo.repo}:${source.number}` : `pr:${source.number}`;
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
// `git diff` never reports untracked files, so a brand-new file would be
|
|
@@ -129,7 +140,7 @@ export async function loadSourceSections(directory, source, { getPullRequestDiff
|
|
|
129
140
|
throw new WalkthroughSourceError('Pull request diffs are unavailable', 500);
|
|
130
141
|
}
|
|
131
142
|
|
|
132
|
-
const { patch, meta } = await getPullRequestDiff(directory, source.number);
|
|
143
|
+
const { patch, meta } = await getPullRequestDiff(directory, source.number, source.sourceRepo);
|
|
133
144
|
return {
|
|
134
145
|
sections: patch && patch.trim() ? [{ scope: `pr:${source.number}`, patch }] : [],
|
|
135
146
|
meta: meta || {},
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { registerOpenCodeProxy } from './lib/opencode/proxy.js';
|
|
5
|
+
|
|
6
|
+
const servers = [];
|
|
7
|
+
const listen = async (app) => {
|
|
8
|
+
const server = await new Promise((resolve) => {
|
|
9
|
+
const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
|
|
10
|
+
});
|
|
11
|
+
servers.push(server);
|
|
12
|
+
return `http://127.0.0.1:${server.address().port}`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => {
|
|
17
|
+
server.closeAllConnections();
|
|
18
|
+
server.close(resolve);
|
|
19
|
+
})));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const worktree = '/repo/worktree with spaces';
|
|
23
|
+
const projectConfig = { mcp: { projectOnly: { type: 'local', command: ['test-mcp'], enabled: false } } };
|
|
24
|
+
|
|
25
|
+
async function createFixture({ timeoutMs = 1000 } = {}) {
|
|
26
|
+
let bootstrap = { status: 'pending', phase: 'directory-created', error: null };
|
|
27
|
+
let instance;
|
|
28
|
+
const forwarded = [];
|
|
29
|
+
const probed = [];
|
|
30
|
+
const upstream = express();
|
|
31
|
+
upstream.use(express.json());
|
|
32
|
+
upstream.use((req, res) => {
|
|
33
|
+
forwarded.push(req.path);
|
|
34
|
+
// Model OpenCode's instance cache: even a GET can permanently capture the
|
|
35
|
+
// empty directory, and a later session inherits that project's identity.
|
|
36
|
+
instance ??= bootstrap.phase === 'directory-created'
|
|
37
|
+
? { projectID: 'global', config: {} }
|
|
38
|
+
: { projectID: 'project-3464', config: projectConfig };
|
|
39
|
+
if (req.path === '/config') return res.json(instance.config);
|
|
40
|
+
if (req.path === '/session' && req.method === 'POST') {
|
|
41
|
+
return res.json({ projectID: instance.projectID, directory: req.query.directory, title: req.body.title });
|
|
42
|
+
}
|
|
43
|
+
res.json([]);
|
|
44
|
+
});
|
|
45
|
+
const upstreamUrl = await listen(upstream);
|
|
46
|
+
const app = express();
|
|
47
|
+
app.use(express.json());
|
|
48
|
+
registerOpenCodeProxy(app, {
|
|
49
|
+
fs: {}, os: {}, path,
|
|
50
|
+
OPEN_CODE_READY_GRACE_MS: 0,
|
|
51
|
+
WORKTREE_READY_TIMEOUT_MS: timeoutMs,
|
|
52
|
+
getRuntime: () => ({ openCodePort: new URL(upstreamUrl).port, isOpenCodeReady: true }),
|
|
53
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
54
|
+
buildOpenCodeUrl: (requestPath) => `${upstreamUrl}${requestPath}`,
|
|
55
|
+
ensureOpenCodeApiPrefix: () => {},
|
|
56
|
+
readWorktreeBootstrapStatus: async (directory) => {
|
|
57
|
+
probed.push(directory);
|
|
58
|
+
return directory === worktree ? bootstrap : { status: 'ready', phase: 'setup-ready' };
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
url: await listen(app), forwarded, probed,
|
|
63
|
+
setBootstrap: (next) => { bootstrap = next; },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('issue #3464 worktree OpenCode initialization', () => {
|
|
68
|
+
it('holds config and session creation until Git is ready, while setup remains pending', async () => {
|
|
69
|
+
const fixture = await createFixture();
|
|
70
|
+
const config = fetch(`${fixture.url}/api/config?directory=${encodeURIComponent(worktree)}`);
|
|
71
|
+
const session = fetch(`${fixture.url}/api/session?directory=${encodeURIComponent(worktree)}`, {
|
|
72
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({ title: 'First worktree session' }),
|
|
74
|
+
});
|
|
75
|
+
await expect.poll(() => fixture.probed.length).toBeGreaterThanOrEqual(2);
|
|
76
|
+
expect(fixture.forwarded).toEqual([]);
|
|
77
|
+
fixture.setBootstrap({ status: 'pending', phase: 'git-ready', error: null });
|
|
78
|
+
expect(await (await config).json()).toEqual(projectConfig);
|
|
79
|
+
expect(await (await session).json()).toEqual({
|
|
80
|
+
projectID: 'project-3464', directory: worktree, title: 'First worktree session',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('gates encoded directory headers and the separately forwarded session list', async () => {
|
|
85
|
+
const fixture = await createFixture();
|
|
86
|
+
const response = fetch(`${fixture.url}/api/session`, { headers: {
|
|
87
|
+
'x-opencode-directory': encodeURIComponent(worktree), 'x-opencode-directory-encoding': 'uri',
|
|
88
|
+
} });
|
|
89
|
+
await expect.poll(() => fixture.probed.length).toBeGreaterThan(0);
|
|
90
|
+
expect(fixture.probed[0]).toBe(worktree);
|
|
91
|
+
expect(fixture.forwarded).toEqual([]);
|
|
92
|
+
fixture.setBootstrap({ status: 'ready', phase: 'setup-ready', error: null });
|
|
93
|
+
expect((await response).status).toBe(200);
|
|
94
|
+
expect(fixture.forwarded).toEqual(['/session']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not block unrelated directories while a worktree is pending', async () => {
|
|
98
|
+
const fixture = await createFixture();
|
|
99
|
+
expect((await fetch(`${fixture.url}/api/session?directory=/other-project`)).status).toBe(200);
|
|
100
|
+
expect(fixture.probed).toEqual(['/other-project']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not forward an abandoned request when checkout later finishes', async () => {
|
|
104
|
+
const fixture = await createFixture();
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const response = fetch(`${fixture.url}/api/config?directory=${encodeURIComponent(worktree)}`, {
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
});
|
|
109
|
+
const rejected = expect(response).rejects.toThrow();
|
|
110
|
+
await expect.poll(() => fixture.probed.length).toBeGreaterThan(0);
|
|
111
|
+
controller.abort();
|
|
112
|
+
await rejected;
|
|
113
|
+
// Allow the server to observe the closed client before releasing checkout.
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
115
|
+
fixture.setBootstrap({ status: 'ready', phase: 'setup-ready', error: null });
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
117
|
+
expect(fixture.forwarded).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('fails closed on bootstrap failure or timeout without initializing OpenCode', async () => {
|
|
121
|
+
const fixture = await createFixture({ timeoutMs: 0 });
|
|
122
|
+
const timedOut = await fetch(`${fixture.url}/api/config?directory=${encodeURIComponent(worktree)}`);
|
|
123
|
+
expect(timedOut.status).toBe(503);
|
|
124
|
+
fixture.setBootstrap({ status: 'failed', phase: 'directory-created', error: 'checkout failed' });
|
|
125
|
+
const failed = await fetch(`${fixture.url}/api/session?directory=${encodeURIComponent(worktree)}`, { method: 'POST' });
|
|
126
|
+
expect(failed.status).toBe(503);
|
|
127
|
+
expect(await failed.json()).toEqual({ error: 'checkout failed' });
|
|
128
|
+
expect(fixture.forwarded).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
});
|