@cursorpool-dev/cli 0.5.6
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/bin/cursor-pool.mjs +9 -0
- package/bin/cursor-pool.ts +169 -0
- package/node_modules/@cursor-pool/extension/dist/extension.js +2910 -0
- package/node_modules/@cursor-pool/extension/package.json +64 -0
- package/node_modules/@cursor-pool/extension/resources/cursor-pool.svg +6 -0
- package/node_modules/@cursor-pool/extension/src/api.ts +545 -0
- package/node_modules/@cursor-pool/extension/src/extension.ts +104 -0
- package/node_modules/@cursor-pool/extension/src/index.ts +1 -0
- package/node_modules/@cursor-pool/extension/src/panel.ts +569 -0
- package/node_modules/@cursor-pool/extension/src/runtime.ts +22 -0
- package/node_modules/@cursor-pool/extension/test/panel.test.ts +1785 -0
- package/node_modules/@cursor-pool/patcher/package.json +17 -0
- package/node_modules/@cursor-pool/patcher/src/alwaysLocalMarker.ts +86 -0
- package/node_modules/@cursor-pool/patcher/src/hash.ts +7 -0
- package/node_modules/@cursor-pool/patcher/src/index.ts +55 -0
- package/node_modules/@cursor-pool/patcher/src/marker.ts +159 -0
- package/node_modules/@cursor-pool/patcher/src/patchCursorAgentExec.ts +154 -0
- package/node_modules/@cursor-pool/patcher/src/patchCursorAlwaysLocal.ts +142 -0
- package/node_modules/@cursor-pool/patcher/src/patchCursorWorkbenchAuthGate.ts +140 -0
- package/node_modules/@cursor-pool/patcher/src/restoreCursorAgentExec.ts +52 -0
- package/node_modules/@cursor-pool/patcher/src/restoreCursorAlwaysLocal.ts +52 -0
- package/node_modules/@cursor-pool/patcher/src/restoreCursorWorkbenchAuthGate.ts +70 -0
- package/node_modules/@cursor-pool/patcher/src/workbenchAuthGateMarker.ts +243 -0
- package/node_modules/@cursor-pool/patcher/test/patchCursorAgentExec.test.ts +630 -0
- package/node_modules/@cursor-pool/patcher/test/patchCursorAlwaysLocal.test.ts +144 -0
- package/node_modules/@cursor-pool/patcher/test/patchCursorWorkbench.test.ts +770 -0
- package/node_modules/@cursor-pool/patcher/test/restoreCursorAgentExec.test.ts +139 -0
- package/node_modules/@cursor-pool/service/package.json +17 -0
- package/node_modules/@cursor-pool/service/src/canary.ts +61 -0
- package/node_modules/@cursor-pool/service/src/diagnostics.ts +385 -0
- package/node_modules/@cursor-pool/service/src/entry.ts +161 -0
- package/node_modules/@cursor-pool/service/src/health.ts +10 -0
- package/node_modules/@cursor-pool/service/src/index.ts +29 -0
- package/node_modules/@cursor-pool/service/src/metadata.ts +22 -0
- package/node_modules/@cursor-pool/service/src/platformSession.ts +1178 -0
- package/node_modules/@cursor-pool/service/src/requestCheck.ts +81 -0
- package/node_modules/@cursor-pool/service/src/requestGate.ts +100 -0
- package/node_modules/@cursor-pool/service/src/requestGateway.ts +441 -0
- package/node_modules/@cursor-pool/service/src/runtime.ts +48 -0
- package/node_modules/@cursor-pool/service/src/server.ts +939 -0
- package/node_modules/@cursor-pool/service/src/takeover.ts +111 -0
- package/node_modules/@cursor-pool/service/test/canary.test.ts +140 -0
- package/node_modules/@cursor-pool/service/test/diagnostics.test.ts +506 -0
- package/node_modules/@cursor-pool/service/test/metadata.test.ts +63 -0
- package/node_modules/@cursor-pool/service/test/platformSession.test.ts +2428 -0
- package/node_modules/@cursor-pool/service/test/requestCheck.test.ts +152 -0
- package/node_modules/@cursor-pool/service/test/requestGate.test.ts +207 -0
- package/node_modules/@cursor-pool/service/test/requestGateway.test.ts +466 -0
- package/node_modules/@cursor-pool/service/test/runtime.test.ts +47 -0
- package/node_modules/@cursor-pool/service/test/server.test.ts +2570 -0
- package/node_modules/@cursor-pool/shared/package.json +17 -0
- package/node_modules/@cursor-pool/shared/src/clientConfig.ts +49 -0
- package/node_modules/@cursor-pool/shared/src/index.ts +14 -0
- package/node_modules/@cursor-pool/shared/src/manifest.ts +36 -0
- package/node_modules/@cursor-pool/shared/src/metadata.ts +19 -0
- package/node_modules/@cursor-pool/shared/src/paths.ts +5 -0
- package/node_modules/@cursor-pool/shared/src/runtime.ts +3 -0
- package/node_modules/@cursor-pool/shared/test/index.test.ts +56 -0
- package/node_modules/@cursor-pool/shared/test/manifest.test.ts +65 -0
- package/node_modules/@cursor-pool/shared/test/metadata.test.ts +25 -0
- package/node_modules/@cursor-pool/shared/test/runtime.test.ts +8 -0
- package/package.json +28 -0
- package/src/adHocResign.ts +65 -0
- package/src/autostart.ts +240 -0
- package/src/compat.ts +282 -0
- package/src/confirm.ts +76 -0
- package/src/cursor.ts +94 -0
- package/src/diagnostics.ts +558 -0
- package/src/environment.ts +18 -0
- package/src/extensionBundle.ts +111 -0
- package/src/extensionLink.ts +168 -0
- package/src/index.ts +23 -0
- package/src/install.ts +614 -0
- package/src/installRecord.ts +105 -0
- package/src/launch.ts +182 -0
- package/src/patchSet.ts +182 -0
- package/src/platform.ts +132 -0
- package/src/repair.ts +383 -0
- package/src/restore.ts +153 -0
- package/src/serviceCommands.ts +79 -0
- package/src/serviceProcess.ts +188 -0
- package/src/status.ts +241 -0
- package/src/target.ts +37 -0
- package/src/trial.ts +133 -0
- package/src/uninstall.ts +213 -0
- package/test/autostart.test.ts +151 -0
- package/test/compat.test.ts +192 -0
- package/test/confirm.test.ts +114 -0
- package/test/cursor-pool-bin.test.ts +658 -0
- package/test/cursor.test.ts +20 -0
- package/test/diagnostics.test.ts +709 -0
- package/test/e2e-install.test.ts +773 -0
- package/test/extensionBundle.test.ts +161 -0
- package/test/extensionLink.test.ts +209 -0
- package/test/install.test.ts +862 -0
- package/test/installRecord.test.ts +107 -0
- package/test/launch.test.ts +138 -0
- package/test/platform.test.ts +226 -0
- package/test/repair.test.ts +575 -0
- package/test/restore.test.ts +211 -0
- package/test/serviceCommands.test.ts +135 -0
- package/test/serviceProcess.test.ts +280 -0
- package/test/status.test.ts +615 -0
- package/test/target.test.ts +49 -0
- package/test/trial.test.ts +146 -0
|
@@ -0,0 +1,1785 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createServer, type Server } from 'node:http';
|
|
3
|
+
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { registerExtensionCommands } from '../src/extension';
|
|
9
|
+
import {
|
|
10
|
+
getPlatformCatalog,
|
|
11
|
+
getPlatformStatus,
|
|
12
|
+
getServiceStatus,
|
|
13
|
+
loginPlatform,
|
|
14
|
+
logoutPlatform,
|
|
15
|
+
selectPlatformProduct,
|
|
16
|
+
startMode,
|
|
17
|
+
stopMode,
|
|
18
|
+
} from '../src/api';
|
|
19
|
+
import { buildPanelHtml, createPanelViewModel, registerStatusPanel } from '../src/panel';
|
|
20
|
+
import { readRuntimeInfo } from '../src/runtime';
|
|
21
|
+
|
|
22
|
+
const extensionRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
|
|
24
|
+
async function listen(server: Server) {
|
|
25
|
+
await new Promise<void>((resolve, reject) => {
|
|
26
|
+
server.once('error', reject);
|
|
27
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const address = server.address();
|
|
31
|
+
assert.equal(typeof address, 'object');
|
|
32
|
+
assert.ok(address);
|
|
33
|
+
return address.port;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function close(server: Server) {
|
|
37
|
+
await new Promise<void>((resolve, reject) => {
|
|
38
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function createRuntimeFixture() {
|
|
43
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-extension-'));
|
|
44
|
+
const runtimeFile = join(tempDir, 'runtime.json');
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
tempDir,
|
|
48
|
+
runtimeFile,
|
|
49
|
+
cleanup: () => rm(tempDir, { recursive: true, force: true }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('sidebar WebView can open with rendered status HTML', async () => {
|
|
54
|
+
const subscriptions: unknown[] = [];
|
|
55
|
+
const registered: {
|
|
56
|
+
id: string;
|
|
57
|
+
provider: { resolveWebviewView: (view: { webview: { html: string } }) => unknown };
|
|
58
|
+
}[] = [];
|
|
59
|
+
const fakeVscode = {
|
|
60
|
+
window: {
|
|
61
|
+
registerWebviewViewProvider(id: string, provider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
62
|
+
registered.push({ id, provider: provider as (typeof registered)[number]['provider'] });
|
|
63
|
+
return { dispose() {} };
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
registerStatusPanel({ subscriptions }, fakeVscode, {
|
|
69
|
+
status: {
|
|
70
|
+
patch: 'applied',
|
|
71
|
+
service: 'running',
|
|
72
|
+
compat: 'supported',
|
|
73
|
+
mode: 'stopped',
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.equal(registered[0]?.id, 'cursorPool.statusPanel');
|
|
78
|
+
assert.equal(subscriptions.length, 1);
|
|
79
|
+
|
|
80
|
+
const view = { webview: { html: '' } };
|
|
81
|
+
await registered[0].provider.resolveWebviewView(view);
|
|
82
|
+
|
|
83
|
+
assert.match(view.webview.html, /Cursor Pool/);
|
|
84
|
+
assert.match(view.webview.html, /本地服务[\s\S]*running/);
|
|
85
|
+
assert.doesNotMatch(view.webview.html, /补丁状态/);
|
|
86
|
+
assert.doesNotMatch(view.webview.html, /兼容状态/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('sidebar WebView enables scripts while preserving existing options', async () => {
|
|
90
|
+
const registered: {
|
|
91
|
+
provider: {
|
|
92
|
+
resolveWebviewView: (view: {
|
|
93
|
+
webview: { html: string; options?: Record<string, unknown> };
|
|
94
|
+
}) => unknown;
|
|
95
|
+
};
|
|
96
|
+
}[] = [];
|
|
97
|
+
const fakeVscode = {
|
|
98
|
+
window: {
|
|
99
|
+
registerWebviewViewProvider(_id: string, provider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
100
|
+
registered.push({ provider: provider as (typeof registered)[number]['provider'] });
|
|
101
|
+
return { dispose() {} };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
registerStatusPanel({ subscriptions: [] }, fakeVscode, {
|
|
107
|
+
status: {
|
|
108
|
+
patch: 'applied',
|
|
109
|
+
service: 'running',
|
|
110
|
+
compat: 'supported',
|
|
111
|
+
mode: 'stopped',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const view = {
|
|
116
|
+
webview: {
|
|
117
|
+
html: '',
|
|
118
|
+
options: { retainContextWhenHidden: true },
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
await registered[0].provider.resolveWebviewView(view);
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(view.webview.options, {
|
|
124
|
+
retainContextWhenHidden: true,
|
|
125
|
+
enableScripts: true,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('extension package main points to an existing JavaScript host entrypoint', async () => {
|
|
130
|
+
const manifest = JSON.parse(await readFile(join(extensionRoot, 'package.json'), 'utf8')) as {
|
|
131
|
+
main?: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
assert.match(manifest.main ?? '', /\.js$/);
|
|
135
|
+
await access(join(extensionRoot, manifest.main ?? ''));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('extension manifest exposes command-based status reporting activation', async () => {
|
|
139
|
+
const manifest = JSON.parse(await readFile(join(extensionRoot, 'package.json'), 'utf8')) as {
|
|
140
|
+
activationEvents?: string[];
|
|
141
|
+
contributes?: { commands?: { command: string; title: string }[] };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
assert.equal(manifest.activationEvents?.includes('onStartupFinished'), false);
|
|
145
|
+
assert.equal(manifest.activationEvents?.includes('onCommand:cursorPool.openClient'), true);
|
|
146
|
+
assert.equal(manifest.activationEvents?.includes('onCommand:cursorPool.reportStatus'), true);
|
|
147
|
+
assert.equal(
|
|
148
|
+
manifest.contributes?.commands?.some(
|
|
149
|
+
(command) =>
|
|
150
|
+
command.command === 'cursorPool.openClient' &&
|
|
151
|
+
command.title === 'Cursor Pool: 打开用户端',
|
|
152
|
+
),
|
|
153
|
+
true,
|
|
154
|
+
);
|
|
155
|
+
assert.equal(
|
|
156
|
+
manifest.contributes?.commands?.some(
|
|
157
|
+
(command) =>
|
|
158
|
+
command.command === 'cursorPool.reportStatus' &&
|
|
159
|
+
command.title === 'Cursor Pool: Report Status',
|
|
160
|
+
),
|
|
161
|
+
true,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('extension manifest exposes Cursor Pool as a first-class client sidebar', async () => {
|
|
166
|
+
const manifest = JSON.parse(await readFile(join(extensionRoot, 'package.json'), 'utf8')) as {
|
|
167
|
+
displayName?: string;
|
|
168
|
+
contributes?: {
|
|
169
|
+
viewsContainers?: { activitybar?: { id: string; title: string; icon: string }[] };
|
|
170
|
+
views?: Record<string, { id: string; name: string; type?: string }[]>;
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
assert.equal(manifest.displayName, 'Cursor Pool 平台模式');
|
|
175
|
+
assert.equal(
|
|
176
|
+
manifest.contributes?.viewsContainers?.activitybar?.some(
|
|
177
|
+
(container) =>
|
|
178
|
+
container.id === 'cursorPoolClient' &&
|
|
179
|
+
container.title === 'Cursor Pool 平台模式' &&
|
|
180
|
+
container.icon === 'resources/cursor-pool.svg',
|
|
181
|
+
),
|
|
182
|
+
true,
|
|
183
|
+
);
|
|
184
|
+
assert.equal(
|
|
185
|
+
manifest.contributes?.views?.cursorPoolClient?.some(
|
|
186
|
+
(view) => view.id === 'cursorPool.statusPanel' && view.name === '用户端' && view.type === 'webview',
|
|
187
|
+
),
|
|
188
|
+
true,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('extension API preserves inactive platform mode release reason', async () => {
|
|
193
|
+
const fixture = await createRuntimeFixture();
|
|
194
|
+
const server = createServer((_request, response) => {
|
|
195
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
196
|
+
response.end(JSON.stringify({
|
|
197
|
+
state: 'logged-in',
|
|
198
|
+
account: { credits: 80 },
|
|
199
|
+
mode: {
|
|
200
|
+
state: 'inactive',
|
|
201
|
+
releaseReason: 'insufficient-credits',
|
|
202
|
+
releasedAt: '2026-05-31T00:05:00.000Z',
|
|
203
|
+
},
|
|
204
|
+
products: [],
|
|
205
|
+
}));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const port = await listen(server);
|
|
210
|
+
await writeFile(
|
|
211
|
+
fixture.runtimeFile,
|
|
212
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
213
|
+
'utf8',
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const catalog = await getPlatformCatalog(fixture.runtimeFile);
|
|
217
|
+
|
|
218
|
+
assert.equal(catalog.state, 'logged-in');
|
|
219
|
+
assert.equal(catalog.mode?.state, 'inactive');
|
|
220
|
+
assert.equal(catalog.mode?.releaseReason, 'insufficient-credits');
|
|
221
|
+
assert.equal(catalog.mode?.releasedAt, '2026-05-31T00:05:00.000Z');
|
|
222
|
+
} finally {
|
|
223
|
+
await close(server);
|
|
224
|
+
await fixture.cleanup();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('real extension host entrypoint handles panel controls from WebView messages', async () => {
|
|
229
|
+
const fixture = await createRuntimeFixture();
|
|
230
|
+
const requests: string[] = [];
|
|
231
|
+
let renderedHtml = '';
|
|
232
|
+
const server = createServer((request, response) => {
|
|
233
|
+
requests.push(`${request.method} ${request.url}`);
|
|
234
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
235
|
+
|
|
236
|
+
if (request.url === '/health') {
|
|
237
|
+
response.end(JSON.stringify({ ok: true, runtimeId: 'extension-runtime' }));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (request.url === '/extension/status') {
|
|
242
|
+
response.end(JSON.stringify({ ok: true, status: { connected: true } }));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (request.url === '/mode/start') {
|
|
247
|
+
response.end(JSON.stringify({ ok: true, mode: 'started' }));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (request.url === '/mode/stop') {
|
|
252
|
+
response.end(JSON.stringify({ ok: true, mode: 'stopped' }));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (request.url === '/platform/login') {
|
|
257
|
+
response.end(JSON.stringify({
|
|
258
|
+
state: 'logged-in',
|
|
259
|
+
user: { id: 'usr_1', email: 'dev@example.com' },
|
|
260
|
+
device: { id: 'dev_1', status: 'active', lastHeartbeatAt: '2026-05-30T00:00:00Z' },
|
|
261
|
+
}));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (request.url === '/platform/logout') {
|
|
266
|
+
response.end(JSON.stringify({ state: 'logged-out' }));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (request.url === '/platform/status') {
|
|
271
|
+
response.end(JSON.stringify({
|
|
272
|
+
state: 'logged-in',
|
|
273
|
+
user: { id: 'usr_1', email: 'dev@example.com' },
|
|
274
|
+
device: { id: 'dev_1', status: 'active', lastHeartbeatAt: '2026-05-30T00:00:00Z' },
|
|
275
|
+
}));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (request.url === '/platform/catalog') {
|
|
280
|
+
response.end(JSON.stringify({
|
|
281
|
+
state: 'logged-in',
|
|
282
|
+
account: { credits: 1000 },
|
|
283
|
+
mode: {
|
|
284
|
+
state: 'inactive',
|
|
285
|
+
releaseReason: 'insufficient-credits',
|
|
286
|
+
releasedAt: '2026-05-31T00:05:00.000Z',
|
|
287
|
+
},
|
|
288
|
+
selectedProductId: 'prod_basic',
|
|
289
|
+
products: [
|
|
290
|
+
{
|
|
291
|
+
id: 'prod_basic',
|
|
292
|
+
name: '基础组',
|
|
293
|
+
description: '开发态基础商品,用于验证扩展展示。',
|
|
294
|
+
status: 'available',
|
|
295
|
+
minCredits: 100,
|
|
296
|
+
usageLabel: '按请求消耗,倍率 1x',
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
}));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (request.url === '/platform/selection') {
|
|
304
|
+
response.end(JSON.stringify({ state: 'selected', selectedProductId: 'prod_basic' }));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
response.end(JSON.stringify({ ok: true }));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const port = await listen(server);
|
|
313
|
+
await writeFile(
|
|
314
|
+
fixture.runtimeFile,
|
|
315
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
316
|
+
'utf8',
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const manifest = JSON.parse(await readFile(join(extensionRoot, 'package.json'), 'utf8')) as {
|
|
320
|
+
main: string;
|
|
321
|
+
};
|
|
322
|
+
const extensionHost = (await import(
|
|
323
|
+
`${pathToFileURL(join(extensionRoot, manifest.main)).href}?test=${Date.now()}-${Math.random()}`
|
|
324
|
+
)) as {
|
|
325
|
+
activate: (
|
|
326
|
+
context: { subscriptions: unknown[] },
|
|
327
|
+
vscode: unknown,
|
|
328
|
+
options: { runtimeFile: string },
|
|
329
|
+
) => Promise<void>;
|
|
330
|
+
};
|
|
331
|
+
const handlers: ((message: { command: string; productId?: string }) => unknown)[] = [];
|
|
332
|
+
const registeredCommands: Record<string, () => unknown> = {};
|
|
333
|
+
const executedCommands: string[] = [];
|
|
334
|
+
const messages: string[] = [];
|
|
335
|
+
let webviewResolved: Promise<void> = Promise.resolve();
|
|
336
|
+
const fakeVscode = {
|
|
337
|
+
commands: {
|
|
338
|
+
registerCommand(command: string, handler: () => unknown) {
|
|
339
|
+
registeredCommands[command] = handler;
|
|
340
|
+
return { dispose() {} };
|
|
341
|
+
},
|
|
342
|
+
executeCommand(command: string) {
|
|
343
|
+
executedCommands.push(command);
|
|
344
|
+
registeredCommands[command]?.();
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
window: {
|
|
348
|
+
registerWebviewViewProvider(
|
|
349
|
+
_id: string,
|
|
350
|
+
provider: {
|
|
351
|
+
resolveWebviewView: (view: {
|
|
352
|
+
webview: {
|
|
353
|
+
html: string;
|
|
354
|
+
options?: Record<string, unknown>;
|
|
355
|
+
onDidReceiveMessage: (handler: (message: { command: string; productId?: string }) => unknown) => void;
|
|
356
|
+
};
|
|
357
|
+
}) => unknown;
|
|
358
|
+
},
|
|
359
|
+
) {
|
|
360
|
+
const view = {
|
|
361
|
+
webview: {
|
|
362
|
+
html: '',
|
|
363
|
+
onDidReceiveMessage(handler) {
|
|
364
|
+
handlers.push(handler);
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
webviewResolved = Promise.resolve(provider.resolveWebviewView(view)).then(() => {
|
|
369
|
+
renderedHtml = view.webview.html;
|
|
370
|
+
});
|
|
371
|
+
return { dispose() {} };
|
|
372
|
+
},
|
|
373
|
+
showInformationMessage(message: string) {
|
|
374
|
+
messages.push(message);
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
await extensionHost.activate({ subscriptions: [] }, fakeVscode, { runtimeFile: fixture.runtimeFile });
|
|
380
|
+
await webviewResolved;
|
|
381
|
+
|
|
382
|
+
assert.equal(handlers.length, 1);
|
|
383
|
+
assert.match(renderedHtml, /本地服务[\s\S]*running/);
|
|
384
|
+
assert.match(renderedHtml, /登录账号[\s\S]*dev@example\.com/);
|
|
385
|
+
assert.match(renderedHtml, /积分余额[\s\S]*1000/);
|
|
386
|
+
assert.match(renderedHtml, /基础组/);
|
|
387
|
+
assert.doesNotMatch(renderedHtml, /平台状态/);
|
|
388
|
+
assert.doesNotMatch(renderedHtml, /当前设备/);
|
|
389
|
+
assert.doesNotMatch(renderedHtml, /号池接管/);
|
|
390
|
+
assert.doesNotMatch(renderedHtml, /当前商品[\s\S]*prod_basic/);
|
|
391
|
+
assert.match(renderedHtml, /data-command="takeover\/enable"/);
|
|
392
|
+
await handlers[0]({ command: 'takeover/enable' });
|
|
393
|
+
await handlers[0]({ command: 'takeover/disable' });
|
|
394
|
+
await handlers[0]({ command: 'platform/select-product', productId: 'prod_basic' });
|
|
395
|
+
await handlers[0]({
|
|
396
|
+
command: 'platform/login',
|
|
397
|
+
email: 'dev@example.com',
|
|
398
|
+
password: 'correct-password',
|
|
399
|
+
});
|
|
400
|
+
await handlers[0]({ command: 'platform/refresh' });
|
|
401
|
+
await handlers[0]({ command: 'platform/logout' });
|
|
402
|
+
await handlers[0]({ command: 'cursorPool.openConsole' });
|
|
403
|
+
await registeredCommands['cursorPool.openClient']();
|
|
404
|
+
await registeredCommands['cursorPool.reportStatus']();
|
|
405
|
+
|
|
406
|
+
assert.deepEqual(requests, [
|
|
407
|
+
'GET /health',
|
|
408
|
+
'POST /extension/status',
|
|
409
|
+
'GET /platform/status',
|
|
410
|
+
'GET /platform/catalog',
|
|
411
|
+
'POST /mode/start',
|
|
412
|
+
'GET /health',
|
|
413
|
+
'POST /extension/status',
|
|
414
|
+
'GET /platform/status',
|
|
415
|
+
'GET /platform/catalog',
|
|
416
|
+
'POST /mode/stop',
|
|
417
|
+
'GET /health',
|
|
418
|
+
'POST /extension/status',
|
|
419
|
+
'GET /platform/status',
|
|
420
|
+
'GET /platform/catalog',
|
|
421
|
+
'POST /platform/selection',
|
|
422
|
+
'GET /health',
|
|
423
|
+
'POST /extension/status',
|
|
424
|
+
'GET /platform/status',
|
|
425
|
+
'GET /platform/catalog',
|
|
426
|
+
'POST /platform/login',
|
|
427
|
+
'GET /health',
|
|
428
|
+
'POST /extension/status',
|
|
429
|
+
'GET /platform/status',
|
|
430
|
+
'GET /platform/catalog',
|
|
431
|
+
'GET /health',
|
|
432
|
+
'POST /extension/status',
|
|
433
|
+
'GET /platform/status',
|
|
434
|
+
'GET /platform/catalog',
|
|
435
|
+
'POST /platform/logout',
|
|
436
|
+
'GET /health',
|
|
437
|
+
'POST /extension/status',
|
|
438
|
+
'GET /platform/status',
|
|
439
|
+
'GET /platform/catalog',
|
|
440
|
+
'GET /health',
|
|
441
|
+
'POST /extension/status',
|
|
442
|
+
]);
|
|
443
|
+
assert.deepEqual(executedCommands, [
|
|
444
|
+
'workbench.action.webview.openDeveloperTools',
|
|
445
|
+
'workbench.view.extension.cursorPoolClient',
|
|
446
|
+
]);
|
|
447
|
+
assert.deepEqual(messages, [
|
|
448
|
+
'Cursor Pool service: running',
|
|
449
|
+
]);
|
|
450
|
+
} finally {
|
|
451
|
+
await close(server);
|
|
452
|
+
await fixture.cleanup();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('status is read from runtime file', async () => {
|
|
457
|
+
const fixture = await createRuntimeFixture();
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
await writeFile(
|
|
461
|
+
fixture.runtimeFile,
|
|
462
|
+
JSON.stringify({ host: '127.0.0.1', port: 43999, runtimeId: 'extension-test' }),
|
|
463
|
+
'utf8',
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
assert.deepEqual(await readRuntimeInfo({ runtimeFile: fixture.runtimeFile }), {
|
|
467
|
+
host: '127.0.0.1',
|
|
468
|
+
port: 43999,
|
|
469
|
+
runtimeId: 'extension-test',
|
|
470
|
+
});
|
|
471
|
+
} finally {
|
|
472
|
+
await fixture.cleanup();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('panel only displays service and user-facing account status', () => {
|
|
477
|
+
const viewModel = createPanelViewModel({
|
|
478
|
+
patch: 'missing',
|
|
479
|
+
service: 'stopped',
|
|
480
|
+
compat: 'unsupported',
|
|
481
|
+
mode: 'unknown',
|
|
482
|
+
});
|
|
483
|
+
const html = buildPanelHtml(viewModel);
|
|
484
|
+
|
|
485
|
+
assert.deepEqual(viewModel.rows, [
|
|
486
|
+
{ label: 'patch', value: 'missing' },
|
|
487
|
+
{ label: 'service', value: 'stopped' },
|
|
488
|
+
{ label: 'compat', value: 'unsupported' },
|
|
489
|
+
{ label: 'mode', value: 'unknown' },
|
|
490
|
+
{ label: 'platform', value: 'logged-out' },
|
|
491
|
+
]);
|
|
492
|
+
assert.match(html, /本地服务[\s\S]*stopped/);
|
|
493
|
+
assert.doesNotMatch(html, /补丁状态/);
|
|
494
|
+
assert.doesNotMatch(html, /兼容状态/);
|
|
495
|
+
assert.doesNotMatch(html, /号池接管/);
|
|
496
|
+
assert.doesNotMatch(html, /平台状态/);
|
|
497
|
+
assert.doesNotMatch(html, /data-command="mode\/start"/);
|
|
498
|
+
assert.doesNotMatch(html, /data-command="mode\/stop"/);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('panel frames Cursor Pool as platform mode entry instead of full web console', () => {
|
|
502
|
+
const html = buildPanelHtml(createPanelViewModel({
|
|
503
|
+
patch: 'unknown',
|
|
504
|
+
service: 'running',
|
|
505
|
+
compat: 'unknown',
|
|
506
|
+
mode: 'inactive',
|
|
507
|
+
platform: 'logged-out',
|
|
508
|
+
}));
|
|
509
|
+
|
|
510
|
+
assert.equal(html.includes('Cursor Pool 号池客户端'), true);
|
|
511
|
+
assert.equal(html.includes('本地服务'), true);
|
|
512
|
+
assert.equal(html.includes('号池登录'), true);
|
|
513
|
+
assert.equal(html.includes('账号/邮箱'), true);
|
|
514
|
+
assert.equal(html.includes('登录码'), false);
|
|
515
|
+
assert.equal(html.includes('启动平台模式'), false);
|
|
516
|
+
assert.equal(html.includes('充值'), false);
|
|
517
|
+
assert.equal(html.includes('使用记录'), false);
|
|
518
|
+
assert.equal(html.includes('账本'), false);
|
|
519
|
+
assert.equal(html.includes('管理后台'), false);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('panel displays platform logged-in user and device status', () => {
|
|
523
|
+
const viewModel = createPanelViewModel({
|
|
524
|
+
patch: 'applied',
|
|
525
|
+
service: 'running',
|
|
526
|
+
compat: 'supported',
|
|
527
|
+
mode: 'active prod_basic',
|
|
528
|
+
platform: 'logged-in',
|
|
529
|
+
user: 'user@example.test',
|
|
530
|
+
device: 'active device-1',
|
|
531
|
+
catalog: 'logged-in',
|
|
532
|
+
credits: '1000',
|
|
533
|
+
selectedProductId: 'prod_basic',
|
|
534
|
+
products: [
|
|
535
|
+
{
|
|
536
|
+
id: 'prod_basic',
|
|
537
|
+
name: '基础组',
|
|
538
|
+
description: '开发态基础商品,用于验证扩展展示。',
|
|
539
|
+
status: 'available',
|
|
540
|
+
minCredits: 100,
|
|
541
|
+
usageLabel: '按请求消耗,倍率 1x',
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
});
|
|
545
|
+
const html = buildPanelHtml(viewModel);
|
|
546
|
+
|
|
547
|
+
assert.deepEqual(viewModel.rows, [
|
|
548
|
+
{ label: 'patch', value: 'applied' },
|
|
549
|
+
{ label: 'service', value: 'running' },
|
|
550
|
+
{ label: 'compat', value: 'supported' },
|
|
551
|
+
{ label: 'mode', value: 'active prod_basic' },
|
|
552
|
+
{ label: 'platform', value: 'logged-in' },
|
|
553
|
+
{ label: 'user', value: 'user@example.test' },
|
|
554
|
+
{ label: 'device', value: 'active device-1' },
|
|
555
|
+
{ label: 'catalog', value: 'logged-in' },
|
|
556
|
+
{ label: 'credits', value: '1000' },
|
|
557
|
+
{ label: 'current product', value: 'prod_basic' },
|
|
558
|
+
]);
|
|
559
|
+
assert.match(html, /登录账号[\s\S]*user@example\.test/);
|
|
560
|
+
assert.match(html, /积分余额[\s\S]*1000/);
|
|
561
|
+
assert.match(html, /当前使用[\s\S]*基础组/);
|
|
562
|
+
assert.match(html, /可用商品/);
|
|
563
|
+
assert.match(html, /class="product-list"/);
|
|
564
|
+
assert.match(html, /class="product-row is-selected"/);
|
|
565
|
+
assert.doesNotMatch(html, /平台状态/);
|
|
566
|
+
assert.doesNotMatch(html, /号池接管/);
|
|
567
|
+
assert.doesNotMatch(html, /当前设备/);
|
|
568
|
+
assert.doesNotMatch(html, /已选择商品/);
|
|
569
|
+
assert.doesNotMatch(html, /当前商品[\s\S]*prod_basic/);
|
|
570
|
+
assert.match(html, /基础组/);
|
|
571
|
+
assert.match(html, /available/);
|
|
572
|
+
assert.match(html, /准入 100/);
|
|
573
|
+
assert.match(html, /按请求消耗,倍率 1x/);
|
|
574
|
+
assert.doesNotMatch(html, /<p>开发态基础商品,用于验证扩展展示。<\/p>/);
|
|
575
|
+
assert.doesNotMatch(html, /data-command="product\//);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('panel keeps five products in a compact scrollable product list', () => {
|
|
579
|
+
const products = Array.from({ length: 5 }, (_, index) => ({
|
|
580
|
+
id: `prod_${index + 1}`,
|
|
581
|
+
name: `商品 ${index + 1}`,
|
|
582
|
+
description: `这是第 ${index + 1} 个商品的说明,不能把面板撑成长卡片。`,
|
|
583
|
+
status: 'available' as const,
|
|
584
|
+
minCredits: (index + 1) * 100,
|
|
585
|
+
usageLabel: `倍率 ${index + 1}x`,
|
|
586
|
+
}));
|
|
587
|
+
const html = buildPanelHtml(createPanelViewModel({
|
|
588
|
+
patch: 'applied',
|
|
589
|
+
service: 'running',
|
|
590
|
+
compat: 'supported',
|
|
591
|
+
mode: 'active prod_3',
|
|
592
|
+
platform: 'logged-in',
|
|
593
|
+
catalog: 'logged-in',
|
|
594
|
+
credits: '1000',
|
|
595
|
+
selectedProductId: 'prod_3',
|
|
596
|
+
products,
|
|
597
|
+
}));
|
|
598
|
+
|
|
599
|
+
assert.match(html, /class="product-list"/);
|
|
600
|
+
assert.match(html, /\.product-list \{[\s\S]*max-height: 260px/);
|
|
601
|
+
assert.equal((html.match(/class="product-row/g) ?? []).length, 5);
|
|
602
|
+
assert.equal((html.match(/class="product-description"/g) ?? []).length, 5);
|
|
603
|
+
assert.doesNotMatch(html, /<p>这是第 1 个商品的说明/);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test('panel HTML only renders product selection controls for available products', () => {
|
|
607
|
+
const html = buildPanelHtml(
|
|
608
|
+
createPanelViewModel({
|
|
609
|
+
patch: 'applied',
|
|
610
|
+
service: 'running',
|
|
611
|
+
compat: 'supported',
|
|
612
|
+
mode: 'stopped',
|
|
613
|
+
platform: 'logged-in',
|
|
614
|
+
catalog: 'logged-in',
|
|
615
|
+
products: [
|
|
616
|
+
{
|
|
617
|
+
id: 'prod_basic',
|
|
618
|
+
name: '基础组',
|
|
619
|
+
description: '开发态基础商品,用于验证扩展展示。',
|
|
620
|
+
status: 'available',
|
|
621
|
+
minCredits: 100,
|
|
622
|
+
usageLabel: '按请求消耗,倍率 1x',
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
id: 'prod_pro',
|
|
626
|
+
name: '高级组',
|
|
627
|
+
description: '开发态高级商品,用于验证多商品展示。',
|
|
628
|
+
status: 'unavailable',
|
|
629
|
+
minCredits: 500,
|
|
630
|
+
usageLabel: '按请求消耗,倍率 2x',
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
}),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
assert.match(html, /data-product-id="prod_basic"/);
|
|
637
|
+
assert.doesNotMatch(html, /data-product-id="prod_pro"/);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('panel keeps active platform mode out of rendered status list', () => {
|
|
641
|
+
const html = buildPanelHtml(createPanelViewModel({
|
|
642
|
+
patch: 'applied',
|
|
643
|
+
service: 'running',
|
|
644
|
+
compat: 'supported',
|
|
645
|
+
mode: 'active prod_basic',
|
|
646
|
+
platform: 'logged-in',
|
|
647
|
+
user: 'user@example.test',
|
|
648
|
+
device: 'active device-1',
|
|
649
|
+
}));
|
|
650
|
+
|
|
651
|
+
assert.doesNotMatch(html, /号池接管/);
|
|
652
|
+
assert.doesNotMatch(html, /active prod_basic/);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test('sidebar view model shows inactive mode release reason', () => {
|
|
656
|
+
const viewModel = createPanelViewModel({
|
|
657
|
+
patch: 'applied',
|
|
658
|
+
service: 'running',
|
|
659
|
+
compat: 'supported',
|
|
660
|
+
mode: 'inactive insufficient-credits',
|
|
661
|
+
platform: 'logged-in',
|
|
662
|
+
});
|
|
663
|
+
const html = buildPanelHtml(viewModel);
|
|
664
|
+
|
|
665
|
+
assert.doesNotMatch(html, /号池接管/);
|
|
666
|
+
assert.doesNotMatch(html, /inactive insufficient-credits/);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test('panel HTML renders platform login form when logged out', () => {
|
|
670
|
+
const html = buildPanelHtml(
|
|
671
|
+
createPanelViewModel({
|
|
672
|
+
patch: 'applied',
|
|
673
|
+
service: 'running',
|
|
674
|
+
compat: 'supported',
|
|
675
|
+
mode: 'stopped',
|
|
676
|
+
platform: 'logged-out',
|
|
677
|
+
}),
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
assert.doesNotMatch(html, /name="apiBaseUrl"/);
|
|
681
|
+
assert.doesNotMatch(html, /登录码/);
|
|
682
|
+
assert.match(html, /name="email"/);
|
|
683
|
+
assert.match(html, /name="password"/);
|
|
684
|
+
assert.match(html, /data-command="platform\/login"/);
|
|
685
|
+
assert.match(html, /data-command="platform\/refresh"/);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('panel HTML renders logout for logged-in and invalid-token states', () => {
|
|
689
|
+
const loggedIn = buildPanelHtml(
|
|
690
|
+
createPanelViewModel({
|
|
691
|
+
patch: 'applied',
|
|
692
|
+
service: 'running',
|
|
693
|
+
compat: 'supported',
|
|
694
|
+
mode: 'started',
|
|
695
|
+
platform: 'logged-in',
|
|
696
|
+
user: 'dev@example.com',
|
|
697
|
+
device: 'active dev_1',
|
|
698
|
+
heartbeat: '2026-05-30T00:00:00Z',
|
|
699
|
+
}),
|
|
700
|
+
);
|
|
701
|
+
const invalidToken = buildPanelHtml(
|
|
702
|
+
createPanelViewModel({
|
|
703
|
+
patch: 'applied',
|
|
704
|
+
service: 'running',
|
|
705
|
+
compat: 'supported',
|
|
706
|
+
mode: 'started',
|
|
707
|
+
platform: 'invalid-token',
|
|
708
|
+
user: 'dev@example.com',
|
|
709
|
+
device: 'active dev_1',
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
assert.match(loggedIn, /data-command="platform\/logout"/);
|
|
714
|
+
assert.match(loggedIn, /data-command="platform\/refresh"/);
|
|
715
|
+
assert.doesNotMatch(invalidToken, /平台状态/);
|
|
716
|
+
assert.match(invalidToken, /data-command="platform\/logout"/);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test('panel HTML wires data-command buttons to VS Code WebView messages', () => {
|
|
720
|
+
const html = buildPanelHtml(
|
|
721
|
+
createPanelViewModel({
|
|
722
|
+
patch: 'applied',
|
|
723
|
+
service: 'running',
|
|
724
|
+
compat: 'supported',
|
|
725
|
+
mode: 'stopped',
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
assert.match(html, /acquireVsCodeApi/);
|
|
730
|
+
assert.match(html, /postMessage/);
|
|
731
|
+
assert.match(html, /dataset\.command/);
|
|
732
|
+
assert.doesNotMatch(html, /data-command="mode\/start"/);
|
|
733
|
+
assert.doesNotMatch(html, /data-command="mode\/stop"/);
|
|
734
|
+
assert.match(html, /data-command="platform\/login"/);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('status panel handles platform login logout and refresh messages', async () => {
|
|
738
|
+
const statuses = [
|
|
739
|
+
{ state: 'logged-out' as const },
|
|
740
|
+
{
|
|
741
|
+
state: 'logged-in' as const,
|
|
742
|
+
user: { id: 'usr_1', email: 'dev@example.com' },
|
|
743
|
+
device: { id: 'dev_1', status: 'active', lastHeartbeatAt: '2026-05-30T00:00:00Z' },
|
|
744
|
+
},
|
|
745
|
+
{ state: 'logged-out' as const },
|
|
746
|
+
];
|
|
747
|
+
let statusIndex = 0;
|
|
748
|
+
const calls: string[] = [];
|
|
749
|
+
const executedCommands: string[] = [];
|
|
750
|
+
const handlers: ((
|
|
751
|
+
message: { command: string; email?: string; password?: string; productId?: string },
|
|
752
|
+
) => unknown)[] = [];
|
|
753
|
+
const rendered: string[] = [];
|
|
754
|
+
const registered: {
|
|
755
|
+
provider: {
|
|
756
|
+
resolveWebviewView: (view: {
|
|
757
|
+
webview: {
|
|
758
|
+
html: string;
|
|
759
|
+
options?: Record<string, unknown>;
|
|
760
|
+
onDidReceiveMessage: (
|
|
761
|
+
handler: (message: { command: string; email?: string; password?: string; productId?: string }) => unknown,
|
|
762
|
+
) => void;
|
|
763
|
+
};
|
|
764
|
+
}) => unknown;
|
|
765
|
+
};
|
|
766
|
+
}[] = [];
|
|
767
|
+
const fakeVscode = {
|
|
768
|
+
window: {
|
|
769
|
+
registerWebviewViewProvider(_id: string, provider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
770
|
+
registered.push({ provider: provider as (typeof registered)[number]['provider'] });
|
|
771
|
+
return { dispose() {} };
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
commands: {
|
|
775
|
+
executeCommand(command: string) {
|
|
776
|
+
executedCommands.push(command);
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
registerStatusPanel({ subscriptions: [] }, fakeVscode, {
|
|
782
|
+
runtimeFile: 'runtime.json',
|
|
783
|
+
getServiceStatus: async () => ({ service: 'running' as const, runtime: null }),
|
|
784
|
+
getPlatformStatus: async () => statuses[Math.min(statusIndex, statuses.length - 1)],
|
|
785
|
+
loginPlatform: async (input) => {
|
|
786
|
+
calls.push(`login:${input.email}:${input.password}`);
|
|
787
|
+
statusIndex = 1;
|
|
788
|
+
return statuses[1];
|
|
789
|
+
},
|
|
790
|
+
logoutPlatform: async () => {
|
|
791
|
+
calls.push('logout');
|
|
792
|
+
statusIndex = 2;
|
|
793
|
+
return statuses[2];
|
|
794
|
+
},
|
|
795
|
+
selectPlatformProduct: async (input) => {
|
|
796
|
+
calls.push(`select:${input.productId}`);
|
|
797
|
+
return { state: 'selected' as const, selectedProductId: input.productId };
|
|
798
|
+
},
|
|
799
|
+
startMode: async () => {
|
|
800
|
+
calls.push('start');
|
|
801
|
+
return { state: 'active' as const, productId: 'prod_basic', startedAt: '2026-05-31T00:00:00.000Z' };
|
|
802
|
+
},
|
|
803
|
+
stopMode: async () => {
|
|
804
|
+
calls.push('stop');
|
|
805
|
+
return { state: 'inactive' as const };
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
const view = {
|
|
810
|
+
webview: {
|
|
811
|
+
html: '',
|
|
812
|
+
onDidReceiveMessage(
|
|
813
|
+
handler: (message: { command: string; email?: string; password?: string; productId?: string }) => unknown,
|
|
814
|
+
) {
|
|
815
|
+
handlers.push(handler);
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
await registered[0].provider.resolveWebviewView(view);
|
|
821
|
+
rendered.push(view.webview.html);
|
|
822
|
+
await handlers[0]({ command: 'takeover/enable' });
|
|
823
|
+
rendered.push(view.webview.html);
|
|
824
|
+
await handlers[0]({ command: 'takeover/disable' });
|
|
825
|
+
rendered.push(view.webview.html);
|
|
826
|
+
await handlers[0]({ command: 'platform/login', email: 'dev@example.com', password: 'correct-password' });
|
|
827
|
+
rendered.push(view.webview.html);
|
|
828
|
+
await handlers[0]({ command: 'platform/refresh' });
|
|
829
|
+
rendered.push(view.webview.html);
|
|
830
|
+
await handlers[0]({ command: 'platform/select-product', productId: 'prod_basic' });
|
|
831
|
+
rendered.push(view.webview.html);
|
|
832
|
+
await handlers[0]({ command: 'platform/logout' });
|
|
833
|
+
rendered.push(view.webview.html);
|
|
834
|
+
|
|
835
|
+
assert.doesNotMatch(rendered[0], /平台状态/);
|
|
836
|
+
assert.match(rendered[1], /已开启平台接管/);
|
|
837
|
+
assert.match(rendered[2], /已切回官方模式/);
|
|
838
|
+
assert.doesNotMatch(rendered[3], /平台状态/);
|
|
839
|
+
assert.match(rendered[3], /登录账号[\s\S]*dev@example\.com/);
|
|
840
|
+
assert.doesNotMatch(rendered[3], /当前设备/);
|
|
841
|
+
assert.match(rendered[4], /平台状态已刷新/);
|
|
842
|
+
assert.equal(calls.includes('select:prod_basic'), true);
|
|
843
|
+
assert.doesNotMatch(rendered[5], /已选择商品/);
|
|
844
|
+
assert.doesNotMatch(rendered[6], /平台状态/);
|
|
845
|
+
assert.deepEqual(calls, ['start', 'stop', 'login:dev@example.com:correct-password', 'select:prod_basic', 'logout']);
|
|
846
|
+
assert.deepEqual(executedCommands, ['workbench.action.reloadWindow', 'workbench.action.reloadWindow']);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test('status panel renders platform mode start errors', async () => {
|
|
850
|
+
const handlers: ((message: { command?: string }) => unknown)[] = [];
|
|
851
|
+
const view = {
|
|
852
|
+
webview: {
|
|
853
|
+
html: '',
|
|
854
|
+
onDidReceiveMessage(handler: (message: { command?: string }) => unknown) {
|
|
855
|
+
handlers.push(handler);
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const provider = registerStatusPanel({}, {
|
|
861
|
+
window: {
|
|
862
|
+
registerWebviewViewProvider(_id: string, registeredProvider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
863
|
+
return registeredProvider;
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
}, {
|
|
867
|
+
status: {
|
|
868
|
+
patch: 'applied',
|
|
869
|
+
service: 'running',
|
|
870
|
+
compat: 'supported',
|
|
871
|
+
mode: 'inactive',
|
|
872
|
+
platform: 'logged-in',
|
|
873
|
+
user: 'dev@example.com',
|
|
874
|
+
},
|
|
875
|
+
startMode: async () => ({ state: 'product-not-selected' as const }),
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
await provider.resolveWebviewView(view);
|
|
879
|
+
await handlers[0]({ command: 'takeover/enable' });
|
|
880
|
+
|
|
881
|
+
assert.match(view.webview.html, /开启平台接管前请先选择商品/);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test('status panel shows login validation errors with static status', async () => {
|
|
885
|
+
let loginCalls = 0;
|
|
886
|
+
const handlers: ((message: { command: string; email?: string; password?: string }) => unknown)[] = [];
|
|
887
|
+
const registered: {
|
|
888
|
+
provider: {
|
|
889
|
+
resolveWebviewView: (view: {
|
|
890
|
+
webview: {
|
|
891
|
+
html: string;
|
|
892
|
+
onDidReceiveMessage: (
|
|
893
|
+
handler: (message: { command: string; email?: string; password?: string }) => unknown,
|
|
894
|
+
) => void;
|
|
895
|
+
};
|
|
896
|
+
}) => unknown;
|
|
897
|
+
};
|
|
898
|
+
}[] = [];
|
|
899
|
+
const fakeVscode = {
|
|
900
|
+
window: {
|
|
901
|
+
registerWebviewViewProvider(_id: string, provider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
902
|
+
registered.push({ provider: provider as (typeof registered)[number]['provider'] });
|
|
903
|
+
return { dispose() {} };
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
registerStatusPanel({ subscriptions: [] }, fakeVscode, {
|
|
909
|
+
status: {
|
|
910
|
+
patch: 'applied',
|
|
911
|
+
service: 'running',
|
|
912
|
+
compat: 'supported',
|
|
913
|
+
mode: 'stopped',
|
|
914
|
+
platform: 'logged-out',
|
|
915
|
+
},
|
|
916
|
+
loginPlatform: async () => {
|
|
917
|
+
loginCalls += 1;
|
|
918
|
+
return {
|
|
919
|
+
state: 'logged-in',
|
|
920
|
+
user: { id: 'usr_1', email: 'dev@example.com' },
|
|
921
|
+
device: { id: 'dev_1', status: 'active', lastHeartbeatAt: '2026-05-30T00:00:00Z' },
|
|
922
|
+
};
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
const view = {
|
|
927
|
+
webview: {
|
|
928
|
+
html: '',
|
|
929
|
+
onDidReceiveMessage(
|
|
930
|
+
handler: (message: { command: string; email?: string; password?: string }) => unknown,
|
|
931
|
+
) {
|
|
932
|
+
handlers.push(handler);
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
await registered[0].provider.resolveWebviewView(view);
|
|
938
|
+
await handlers[0]({ command: 'platform/login', password: 'correct-password' });
|
|
939
|
+
|
|
940
|
+
assert.match(view.webview.html, /请输入账号\/邮箱/);
|
|
941
|
+
await handlers[0]({ command: 'platform/login', email: 'dev@example.com' });
|
|
942
|
+
assert.match(view.webview.html, /请输入密码/);
|
|
943
|
+
assert.equal(loginCalls, 0);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test('status panel clears stale login errors when refresh sees logged-in status', async () => {
|
|
947
|
+
let status: { state: 'logged-out' } | {
|
|
948
|
+
state: 'logged-in';
|
|
949
|
+
user: { id: string; email: string };
|
|
950
|
+
device: { id: string; status: string; lastHeartbeatAt: string };
|
|
951
|
+
} = { state: 'logged-out' };
|
|
952
|
+
const handlers: ((message: { command: string; email?: string; password?: string }) => unknown)[] = [];
|
|
953
|
+
const registered: {
|
|
954
|
+
provider: {
|
|
955
|
+
resolveWebviewView: (view: {
|
|
956
|
+
webview: {
|
|
957
|
+
html: string;
|
|
958
|
+
onDidReceiveMessage: (
|
|
959
|
+
handler: (message: { command: string; email?: string; password?: string }) => unknown,
|
|
960
|
+
) => void;
|
|
961
|
+
};
|
|
962
|
+
}) => unknown;
|
|
963
|
+
};
|
|
964
|
+
}[] = [];
|
|
965
|
+
const fakeVscode = {
|
|
966
|
+
window: {
|
|
967
|
+
registerWebviewViewProvider(_id: string, provider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
968
|
+
registered.push({ provider: provider as (typeof registered)[number]['provider'] });
|
|
969
|
+
return { dispose() {} };
|
|
970
|
+
},
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
registerStatusPanel({ subscriptions: [] }, fakeVscode, {
|
|
975
|
+
runtimeFile: '/tmp/runtime.json',
|
|
976
|
+
getServiceStatus: async () => ({ service: 'running' as const, runtime: null }),
|
|
977
|
+
getPlatformStatus: async () => status,
|
|
978
|
+
getPlatformCatalog: async () => ({
|
|
979
|
+
state: 'logged-in' as const,
|
|
980
|
+
account: { credits: 1000 },
|
|
981
|
+
mode: { state: 'inactive' as const },
|
|
982
|
+
products: [],
|
|
983
|
+
}),
|
|
984
|
+
loginPlatform: async () => {
|
|
985
|
+
throw new Error('登录失败:账号或密码不正确');
|
|
986
|
+
},
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const view = {
|
|
990
|
+
webview: {
|
|
991
|
+
html: '',
|
|
992
|
+
onDidReceiveMessage(
|
|
993
|
+
handler: (message: { command: string; email?: string; password?: string }) => unknown,
|
|
994
|
+
) {
|
|
995
|
+
handlers.push(handler);
|
|
996
|
+
},
|
|
997
|
+
},
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
await registered[0].provider.resolveWebviewView(view);
|
|
1001
|
+
await handlers[0]({ command: 'platform/login', email: 'dev@example.com', password: 'wrong-password' });
|
|
1002
|
+
assert.match(view.webview.html, /登录失败:账号或密码不正确/);
|
|
1003
|
+
|
|
1004
|
+
status = {
|
|
1005
|
+
state: 'logged-in',
|
|
1006
|
+
user: { id: 'usr_1', email: 'dev@example.com' },
|
|
1007
|
+
device: { id: 'dev_1', status: 'active', lastHeartbeatAt: '2026-05-30T00:00:00Z' },
|
|
1008
|
+
};
|
|
1009
|
+
await handlers[0]({ command: 'platform/refresh' });
|
|
1010
|
+
|
|
1011
|
+
assert.match(view.webview.html, /登录账号[\s\S]*dev@example\.com/);
|
|
1012
|
+
assert.doesNotMatch(view.webview.html, /登录失败:账号或密码不正确/);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
test('status panel suppresses failed login feedback when platform is already logged in', async () => {
|
|
1016
|
+
const handlers: ((message: { command: string; email?: string; password?: string }) => unknown)[] = [];
|
|
1017
|
+
const registered: {
|
|
1018
|
+
provider: {
|
|
1019
|
+
resolveWebviewView: (view: {
|
|
1020
|
+
webview: {
|
|
1021
|
+
html: string;
|
|
1022
|
+
onDidReceiveMessage: (
|
|
1023
|
+
handler: (message: { command: string; email?: string; password?: string }) => unknown,
|
|
1024
|
+
) => void;
|
|
1025
|
+
};
|
|
1026
|
+
}) => unknown;
|
|
1027
|
+
};
|
|
1028
|
+
}[] = [];
|
|
1029
|
+
const fakeVscode = {
|
|
1030
|
+
window: {
|
|
1031
|
+
registerWebviewViewProvider(_id: string, provider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
1032
|
+
registered.push({ provider: provider as (typeof registered)[number]['provider'] });
|
|
1033
|
+
return { dispose() {} };
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
registerStatusPanel({ subscriptions: [] }, fakeVscode, {
|
|
1039
|
+
runtimeFile: '/tmp/runtime.json',
|
|
1040
|
+
getServiceStatus: async () => ({ service: 'running' as const, runtime: null }),
|
|
1041
|
+
getPlatformStatus: async () => ({
|
|
1042
|
+
state: 'logged-in' as const,
|
|
1043
|
+
user: { id: 'usr_1', email: 'dev@example.com' },
|
|
1044
|
+
device: { id: 'dev_1', status: 'active', lastHeartbeatAt: '2026-05-30T00:00:00Z' },
|
|
1045
|
+
}),
|
|
1046
|
+
getPlatformCatalog: async () => ({
|
|
1047
|
+
state: 'logged-in' as const,
|
|
1048
|
+
account: { credits: 1000 },
|
|
1049
|
+
mode: { state: 'inactive' as const },
|
|
1050
|
+
products: [],
|
|
1051
|
+
}),
|
|
1052
|
+
loginPlatform: async () => {
|
|
1053
|
+
throw new Error('登录失败:账号或密码不正确');
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
const view = {
|
|
1058
|
+
webview: {
|
|
1059
|
+
html: '',
|
|
1060
|
+
onDidReceiveMessage(
|
|
1061
|
+
handler: (message: { command: string; email?: string; password?: string }) => unknown,
|
|
1062
|
+
) {
|
|
1063
|
+
handlers.push(handler);
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
await registered[0].provider.resolveWebviewView(view);
|
|
1069
|
+
await handlers[0]({ command: 'platform/login', email: 'dev@example.com', password: 'wrong-password' });
|
|
1070
|
+
|
|
1071
|
+
assert.match(view.webview.html, /登录账号[\s\S]*dev@example\.com/);
|
|
1072
|
+
assert.doesNotMatch(view.webview.html, /登录失败:账号或密码不正确/);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
test('status panel starts local service before login when service is stopped', async () => {
|
|
1076
|
+
const calls: string[] = [];
|
|
1077
|
+
const handlers: ((message: { command: string; email?: string; password?: string }) => unknown)[] = [];
|
|
1078
|
+
const registered: {
|
|
1079
|
+
provider: {
|
|
1080
|
+
resolveWebviewView: (view: {
|
|
1081
|
+
webview: {
|
|
1082
|
+
html: string;
|
|
1083
|
+
onDidReceiveMessage: (
|
|
1084
|
+
handler: (message: { command: string; email?: string; password?: string }) => unknown,
|
|
1085
|
+
) => void;
|
|
1086
|
+
};
|
|
1087
|
+
}) => unknown;
|
|
1088
|
+
};
|
|
1089
|
+
}[] = [];
|
|
1090
|
+
const fakeVscode = {
|
|
1091
|
+
window: {
|
|
1092
|
+
registerWebviewViewProvider(_id: string, provider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
1093
|
+
registered.push({ provider: provider as (typeof registered)[number]['provider'] });
|
|
1094
|
+
return { dispose() {} };
|
|
1095
|
+
},
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
registerStatusPanel({ subscriptions: [] }, fakeVscode, {
|
|
1100
|
+
runtimeFile: '/tmp/runtime.json',
|
|
1101
|
+
getServiceStatus: async () => ({ service: 'stopped' as const, runtime: null }),
|
|
1102
|
+
getPlatformStatus: async () => ({ state: 'logged-out' as const }),
|
|
1103
|
+
getPlatformCatalog: async () => ({ state: 'logged-out' as const, products: [] }),
|
|
1104
|
+
ensureService: async (runtimeFile) => {
|
|
1105
|
+
calls.push(`ensure:${runtimeFile}`);
|
|
1106
|
+
},
|
|
1107
|
+
loginPlatform: async (input) => {
|
|
1108
|
+
calls.push(`login:${input.email}:${input.password}`);
|
|
1109
|
+
return {
|
|
1110
|
+
state: 'logged-in' as const,
|
|
1111
|
+
user: { id: 'usr_1', email: input.email },
|
|
1112
|
+
device: { id: 'dev_1', status: 'active', lastHeartbeatAt: '2026-05-30T00:00:00Z' },
|
|
1113
|
+
};
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
const view = {
|
|
1118
|
+
webview: {
|
|
1119
|
+
html: '',
|
|
1120
|
+
onDidReceiveMessage(
|
|
1121
|
+
handler: (message: { command: string; email?: string; password?: string }) => unknown,
|
|
1122
|
+
) {
|
|
1123
|
+
handlers.push(handler);
|
|
1124
|
+
},
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
await registered[0].provider.resolveWebviewView(view);
|
|
1129
|
+
await handlers[0]({ command: 'platform/login', email: 'dev@example.com', password: 'correct-password' });
|
|
1130
|
+
|
|
1131
|
+
assert.deepEqual(calls, ['ensure:/tmp/runtime.json', 'login:dev@example.com:correct-password']);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
test('status panel renders errors when mode start message handling fails', async () => {
|
|
1135
|
+
const handlers: ((message: { command: string }) => unknown)[] = [];
|
|
1136
|
+
const registered: {
|
|
1137
|
+
provider: {
|
|
1138
|
+
resolveWebviewView: (view: {
|
|
1139
|
+
webview: {
|
|
1140
|
+
html: string;
|
|
1141
|
+
onDidReceiveMessage: (handler: (message: { command: string }) => unknown) => void;
|
|
1142
|
+
};
|
|
1143
|
+
}) => unknown;
|
|
1144
|
+
};
|
|
1145
|
+
}[] = [];
|
|
1146
|
+
const fakeVscode = {
|
|
1147
|
+
window: {
|
|
1148
|
+
registerWebviewViewProvider(_id: string, provider: { resolveWebviewView: (view: unknown) => unknown }) {
|
|
1149
|
+
registered.push({ provider: provider as (typeof registered)[number]['provider'] });
|
|
1150
|
+
return { dispose() {} };
|
|
1151
|
+
},
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
registerStatusPanel({ subscriptions: [] }, fakeVscode, {
|
|
1156
|
+
status: {
|
|
1157
|
+
patch: 'applied',
|
|
1158
|
+
service: 'running',
|
|
1159
|
+
compat: 'supported',
|
|
1160
|
+
mode: 'stopped',
|
|
1161
|
+
platform: 'logged-out',
|
|
1162
|
+
},
|
|
1163
|
+
startMode: async () => {
|
|
1164
|
+
throw new Error('mode start failed');
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
const view = {
|
|
1169
|
+
webview: {
|
|
1170
|
+
html: '',
|
|
1171
|
+
onDidReceiveMessage(handler: (message: { command: string }) => unknown) {
|
|
1172
|
+
handlers.push(handler);
|
|
1173
|
+
},
|
|
1174
|
+
},
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
await registered[0].provider.resolveWebviewView(view);
|
|
1178
|
+
await assert.doesNotReject(async () => {
|
|
1179
|
+
await handlers[0]({ command: 'mode/start' });
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
assert.match(view.webview.html, /mode start failed/);
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test('extension activation registers console and report status command handlers', async () => {
|
|
1186
|
+
const subscriptions: unknown[] = [];
|
|
1187
|
+
const registeredCommands: { command: string; handler: () => unknown }[] = [];
|
|
1188
|
+
const executedCommands: string[] = [];
|
|
1189
|
+
const messages: string[] = [];
|
|
1190
|
+
const fakeVscode = {
|
|
1191
|
+
commands: {
|
|
1192
|
+
executeCommand(command: string) {
|
|
1193
|
+
executedCommands.push(command);
|
|
1194
|
+
},
|
|
1195
|
+
registerCommand(command: string, handler: () => unknown) {
|
|
1196
|
+
registeredCommands.push({ command, handler });
|
|
1197
|
+
return { dispose() {} };
|
|
1198
|
+
},
|
|
1199
|
+
},
|
|
1200
|
+
window: {
|
|
1201
|
+
showInformationMessage(message: string) {
|
|
1202
|
+
messages.push(message);
|
|
1203
|
+
},
|
|
1204
|
+
},
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
registerExtensionCommands({ subscriptions }, fakeVscode, {
|
|
1208
|
+
getServiceStatus: async () => ({ service: 'running' as const, runtime: null }),
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
assert.deepEqual(
|
|
1212
|
+
registeredCommands.map((registered) => registered.command),
|
|
1213
|
+
['cursorPool.openClient', 'cursorPool.openConsole', 'cursorPool.reportStatus'],
|
|
1214
|
+
);
|
|
1215
|
+
assert.equal(subscriptions.length, 3);
|
|
1216
|
+
|
|
1217
|
+
registeredCommands[0].handler();
|
|
1218
|
+
registeredCommands[1].handler();
|
|
1219
|
+
await registeredCommands[2].handler();
|
|
1220
|
+
|
|
1221
|
+
assert.deepEqual(executedCommands, ['workbench.view.extension.cursorPoolClient']);
|
|
1222
|
+
assert.deepEqual(messages, [
|
|
1223
|
+
'请在网页端查看充值、记录、设备和账号信息。',
|
|
1224
|
+
'Cursor Pool service: running',
|
|
1225
|
+
]);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
test('API reports local service status from runtime and posts extension status', async () => {
|
|
1229
|
+
const fixture = await createRuntimeFixture();
|
|
1230
|
+
const requests: { method?: string; url?: string; body: string }[] = [];
|
|
1231
|
+
const server = createServer((request, response) => {
|
|
1232
|
+
let body = '';
|
|
1233
|
+
request.setEncoding('utf8');
|
|
1234
|
+
request.on('data', (chunk) => {
|
|
1235
|
+
body += chunk;
|
|
1236
|
+
});
|
|
1237
|
+
request.on('end', () => {
|
|
1238
|
+
requests.push({ method: request.method, url: request.url, body });
|
|
1239
|
+
|
|
1240
|
+
if (request.method === 'GET' && request.url === '/health') {
|
|
1241
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1242
|
+
response.end(JSON.stringify({ ok: true, runtimeId: 'extension-runtime' }));
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (request.method === 'POST' && request.url === '/extension/status') {
|
|
1247
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1248
|
+
response.end(JSON.stringify({ ok: true, status: { connected: true } }));
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
response.writeHead(404, { 'content-type': 'application/json' });
|
|
1253
|
+
response.end(JSON.stringify({ ok: false }));
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
try {
|
|
1258
|
+
const port = await listen(server);
|
|
1259
|
+
await writeFile(
|
|
1260
|
+
fixture.runtimeFile,
|
|
1261
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1262
|
+
'utf8',
|
|
1263
|
+
);
|
|
1264
|
+
|
|
1265
|
+
const status = await getServiceStatus(fixture.runtimeFile);
|
|
1266
|
+
|
|
1267
|
+
assert.equal(status.service, 'running');
|
|
1268
|
+
assert.equal(status.runtime?.port, port);
|
|
1269
|
+
assert.equal(requests.some((request) => request.method === 'GET' && request.url === '/health'), true);
|
|
1270
|
+
assert.equal(
|
|
1271
|
+
requests.some((request) => request.method === 'POST' && request.url === '/extension/status'),
|
|
1272
|
+
true,
|
|
1273
|
+
);
|
|
1274
|
+
} finally {
|
|
1275
|
+
await close(server);
|
|
1276
|
+
await fixture.cleanup();
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
test('API recovers sidebar status when runtime file points to a stale service port', async () => {
|
|
1281
|
+
const fixture = await createRuntimeFixture();
|
|
1282
|
+
const requests: string[] = [];
|
|
1283
|
+
const server = createServer((request, response) => {
|
|
1284
|
+
requests.push(`${request.method} ${request.url}`);
|
|
1285
|
+
|
|
1286
|
+
if (request.method === 'GET' && request.url === '/health') {
|
|
1287
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1288
|
+
response.end(JSON.stringify({ ok: true, runtimeId: 'fallback-runtime' }));
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
if (request.method === 'POST' && request.url === '/extension/status') {
|
|
1293
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1294
|
+
response.end(JSON.stringify({ ok: true, status: { connected: true } }));
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if (request.method === 'GET' && request.url === '/platform/status') {
|
|
1299
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1300
|
+
response.end(JSON.stringify({
|
|
1301
|
+
state: 'logged-in',
|
|
1302
|
+
user: { id: 'user-1', email: 'user@example.test' },
|
|
1303
|
+
device: {
|
|
1304
|
+
id: 'device-1',
|
|
1305
|
+
status: 'active',
|
|
1306
|
+
lastHeartbeatAt: '2026-05-30T08:00:00.000Z',
|
|
1307
|
+
},
|
|
1308
|
+
mode: {
|
|
1309
|
+
state: 'active',
|
|
1310
|
+
productId: 'prod_basic',
|
|
1311
|
+
startedAt: '2026-05-31T00:00:00.000Z',
|
|
1312
|
+
},
|
|
1313
|
+
}));
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
response.writeHead(404, { 'content-type': 'application/json' });
|
|
1318
|
+
response.end(JSON.stringify({ ok: false }));
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
try {
|
|
1322
|
+
const port = await listen(server);
|
|
1323
|
+
await writeFile(
|
|
1324
|
+
fixture.runtimeFile,
|
|
1325
|
+
JSON.stringify({ host: '127.0.0.1', port: 1, runtimeId: 'stale-runtime' }),
|
|
1326
|
+
'utf8',
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
const service = await getServiceStatus(fixture.runtimeFile, { fallbackPort: port });
|
|
1330
|
+
const platform = await getPlatformStatus(fixture.runtimeFile, { fallbackPort: port });
|
|
1331
|
+
const healedRuntime = JSON.parse(await readFile(fixture.runtimeFile, 'utf8')) as {
|
|
1332
|
+
host: string;
|
|
1333
|
+
port: number;
|
|
1334
|
+
runtimeId: string;
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
assert.equal(service.service, 'running');
|
|
1338
|
+
assert.equal(service.runtime?.port, port);
|
|
1339
|
+
assert.equal(platform.state, 'logged-in');
|
|
1340
|
+
assert.equal(platform.state === 'logged-in' ? platform.user.email : '', 'user@example.test');
|
|
1341
|
+
assert.deepEqual(healedRuntime, {
|
|
1342
|
+
host: '127.0.0.1',
|
|
1343
|
+
port,
|
|
1344
|
+
runtimeId: 'fallback-runtime',
|
|
1345
|
+
});
|
|
1346
|
+
assert.deepEqual(requests, [
|
|
1347
|
+
'GET /health',
|
|
1348
|
+
'POST /extension/status',
|
|
1349
|
+
'GET /health',
|
|
1350
|
+
'GET /platform/status',
|
|
1351
|
+
]);
|
|
1352
|
+
} finally {
|
|
1353
|
+
await close(server);
|
|
1354
|
+
await fixture.cleanup();
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
test('API reads platform status from local runtime service', async () => {
|
|
1359
|
+
const fixture = await createRuntimeFixture();
|
|
1360
|
+
const requests: string[] = [];
|
|
1361
|
+
const server = createServer((request, response) => {
|
|
1362
|
+
requests.push(`${request.method} ${request.url}`);
|
|
1363
|
+
|
|
1364
|
+
if (request.method === 'GET' && request.url === '/platform/status') {
|
|
1365
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1366
|
+
response.end(
|
|
1367
|
+
JSON.stringify({
|
|
1368
|
+
state: 'logged-in',
|
|
1369
|
+
user: { id: 'user-1', email: 'user@example.test' },
|
|
1370
|
+
device: {
|
|
1371
|
+
id: 'device-1',
|
|
1372
|
+
status: 'active',
|
|
1373
|
+
lastHeartbeatAt: '2026-05-30T08:00:00.000Z',
|
|
1374
|
+
},
|
|
1375
|
+
mode: { state: 'inactive' },
|
|
1376
|
+
}),
|
|
1377
|
+
);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
response.writeHead(404, { 'content-type': 'application/json' });
|
|
1382
|
+
response.end(JSON.stringify({ ok: false }));
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
try {
|
|
1386
|
+
const port = await listen(server);
|
|
1387
|
+
await writeFile(
|
|
1388
|
+
fixture.runtimeFile,
|
|
1389
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1390
|
+
'utf8',
|
|
1391
|
+
);
|
|
1392
|
+
|
|
1393
|
+
assert.deepEqual(await getPlatformStatus(fixture.runtimeFile), {
|
|
1394
|
+
state: 'logged-in',
|
|
1395
|
+
user: { id: 'user-1', email: 'user@example.test' },
|
|
1396
|
+
device: {
|
|
1397
|
+
id: 'device-1',
|
|
1398
|
+
status: 'active',
|
|
1399
|
+
lastHeartbeatAt: '2026-05-30T08:00:00.000Z',
|
|
1400
|
+
},
|
|
1401
|
+
mode: { state: 'inactive' },
|
|
1402
|
+
});
|
|
1403
|
+
assert.deepEqual(requests, ['GET /platform/status']);
|
|
1404
|
+
} finally {
|
|
1405
|
+
await close(server);
|
|
1406
|
+
await fixture.cleanup();
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
test('API maps platform login error codes from local service', async () => {
|
|
1411
|
+
const fixture = await createRuntimeFixture();
|
|
1412
|
+
const server = createServer((_request, response) => {
|
|
1413
|
+
response.writeHead(400, { 'content-type': 'application/json' });
|
|
1414
|
+
response.end(JSON.stringify({ ok: false, error: 'platform login failed', code: 'DEVICE_LIMIT_REACHED' }));
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
try {
|
|
1418
|
+
const port = await listen(server);
|
|
1419
|
+
await writeFile(
|
|
1420
|
+
fixture.runtimeFile,
|
|
1421
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1422
|
+
'utf8',
|
|
1423
|
+
);
|
|
1424
|
+
|
|
1425
|
+
await assert.rejects(
|
|
1426
|
+
loginPlatform({
|
|
1427
|
+
runtimeFile: fixture.runtimeFile,
|
|
1428
|
+
email: 'dev@example.com',
|
|
1429
|
+
password: 'correct-password',
|
|
1430
|
+
}),
|
|
1431
|
+
/设备数量已达上限/,
|
|
1432
|
+
);
|
|
1433
|
+
} finally {
|
|
1434
|
+
await close(server);
|
|
1435
|
+
await rm(fixture.tempDir, { recursive: true, force: true });
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
test('API reads platform catalog from local runtime service', async () => {
|
|
1440
|
+
const fixture = await createRuntimeFixture();
|
|
1441
|
+
const requests: string[] = [];
|
|
1442
|
+
const server = createServer((request, response) => {
|
|
1443
|
+
requests.push(`${request.method} ${request.url}`);
|
|
1444
|
+
|
|
1445
|
+
if (request.method === 'GET' && request.url === '/platform/catalog') {
|
|
1446
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1447
|
+
response.end(
|
|
1448
|
+
JSON.stringify({
|
|
1449
|
+
state: 'logged-in',
|
|
1450
|
+
account: { credits: 1000 },
|
|
1451
|
+
selectedProductId: 'prod_basic',
|
|
1452
|
+
mode: { state: 'active', productId: 'prod_basic', startedAt: '2026-05-31T00:00:00.000Z' },
|
|
1453
|
+
products: [
|
|
1454
|
+
{
|
|
1455
|
+
id: 'prod_basic',
|
|
1456
|
+
name: '基础组',
|
|
1457
|
+
description: '开发态基础商品,用于验证扩展展示。',
|
|
1458
|
+
status: 'available',
|
|
1459
|
+
minCredits: 100,
|
|
1460
|
+
usageLabel: '按请求消耗,倍率 1x',
|
|
1461
|
+
},
|
|
1462
|
+
{
|
|
1463
|
+
id: 'prod_bad',
|
|
1464
|
+
name: 'Bad',
|
|
1465
|
+
description: 'Bad product',
|
|
1466
|
+
status: 'available',
|
|
1467
|
+
minCredits: -1,
|
|
1468
|
+
usageLabel: 'bad',
|
|
1469
|
+
},
|
|
1470
|
+
],
|
|
1471
|
+
}),
|
|
1472
|
+
);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
response.writeHead(404, { 'content-type': 'application/json' });
|
|
1477
|
+
response.end(JSON.stringify({ ok: false }));
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
try {
|
|
1481
|
+
const port = await listen(server);
|
|
1482
|
+
await writeFile(
|
|
1483
|
+
fixture.runtimeFile,
|
|
1484
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1485
|
+
'utf8',
|
|
1486
|
+
);
|
|
1487
|
+
|
|
1488
|
+
assert.deepEqual(await getPlatformCatalog(fixture.runtimeFile), {
|
|
1489
|
+
state: 'logged-in',
|
|
1490
|
+
account: { credits: 1000 },
|
|
1491
|
+
selectedProductId: 'prod_basic',
|
|
1492
|
+
mode: { state: 'active', productId: 'prod_basic', startedAt: '2026-05-31T00:00:00.000Z' },
|
|
1493
|
+
products: [
|
|
1494
|
+
{
|
|
1495
|
+
id: 'prod_basic',
|
|
1496
|
+
name: '基础组',
|
|
1497
|
+
description: '开发态基础商品,用于验证扩展展示。',
|
|
1498
|
+
status: 'available',
|
|
1499
|
+
minCredits: 100,
|
|
1500
|
+
usageLabel: '按请求消耗,倍率 1x',
|
|
1501
|
+
},
|
|
1502
|
+
],
|
|
1503
|
+
});
|
|
1504
|
+
assert.deepEqual(requests, ['GET /platform/catalog']);
|
|
1505
|
+
} finally {
|
|
1506
|
+
await close(server);
|
|
1507
|
+
await fixture.cleanup();
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
test('API selects a platform product through local service', async () => {
|
|
1512
|
+
const fixture = await createRuntimeFixture();
|
|
1513
|
+
const requests: { method?: string; url?: string; body: Record<string, unknown> }[] = [];
|
|
1514
|
+
const server = createServer((request, response) => {
|
|
1515
|
+
let rawBody = '';
|
|
1516
|
+
request.setEncoding('utf8');
|
|
1517
|
+
request.on('data', (chunk) => {
|
|
1518
|
+
rawBody += chunk;
|
|
1519
|
+
});
|
|
1520
|
+
request.on('end', () => {
|
|
1521
|
+
requests.push({
|
|
1522
|
+
method: request.method,
|
|
1523
|
+
url: request.url,
|
|
1524
|
+
body: rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {},
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
if (request.method === 'POST' && request.url === '/platform/selection') {
|
|
1528
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1529
|
+
response.end(JSON.stringify({ state: 'selected', selectedProductId: 'prod_basic' }));
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
response.writeHead(404, { 'content-type': 'application/json' });
|
|
1534
|
+
response.end(JSON.stringify({ ok: false }));
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
try {
|
|
1539
|
+
const port = await listen(server);
|
|
1540
|
+
await writeFile(
|
|
1541
|
+
fixture.runtimeFile,
|
|
1542
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1543
|
+
'utf8',
|
|
1544
|
+
);
|
|
1545
|
+
|
|
1546
|
+
assert.deepEqual(await selectPlatformProduct({
|
|
1547
|
+
runtimeFile: fixture.runtimeFile,
|
|
1548
|
+
productId: 'prod_basic',
|
|
1549
|
+
}), {
|
|
1550
|
+
state: 'selected',
|
|
1551
|
+
selectedProductId: 'prod_basic',
|
|
1552
|
+
});
|
|
1553
|
+
assert.deepEqual(requests, [
|
|
1554
|
+
{ method: 'POST', url: '/platform/selection', body: { productId: 'prod_basic' } },
|
|
1555
|
+
]);
|
|
1556
|
+
} finally {
|
|
1557
|
+
await close(server);
|
|
1558
|
+
await fixture.cleanup();
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
test('API reports offline selection state when runtime is missing or response is malformed', async () => {
|
|
1563
|
+
const fixture = await createRuntimeFixture();
|
|
1564
|
+
let serverStarted = false;
|
|
1565
|
+
const server = createServer((_request, response) => {
|
|
1566
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1567
|
+
response.end(JSON.stringify({ state: 'selected' }));
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
try {
|
|
1571
|
+
assert.deepEqual(await selectPlatformProduct({
|
|
1572
|
+
runtimeFile: fixture.runtimeFile,
|
|
1573
|
+
productId: 'prod_basic',
|
|
1574
|
+
}), { state: 'offline' });
|
|
1575
|
+
|
|
1576
|
+
const port = await listen(server);
|
|
1577
|
+
serverStarted = true;
|
|
1578
|
+
await writeFile(
|
|
1579
|
+
fixture.runtimeFile,
|
|
1580
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1581
|
+
'utf8',
|
|
1582
|
+
);
|
|
1583
|
+
assert.deepEqual(await selectPlatformProduct({
|
|
1584
|
+
runtimeFile: fixture.runtimeFile,
|
|
1585
|
+
productId: 'prod_basic',
|
|
1586
|
+
}), { state: 'offline' });
|
|
1587
|
+
} finally {
|
|
1588
|
+
if (serverStarted) {
|
|
1589
|
+
await close(server);
|
|
1590
|
+
}
|
|
1591
|
+
await fixture.cleanup();
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
test('API can login and logout through local service platform routes', async () => {
|
|
1596
|
+
const fixture = await createRuntimeFixture();
|
|
1597
|
+
const requests: { method?: string; url?: string; body: Record<string, unknown> }[] = [];
|
|
1598
|
+
const server = createServer((request, response) => {
|
|
1599
|
+
let rawBody = '';
|
|
1600
|
+
request.setEncoding('utf8');
|
|
1601
|
+
request.on('data', (chunk) => {
|
|
1602
|
+
rawBody += chunk;
|
|
1603
|
+
});
|
|
1604
|
+
request.on('end', () => {
|
|
1605
|
+
requests.push({
|
|
1606
|
+
method: request.method,
|
|
1607
|
+
url: request.url,
|
|
1608
|
+
body: rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {},
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1612
|
+
if (request.url === '/platform/login') {
|
|
1613
|
+
response.end(
|
|
1614
|
+
JSON.stringify({
|
|
1615
|
+
state: 'logged-in',
|
|
1616
|
+
user: { id: 'usr_1', email: 'dev@example.com' },
|
|
1617
|
+
device: { id: 'dev_1', status: 'active', lastHeartbeatAt: '2026-05-30T00:00:00Z' },
|
|
1618
|
+
}),
|
|
1619
|
+
);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (request.url === '/platform/logout') {
|
|
1624
|
+
response.end(JSON.stringify({ state: 'logged-out' }));
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
response.end(JSON.stringify({ ok: true }));
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
try {
|
|
1633
|
+
const port = await listen(server);
|
|
1634
|
+
await writeFile(
|
|
1635
|
+
fixture.runtimeFile,
|
|
1636
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1637
|
+
'utf8',
|
|
1638
|
+
);
|
|
1639
|
+
|
|
1640
|
+
const login = await loginPlatform({
|
|
1641
|
+
runtimeFile: fixture.runtimeFile,
|
|
1642
|
+
email: 'dev@example.com',
|
|
1643
|
+
password: 'correct-password',
|
|
1644
|
+
});
|
|
1645
|
+
const logout = await logoutPlatform(fixture.runtimeFile);
|
|
1646
|
+
|
|
1647
|
+
assert.equal(login.state, 'logged-in');
|
|
1648
|
+
assert.deepEqual(logout, { state: 'logged-out' });
|
|
1649
|
+
assert.equal(requests[0]?.method, 'POST');
|
|
1650
|
+
assert.equal(requests[0]?.url, '/platform/login');
|
|
1651
|
+
assert.equal(requests[0]?.body.email, 'dev@example.com');
|
|
1652
|
+
assert.equal(requests[0]?.body.password, 'correct-password');
|
|
1653
|
+
assert.equal(Object.hasOwn(requests[0]?.body ?? {}, 'apiBaseUrl'), false);
|
|
1654
|
+
assert.equal(Object.hasOwn(requests[0]?.body ?? {}, 'code'), false);
|
|
1655
|
+
assert.equal(typeof (requests[0]?.body.device as Record<string, unknown>).name, 'string');
|
|
1656
|
+
assert.equal(typeof (requests[0]?.body.device as Record<string, unknown>).os, 'string');
|
|
1657
|
+
assert.equal(typeof (requests[0]?.body.device as Record<string, unknown>).arch, 'string');
|
|
1658
|
+
assert.equal((requests[0]?.body.device as Record<string, unknown>).extensionVersion, '0.5.6');
|
|
1659
|
+
assert.equal(requests[1]?.method, 'POST');
|
|
1660
|
+
assert.equal(requests[1]?.url, '/platform/logout');
|
|
1661
|
+
} finally {
|
|
1662
|
+
await close(server);
|
|
1663
|
+
await fixture.cleanup();
|
|
1664
|
+
}
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
test('API reports logged-out platform state when runtime is missing', async () => {
|
|
1668
|
+
const fixture = await createRuntimeFixture();
|
|
1669
|
+
|
|
1670
|
+
try {
|
|
1671
|
+
assert.deepEqual(await getPlatformStatus(fixture.runtimeFile), { state: 'logged-out' });
|
|
1672
|
+
assert.deepEqual(await getPlatformCatalog(fixture.runtimeFile), { state: 'logged-out', products: [] });
|
|
1673
|
+
} finally {
|
|
1674
|
+
await fixture.cleanup();
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
test('API reports offline platform state when service request fails', async () => {
|
|
1679
|
+
const fixture = await createRuntimeFixture();
|
|
1680
|
+
const server = createServer((_request, response) => {
|
|
1681
|
+
response.writeHead(500, { 'content-type': 'application/json' });
|
|
1682
|
+
response.end(JSON.stringify({ ok: false }));
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
try {
|
|
1686
|
+
const port = await listen(server);
|
|
1687
|
+
await writeFile(
|
|
1688
|
+
fixture.runtimeFile,
|
|
1689
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1690
|
+
'utf8',
|
|
1691
|
+
);
|
|
1692
|
+
|
|
1693
|
+
assert.deepEqual(await getPlatformStatus(fixture.runtimeFile), { state: 'offline' });
|
|
1694
|
+
assert.deepEqual(await getPlatformCatalog(fixture.runtimeFile), { state: 'offline', products: [] });
|
|
1695
|
+
} finally {
|
|
1696
|
+
await close(server);
|
|
1697
|
+
await fixture.cleanup();
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
test('API reports offline platform state when service returns malformed status', async () => {
|
|
1702
|
+
const fixture = await createRuntimeFixture();
|
|
1703
|
+
const server = createServer((_request, response) => {
|
|
1704
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1705
|
+
response.end(JSON.stringify({ state: 'logged-in' }));
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
try {
|
|
1709
|
+
const port = await listen(server);
|
|
1710
|
+
await writeFile(
|
|
1711
|
+
fixture.runtimeFile,
|
|
1712
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1713
|
+
'utf8',
|
|
1714
|
+
);
|
|
1715
|
+
|
|
1716
|
+
assert.deepEqual(await getPlatformStatus(fixture.runtimeFile), { state: 'offline' });
|
|
1717
|
+
assert.deepEqual(await getPlatformCatalog(fixture.runtimeFile), { state: 'offline', products: [] });
|
|
1718
|
+
} finally {
|
|
1719
|
+
await close(server);
|
|
1720
|
+
await fixture.cleanup();
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
test('API can trigger mode/start and mode/stop', async () => {
|
|
1725
|
+
const fixture = await createRuntimeFixture();
|
|
1726
|
+
const paths: string[] = [];
|
|
1727
|
+
const server = createServer((request, response) => {
|
|
1728
|
+
paths.push(`${request.method} ${request.url}`);
|
|
1729
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1730
|
+
|
|
1731
|
+
if (request.url === '/mode/start') {
|
|
1732
|
+
response.end(JSON.stringify({
|
|
1733
|
+
state: 'active',
|
|
1734
|
+
productId: 'prod_basic',
|
|
1735
|
+
startedAt: '2026-05-31T00:00:00.000Z',
|
|
1736
|
+
}));
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
response.end(JSON.stringify({ state: 'inactive' }));
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
try {
|
|
1744
|
+
const port = await listen(server);
|
|
1745
|
+
await writeFile(
|
|
1746
|
+
fixture.runtimeFile,
|
|
1747
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1748
|
+
'utf8',
|
|
1749
|
+
);
|
|
1750
|
+
|
|
1751
|
+
assert.deepEqual(await startMode(fixture.runtimeFile), {
|
|
1752
|
+
state: 'active',
|
|
1753
|
+
productId: 'prod_basic',
|
|
1754
|
+
startedAt: '2026-05-31T00:00:00.000Z',
|
|
1755
|
+
});
|
|
1756
|
+
assert.deepEqual(await stopMode(fixture.runtimeFile), { state: 'inactive' });
|
|
1757
|
+
assert.deepEqual(paths, ['POST /mode/start', 'POST /mode/stop']);
|
|
1758
|
+
} finally {
|
|
1759
|
+
await close(server);
|
|
1760
|
+
await fixture.cleanup();
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
test('API reports offline mode state when runtime is missing or mode response is malformed', async () => {
|
|
1765
|
+
const fixture = await createRuntimeFixture();
|
|
1766
|
+
const server = createServer((_request, response) => {
|
|
1767
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1768
|
+
response.end(JSON.stringify({ state: 'active' }));
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
try {
|
|
1772
|
+
assert.deepEqual(await startMode(fixture.runtimeFile), { state: 'offline' });
|
|
1773
|
+
|
|
1774
|
+
const port = await listen(server);
|
|
1775
|
+
await writeFile(
|
|
1776
|
+
fixture.runtimeFile,
|
|
1777
|
+
JSON.stringify({ host: '127.0.0.1', port, runtimeId: 'extension-runtime' }),
|
|
1778
|
+
'utf8',
|
|
1779
|
+
);
|
|
1780
|
+
assert.deepEqual(await startMode(fixture.runtimeFile), { state: 'offline' });
|
|
1781
|
+
} finally {
|
|
1782
|
+
await close(server);
|
|
1783
|
+
await fixture.cleanup();
|
|
1784
|
+
}
|
|
1785
|
+
});
|