@agentrhq/webcmd 0.3.2 → 0.3.4
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/README.md +12 -7
- package/clis/producthunt/browse.js +9 -2
- package/clis/producthunt/browser-commands.test.js +66 -0
- package/clis/producthunt/hot.js +2 -1
- package/clis/producthunt/utils.js +27 -0
- package/clis/producthunt/utils.test.js +8 -0
- package/dist/src/browser/ax-snapshot.js +15 -5
- package/dist/src/browser/ax-snapshot.test.js +49 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +25 -1
- package/dist/src/browser/daemon-client.test.js +86 -1
- package/dist/src/browser/daemon-transport.d.ts +2 -0
- package/dist/src/browser/protocol.d.ts +12 -1
- package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
- package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
- package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
- package/dist/src/browser/runtime/provider.d.ts +1 -0
- package/dist/src/cli.js +36 -12
- package/dist/src/cli.test.js +5 -0
- package/dist/src/daemon/server.js +65 -24
- package/dist/src/daemon/server.test.js +211 -2
- package/dist/src/docs-sync-review-cli.test.js +1 -1
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/errors.test.js +58 -1
- package/dist/src/execution.js +57 -6
- package/dist/src/execution.test.js +426 -2
- package/dist/src/hosted/client.js +3 -1
- package/dist/src/hosted/client.test.js +62 -0
- package/dist/src/hosted/main-lifecycle.test.js +66 -5
- package/dist/src/hosted/types.d.ts +1 -0
- package/dist/src/main.js +4 -0
- package/dist/src/package-bin-process.d.ts +13 -0
- package/dist/src/package-bin-process.js +24 -0
- package/dist/src/package-bin-process.test.d.ts +1 -0
- package/dist/src/package-bin-process.test.js +22 -0
- package/dist/src/session-lease.d.ts +82 -0
- package/dist/src/session-lease.js +163 -0
- package/dist/src/session-lease.test.d.ts +1 -0
- package/dist/src/session-lease.test.js +217 -0
- package/dist/src/skills.d.ts +8 -4
- package/dist/src/skills.js +30 -1
- package/dist/src/skills.test.js +40 -12
- package/hosted-contract.json +1 -1
- package/package.json +3 -1
- package/scripts/check-package-bin.mjs +7 -6
- package/scripts/collect-ci-diagnostics.ps1 +274 -0
- package/scripts/docs-sync-review.ts +1 -1
- package/skills/smart-search/SKILL.md +20 -6
- package/skills/webcmd-adapter-author/SKILL.md +1 -1
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
- package/skills/webcmd-browser/SKILL.md +16 -2
- package/skills/webcmd-usage/SKILL.md +21 -6
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
const { mockSetDaemonCommandTimeoutSeconds } = vi.hoisted(() => ({
|
|
5
|
+
const { mockReleaseSiteSessionLease, mockSetDaemonCommandTimeoutSeconds } = vi.hoisted(() => ({
|
|
6
|
+
mockReleaseSiteSessionLease: vi.fn().mockResolvedValue(undefined),
|
|
6
7
|
mockSetDaemonCommandTimeoutSeconds: vi.fn(),
|
|
7
8
|
}));
|
|
8
9
|
vi.mock('./browser/daemon-client.js', async () => {
|
|
9
10
|
const actual = await vi.importActual('./browser/daemon-client.js');
|
|
10
11
|
return {
|
|
11
12
|
...actual,
|
|
13
|
+
releaseSiteSessionLease: mockReleaseSiteSessionLease,
|
|
12
14
|
setDaemonCommandTimeoutSeconds: mockSetDaemonCommandTimeoutSeconds,
|
|
13
15
|
};
|
|
14
16
|
});
|
|
@@ -17,6 +19,17 @@ import { ArgumentError, TimeoutError, toEnvelope } from './errors.js';
|
|
|
17
19
|
import { cli, Strategy } from './registry.js';
|
|
18
20
|
import * as runtime from './runtime.js';
|
|
19
21
|
import * as capRouting from './capabilityRouting.js';
|
|
22
|
+
import { clearDaemonRunContext, getDaemonRunContext } from './session-lease.js';
|
|
23
|
+
import { sendCommand } from './browser/daemon-client.js';
|
|
24
|
+
function deferred() {
|
|
25
|
+
let resolve;
|
|
26
|
+
let reject;
|
|
27
|
+
const promise = new Promise((res, rej) => {
|
|
28
|
+
resolve = res;
|
|
29
|
+
reject = rej;
|
|
30
|
+
});
|
|
31
|
+
return { promise, resolve, reject };
|
|
32
|
+
}
|
|
20
33
|
describe('executeCommand — non-browser timeout', () => {
|
|
21
34
|
it('applies the user --timeout arg as the ceiling for non-browser commands', async () => {
|
|
22
35
|
const runWithTimeoutSpy = vi.spyOn(runtime, 'runWithTimeout');
|
|
@@ -148,6 +161,417 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
148
161
|
});
|
|
149
162
|
vi.restoreAllMocks();
|
|
150
163
|
});
|
|
164
|
+
describe('persistent write run ownership', () => {
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
const activeRun = getDaemonRunContext();
|
|
167
|
+
if (activeRun)
|
|
168
|
+
clearDaemonRunContext(activeRun.runId);
|
|
169
|
+
mockReleaseSiteSessionLease.mockReset().mockResolvedValue(undefined);
|
|
170
|
+
vi.restoreAllMocks();
|
|
171
|
+
vi.unstubAllGlobals();
|
|
172
|
+
});
|
|
173
|
+
it('binds a run only for browser-backed persistent writes', async () => {
|
|
174
|
+
const seen = new Map();
|
|
175
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
176
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockImplementation(cmd => cmd.browser !== false);
|
|
177
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
178
|
+
const makeCommand = (name, access, browser, siteSession) => cli({
|
|
179
|
+
site: 'test-execution',
|
|
180
|
+
name,
|
|
181
|
+
access,
|
|
182
|
+
description: 'test logical run eligibility',
|
|
183
|
+
browser,
|
|
184
|
+
strategy: Strategy.PUBLIC,
|
|
185
|
+
siteSession,
|
|
186
|
+
func: async () => {
|
|
187
|
+
seen.set(name, getDaemonRunContext());
|
|
188
|
+
return [{ ok: true }];
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
await executeCommand(makeCommand('run-eligible', 'write', true, 'persistent'), {});
|
|
192
|
+
await executeCommand(makeCommand('run-read', 'read', true, 'persistent'), {});
|
|
193
|
+
await executeCommand(makeCommand('run-ephemeral', 'write', true, 'ephemeral'), {});
|
|
194
|
+
await executeCommand(makeCommand('run-non-browser', 'write', false, 'persistent'), {});
|
|
195
|
+
const eligibleRun = seen.get('run-eligible');
|
|
196
|
+
expect(eligibleRun).toMatchObject({
|
|
197
|
+
runId: expect.stringMatching(/^run_/),
|
|
198
|
+
command: 'test-execution/run-eligible',
|
|
199
|
+
access: 'write',
|
|
200
|
+
});
|
|
201
|
+
expect(seen.get('run-read')).toBeUndefined();
|
|
202
|
+
expect(seen.get('run-ephemeral')).toBeUndefined();
|
|
203
|
+
expect(seen.get('run-non-browser')).toBeUndefined();
|
|
204
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
|
|
205
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(eligibleRun?.runId);
|
|
206
|
+
});
|
|
207
|
+
it('binds one canonical run before session setup, pre-navigation, and adapter execution', async () => {
|
|
208
|
+
const seen = [];
|
|
209
|
+
const capture = (stage) => seen.push({ stage, run: getDaemonRunContext() });
|
|
210
|
+
const mockPage = {
|
|
211
|
+
closeWindow: vi.fn().mockResolvedValue(undefined),
|
|
212
|
+
getCurrentUrl: vi.fn().mockImplementation(async () => {
|
|
213
|
+
capture('current-url');
|
|
214
|
+
return 'about:blank';
|
|
215
|
+
}),
|
|
216
|
+
goto: vi.fn().mockImplementation(async () => capture('pre-navigation')),
|
|
217
|
+
};
|
|
218
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
219
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => {
|
|
220
|
+
capture('session');
|
|
221
|
+
return fn(mockPage);
|
|
222
|
+
});
|
|
223
|
+
const cmd = cli({
|
|
224
|
+
site: 'test-execution',
|
|
225
|
+
name: 'run-bound-before-operations',
|
|
226
|
+
access: 'write',
|
|
227
|
+
description: 'test one run spans the complete adapter execution',
|
|
228
|
+
browser: true,
|
|
229
|
+
strategy: Strategy.COOKIE,
|
|
230
|
+
domain: 'example.com',
|
|
231
|
+
siteSession: 'persistent',
|
|
232
|
+
func: async () => {
|
|
233
|
+
capture('adapter');
|
|
234
|
+
return [{ ok: true }];
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true }]);
|
|
238
|
+
expect(seen.map(entry => entry.stage)).toEqual([
|
|
239
|
+
'session',
|
|
240
|
+
'current-url',
|
|
241
|
+
'pre-navigation',
|
|
242
|
+
'adapter',
|
|
243
|
+
]);
|
|
244
|
+
const runId = seen[0]?.run?.runId;
|
|
245
|
+
expect(runId).toMatch(/^run_/);
|
|
246
|
+
for (const entry of seen) {
|
|
247
|
+
expect(entry.run).toEqual({
|
|
248
|
+
runId,
|
|
249
|
+
command: 'test-execution/run-bound-before-operations',
|
|
250
|
+
access: 'write',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
254
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
|
|
255
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
256
|
+
});
|
|
257
|
+
it('clears and releases the run once after an ordinary adapter error', async () => {
|
|
258
|
+
let runId;
|
|
259
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
260
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
261
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
262
|
+
const cmd = cli({
|
|
263
|
+
site: 'test-execution',
|
|
264
|
+
name: 'run-ordinary-error',
|
|
265
|
+
access: 'write',
|
|
266
|
+
description: 'test ordinary errors release run ownership',
|
|
267
|
+
browser: true,
|
|
268
|
+
strategy: Strategy.PUBLIC,
|
|
269
|
+
siteSession: 'persistent',
|
|
270
|
+
func: async () => {
|
|
271
|
+
runId = getDaemonRunContext()?.runId;
|
|
272
|
+
throw new Error('ordinary adapter failure');
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
await expect(executeCommand(cmd, {})).rejects.toThrow('ordinary adapter failure');
|
|
276
|
+
expect(runId).toMatch(/^run_/);
|
|
277
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
278
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
|
|
279
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
280
|
+
});
|
|
281
|
+
it('clears but does not release a run when an error cause has unknown outcome', async () => {
|
|
282
|
+
let runId;
|
|
283
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
284
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
285
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
286
|
+
const unknownOutcome = new Error('wrapped adapter failure');
|
|
287
|
+
unknownOutcome.cause = { code: 'COMMAND_RESULT_UNKNOWN' };
|
|
288
|
+
const cmd = cli({
|
|
289
|
+
site: 'test-execution',
|
|
290
|
+
name: 'run-unknown-outcome',
|
|
291
|
+
access: 'write',
|
|
292
|
+
description: 'test unknown outcomes retain the daemon lease',
|
|
293
|
+
browser: true,
|
|
294
|
+
strategy: Strategy.PUBLIC,
|
|
295
|
+
siteSession: 'persistent',
|
|
296
|
+
func: async () => {
|
|
297
|
+
runId = getDaemonRunContext()?.runId;
|
|
298
|
+
throw unknownOutcome;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
await expect(executeCommand(cmd, {})).rejects.toBe(unknownOutcome);
|
|
302
|
+
expect(runId).toMatch(/^run_/);
|
|
303
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
304
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
it('does not release a run when pre-navigation has unknown outcome', async () => {
|
|
307
|
+
let runId;
|
|
308
|
+
const unknownOutcome = new Error('navigation result unknown');
|
|
309
|
+
unknownOutcome.cause = { errorCode: 'COMMAND_LOST' };
|
|
310
|
+
const mockPage = {
|
|
311
|
+
closeWindow: vi.fn().mockResolvedValue(undefined),
|
|
312
|
+
getCurrentUrl: vi.fn().mockResolvedValue('about:blank'),
|
|
313
|
+
goto: vi.fn().mockImplementation(async () => {
|
|
314
|
+
runId = getDaemonRunContext()?.runId;
|
|
315
|
+
throw unknownOutcome;
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
319
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
320
|
+
const cmd = cli({
|
|
321
|
+
site: 'test-execution',
|
|
322
|
+
name: 'run-unknown-prenavigation',
|
|
323
|
+
access: 'write',
|
|
324
|
+
description: 'test unknown pre-navigation retains the daemon lease',
|
|
325
|
+
browser: true,
|
|
326
|
+
strategy: Strategy.COOKIE,
|
|
327
|
+
domain: 'example.com',
|
|
328
|
+
siteSession: 'persistent',
|
|
329
|
+
func: async () => [{ ok: true }],
|
|
330
|
+
});
|
|
331
|
+
await expect(executeCommand(cmd, {})).rejects.toThrow('Pre-navigation');
|
|
332
|
+
expect(runId).toMatch(/^run_/);
|
|
333
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
334
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
it('releases after a wrapper timeout when the adapter later succeeds', async () => {
|
|
337
|
+
const adapter = deferred();
|
|
338
|
+
let runId;
|
|
339
|
+
let settledRunId;
|
|
340
|
+
let adapterSettled = false;
|
|
341
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
342
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
343
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
344
|
+
vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
|
|
345
|
+
const cmd = cli({
|
|
346
|
+
site: 'test-execution',
|
|
347
|
+
name: 'run-timeout',
|
|
348
|
+
access: 'write',
|
|
349
|
+
description: 'test timeout defers local run cleanup',
|
|
350
|
+
browser: true,
|
|
351
|
+
strategy: Strategy.PUBLIC,
|
|
352
|
+
siteSession: 'persistent',
|
|
353
|
+
func: async () => {
|
|
354
|
+
runId = getDaemonRunContext()?.runId;
|
|
355
|
+
await adapter.promise;
|
|
356
|
+
settledRunId = getDaemonRunContext()?.runId;
|
|
357
|
+
adapterSettled = true;
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
361
|
+
expect(runId).toMatch(/^run_/);
|
|
362
|
+
expect(adapterSettled).toBe(false);
|
|
363
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
364
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
365
|
+
adapter.resolve();
|
|
366
|
+
await vi.waitFor(() => expect(adapterSettled).toBe(true));
|
|
367
|
+
expect(settledRunId).toBe(runId);
|
|
368
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
369
|
+
await vi.waitFor(() => expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce());
|
|
370
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
371
|
+
});
|
|
372
|
+
it('releases after a wrapper timeout when the adapter later fails ordinarily', async () => {
|
|
373
|
+
const adapter = deferred();
|
|
374
|
+
let runId;
|
|
375
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
376
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
377
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
378
|
+
vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
|
|
379
|
+
const cmd = cli({
|
|
380
|
+
site: 'test-execution',
|
|
381
|
+
name: 'run-timeout-late-ordinary-failure',
|
|
382
|
+
access: 'write',
|
|
383
|
+
description: 'test late ordinary failure releases run ownership',
|
|
384
|
+
browser: true,
|
|
385
|
+
strategy: Strategy.PUBLIC,
|
|
386
|
+
siteSession: 'persistent',
|
|
387
|
+
func: async () => {
|
|
388
|
+
runId = getDaemonRunContext()?.runId;
|
|
389
|
+
await adapter.promise;
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
393
|
+
expect(runId).toMatch(/^run_/);
|
|
394
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
395
|
+
adapter.reject(new Error('late ordinary failure'));
|
|
396
|
+
await vi.waitFor(() => expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce());
|
|
397
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
398
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
399
|
+
});
|
|
400
|
+
it('retains ownership for TTL after a wrapper timeout when the adapter later has an unknown outcome', async () => {
|
|
401
|
+
const adapter = deferred();
|
|
402
|
+
let runId;
|
|
403
|
+
let adapterSettled = false;
|
|
404
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
405
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
406
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
407
|
+
vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
|
|
408
|
+
const cmd = cli({
|
|
409
|
+
site: 'test-execution',
|
|
410
|
+
name: 'run-timeout-late-unknown-failure',
|
|
411
|
+
access: 'write',
|
|
412
|
+
description: 'test late unknown failure retains ownership for TTL',
|
|
413
|
+
browser: true,
|
|
414
|
+
strategy: Strategy.PUBLIC,
|
|
415
|
+
siteSession: 'persistent',
|
|
416
|
+
func: async () => {
|
|
417
|
+
runId = getDaemonRunContext()?.runId;
|
|
418
|
+
try {
|
|
419
|
+
await adapter.promise;
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
adapterSettled = true;
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
427
|
+
expect(runId).toMatch(/^run_/);
|
|
428
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
429
|
+
const unknownOutcome = new Error('late result unknown');
|
|
430
|
+
unknownOutcome.cause = { code: 'COMMAND_RESULT_UNKNOWN' };
|
|
431
|
+
adapter.reject(unknownOutcome);
|
|
432
|
+
await vi.waitFor(() => expect(adapterSettled).toBe(true));
|
|
433
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
434
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
435
|
+
});
|
|
436
|
+
it('releases normally when the adapter itself rejects with TimeoutError', async () => {
|
|
437
|
+
let runId;
|
|
438
|
+
const adapterTimeout = new TimeoutError('adapter operation', 1);
|
|
439
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
440
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
441
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
442
|
+
const cmd = cli({
|
|
443
|
+
site: 'test-execution',
|
|
444
|
+
name: 'run-adapter-timeout',
|
|
445
|
+
access: 'write',
|
|
446
|
+
description: 'test a settled adapter timeout releases normally',
|
|
447
|
+
browser: true,
|
|
448
|
+
strategy: Strategy.PUBLIC,
|
|
449
|
+
siteSession: 'persistent',
|
|
450
|
+
func: async () => {
|
|
451
|
+
runId = getDaemonRunContext()?.runId;
|
|
452
|
+
throw adapterTimeout;
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
await expect(executeCommand(cmd, {})).rejects.toBe(adapterTimeout);
|
|
456
|
+
expect(runId).toMatch(/^run_/);
|
|
457
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
458
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
|
|
459
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
460
|
+
});
|
|
461
|
+
it('does not let deferred timeout cleanup clear a later run', async () => {
|
|
462
|
+
const firstAdapter = deferred();
|
|
463
|
+
const secondAdapter = deferred();
|
|
464
|
+
let firstRunId;
|
|
465
|
+
let secondRunId;
|
|
466
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
467
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
468
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
469
|
+
vi.spyOn(runtime, 'runWithTimeout')
|
|
470
|
+
.mockRejectedValueOnce(new TimeoutError('first logical run', 1))
|
|
471
|
+
.mockImplementationOnce(async (promise) => promise);
|
|
472
|
+
const firstCommand = cli({
|
|
473
|
+
site: 'test-execution',
|
|
474
|
+
name: 'run-timeout-first',
|
|
475
|
+
access: 'write',
|
|
476
|
+
description: 'test deferred cleanup for an older run',
|
|
477
|
+
browser: true,
|
|
478
|
+
strategy: Strategy.PUBLIC,
|
|
479
|
+
siteSession: 'persistent',
|
|
480
|
+
func: () => {
|
|
481
|
+
firstRunId = getDaemonRunContext()?.runId;
|
|
482
|
+
return firstAdapter.promise;
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
const secondCommand = cli({
|
|
486
|
+
site: 'test-execution',
|
|
487
|
+
name: 'run-timeout-second',
|
|
488
|
+
access: 'write',
|
|
489
|
+
description: 'test a newer run survives old deferred cleanup',
|
|
490
|
+
browser: true,
|
|
491
|
+
strategy: Strategy.PUBLIC,
|
|
492
|
+
siteSession: 'persistent',
|
|
493
|
+
func: () => {
|
|
494
|
+
secondRunId = getDaemonRunContext()?.runId;
|
|
495
|
+
return secondAdapter.promise;
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
await expect(executeCommand(firstCommand, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
499
|
+
const secondExecution = executeCommand(secondCommand, {});
|
|
500
|
+
await vi.waitFor(() => expect(secondRunId).toMatch(/^run_/));
|
|
501
|
+
expect(secondRunId).not.toBe(firstRunId);
|
|
502
|
+
firstAdapter.resolve();
|
|
503
|
+
await firstAdapter.promise;
|
|
504
|
+
await vi.waitFor(() => expect(getDaemonRunContext()).toBeUndefined());
|
|
505
|
+
secondAdapter.resolve();
|
|
506
|
+
await expect(secondExecution).resolves.toBeUndefined();
|
|
507
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
508
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledTimes(2);
|
|
509
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(firstRunId);
|
|
510
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(secondRunId);
|
|
511
|
+
});
|
|
512
|
+
it('keeps daemon metadata scoped to overlapping timed-out and active runs', async () => {
|
|
513
|
+
const resumeFirst = deferred();
|
|
514
|
+
const firstFinished = deferred();
|
|
515
|
+
const finishSecond = deferred();
|
|
516
|
+
const secondStarted = deferred();
|
|
517
|
+
let firstRunId;
|
|
518
|
+
let secondRunId;
|
|
519
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
520
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
521
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
522
|
+
vi.spyOn(runtime, 'runWithTimeout')
|
|
523
|
+
.mockRejectedValueOnce(new TimeoutError('first logical run', 1))
|
|
524
|
+
.mockImplementationOnce(async (promise) => promise);
|
|
525
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation(async (_input, init) => {
|
|
526
|
+
const request = JSON.parse(String(init?.body));
|
|
527
|
+
return {
|
|
528
|
+
status: 200,
|
|
529
|
+
json: async () => ({ id: request.id, ok: true, data: 'ok' }),
|
|
530
|
+
};
|
|
531
|
+
}));
|
|
532
|
+
const firstCommand = cli({
|
|
533
|
+
site: 'test-execution',
|
|
534
|
+
name: 'run-overlap-first',
|
|
535
|
+
access: 'write',
|
|
536
|
+
description: 'test timed-out run keeps its daemon metadata',
|
|
537
|
+
browser: true,
|
|
538
|
+
strategy: Strategy.PUBLIC,
|
|
539
|
+
siteSession: 'persistent',
|
|
540
|
+
func: async () => {
|
|
541
|
+
firstRunId = getDaemonRunContext()?.runId;
|
|
542
|
+
await resumeFirst.promise;
|
|
543
|
+
await sendCommand('exec', { code: 'first-after-second' });
|
|
544
|
+
firstFinished.resolve();
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
const secondCommand = cli({
|
|
548
|
+
site: 'test-execution',
|
|
549
|
+
name: 'run-overlap-second',
|
|
550
|
+
access: 'write',
|
|
551
|
+
description: 'test active run keeps its daemon metadata',
|
|
552
|
+
browser: true,
|
|
553
|
+
strategy: Strategy.PUBLIC,
|
|
554
|
+
siteSession: 'persistent',
|
|
555
|
+
func: async () => {
|
|
556
|
+
secondRunId = getDaemonRunContext()?.runId;
|
|
557
|
+
await sendCommand('exec', { code: 'second-active' });
|
|
558
|
+
secondStarted.resolve();
|
|
559
|
+
await finishSecond.promise;
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
await expect(executeCommand(firstCommand, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
563
|
+
const secondExecution = executeCommand(secondCommand, {});
|
|
564
|
+
await secondStarted.promise;
|
|
565
|
+
resumeFirst.resolve();
|
|
566
|
+
await firstFinished.promise;
|
|
567
|
+
const bodies = vi.mocked(fetch).mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
|
|
568
|
+
expect(bodies.find(body => body.code === 'first-after-second')?.runId).toBe(firstRunId);
|
|
569
|
+
expect(bodies.find(body => body.code === 'second-active')?.runId).toBe(secondRunId);
|
|
570
|
+
expect(firstRunId).not.toBe(secondRunId);
|
|
571
|
+
finishSecond.resolve();
|
|
572
|
+
await expect(secondExecution).resolves.toBeUndefined();
|
|
573
|
+
});
|
|
574
|
+
});
|
|
151
575
|
it('reuses a persistent site browser session and keeps the tab lease open', async () => {
|
|
152
576
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
153
577
|
const mockPage = { closeWindow };
|
|
@@ -293,7 +293,7 @@ function isHostedManifestCommand(value) {
|
|
|
293
293
|
if (!hasOnlyKeys(value, [
|
|
294
294
|
'site', 'name', 'aliases', 'command', 'description', 'access', 'example', 'domain', 'strategy', 'browser',
|
|
295
295
|
'args', 'columns', 'pipeline', 'defaultFormat', 'type', 'modulePath', 'sourceFile', 'navigateBefore',
|
|
296
|
-
'siteSession', 'defaultWindowMode', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion',
|
|
296
|
+
'siteSession', 'freshPage', 'defaultWindowMode', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion',
|
|
297
297
|
]))
|
|
298
298
|
return false;
|
|
299
299
|
if (typeof value.site !== 'string' || typeof value.name !== 'string' || typeof value.command !== 'string')
|
|
@@ -318,6 +318,8 @@ function isHostedManifestCommand(value) {
|
|
|
318
318
|
if (value[key] !== undefined && typeof value[key] !== 'string')
|
|
319
319
|
return false;
|
|
320
320
|
}
|
|
321
|
+
if (value.freshPage !== undefined && typeof value.freshPage !== 'boolean')
|
|
322
|
+
return false;
|
|
321
323
|
return value.navigateBefore === undefined || typeof value.navigateBefore === 'boolean' || typeof value.navigateBefore === 'string';
|
|
322
324
|
}
|
|
323
325
|
function isHostedManifestArg(value) {
|
|
@@ -103,6 +103,68 @@ describe('HostedClient', () => {
|
|
|
103
103
|
});
|
|
104
104
|
expect(requests).toEqual([{ url: 'https://api.example.com/v1/manifest', authorization: 'Bearer wcmd_live_test' }]);
|
|
105
105
|
});
|
|
106
|
+
it('accepts boolean freshPage command metadata', async () => {
|
|
107
|
+
const client = new HostedClient({
|
|
108
|
+
apiBaseUrl: 'https://api.example.com',
|
|
109
|
+
apiKey: 'key',
|
|
110
|
+
fetchImpl: async () => new Response(JSON.stringify({
|
|
111
|
+
ok: true,
|
|
112
|
+
manifest: {
|
|
113
|
+
userId: 'user_demo',
|
|
114
|
+
metadata: {
|
|
115
|
+
contractSchemaVersion: 1,
|
|
116
|
+
webcmdPackageVersion: '0.3.0',
|
|
117
|
+
generatedAt: 'now',
|
|
118
|
+
},
|
|
119
|
+
commands: [{
|
|
120
|
+
site: 'district',
|
|
121
|
+
name: 'checkout',
|
|
122
|
+
command: 'district/checkout',
|
|
123
|
+
description: 'Checkout',
|
|
124
|
+
access: 'write',
|
|
125
|
+
strategy: 'UI',
|
|
126
|
+
browser: true,
|
|
127
|
+
args: [],
|
|
128
|
+
columns: [],
|
|
129
|
+
freshPage: true,
|
|
130
|
+
}],
|
|
131
|
+
},
|
|
132
|
+
}), { status: 200 }),
|
|
133
|
+
});
|
|
134
|
+
await expect(client.getManifest()).resolves.toMatchObject({
|
|
135
|
+
commands: [expect.objectContaining({ freshPage: true })],
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
it('rejects non-boolean freshPage command metadata', async () => {
|
|
139
|
+
const client = new HostedClient({
|
|
140
|
+
apiBaseUrl: 'https://api.example.com',
|
|
141
|
+
apiKey: 'key',
|
|
142
|
+
fetchImpl: async () => new Response(JSON.stringify({
|
|
143
|
+
ok: true,
|
|
144
|
+
manifest: {
|
|
145
|
+
userId: 'user_demo',
|
|
146
|
+
metadata: {
|
|
147
|
+
contractSchemaVersion: 1,
|
|
148
|
+
webcmdPackageVersion: '0.3.0',
|
|
149
|
+
generatedAt: 'now',
|
|
150
|
+
},
|
|
151
|
+
commands: [{
|
|
152
|
+
site: 'district',
|
|
153
|
+
name: 'checkout',
|
|
154
|
+
command: 'district/checkout',
|
|
155
|
+
description: 'Checkout',
|
|
156
|
+
access: 'write',
|
|
157
|
+
strategy: 'UI',
|
|
158
|
+
browser: true,
|
|
159
|
+
args: [],
|
|
160
|
+
columns: [],
|
|
161
|
+
freshPage: 'yes',
|
|
162
|
+
}],
|
|
163
|
+
},
|
|
164
|
+
}), { status: 200 }),
|
|
165
|
+
});
|
|
166
|
+
await expect(client.getManifest()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
|
|
167
|
+
});
|
|
106
168
|
it('parses hosted profile rows without provider identifiers', async () => {
|
|
107
169
|
const client = new HostedClient({
|
|
108
170
|
apiBaseUrl: 'https://api.example.com',
|
|
@@ -24,6 +24,18 @@ const command = {
|
|
|
24
24
|
columns: ['value'],
|
|
25
25
|
defaultFormat: 'plain',
|
|
26
26
|
};
|
|
27
|
+
const authCommand = {
|
|
28
|
+
site: 'auth',
|
|
29
|
+
name: 'status',
|
|
30
|
+
command: 'auth/status',
|
|
31
|
+
description: 'Show hosted login status',
|
|
32
|
+
access: 'read',
|
|
33
|
+
strategy: 'PUBLIC',
|
|
34
|
+
browser: false,
|
|
35
|
+
args: [],
|
|
36
|
+
columns: ['value'],
|
|
37
|
+
defaultFormat: 'plain',
|
|
38
|
+
};
|
|
27
39
|
afterEach(async () => {
|
|
28
40
|
await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => {
|
|
29
41
|
server.close(error => error ? reject(error) : resolve());
|
|
@@ -77,6 +89,44 @@ describe('hosted CLI process lifecycle', () => {
|
|
|
77
89
|
expect(result.stdout).toContain('Local-only commands:');
|
|
78
90
|
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
79
91
|
}, 20_000);
|
|
92
|
+
it('runs skills add locally without contacting Cloud when hosted mode is configured', async () => {
|
|
93
|
+
const fixture = await createHostedFixture('success');
|
|
94
|
+
const installDir = path.join(fixture.root, 'agent-skills');
|
|
95
|
+
const result = await runCli(['skills', 'add', '--path', installDir, '--json'], fixture.env);
|
|
96
|
+
expect(result.status).toBe(0);
|
|
97
|
+
expect(result.stderr).toBe('');
|
|
98
|
+
const body = JSON.parse(result.stdout);
|
|
99
|
+
expect(body.skills.map(skill => skill.name)).toEqual([
|
|
100
|
+
'smart-search',
|
|
101
|
+
'webcmd-adapter-author',
|
|
102
|
+
'webcmd-autofix',
|
|
103
|
+
'webcmd-browser',
|
|
104
|
+
'webcmd-browser-sitemap',
|
|
105
|
+
'webcmd-sitemap-author',
|
|
106
|
+
'webcmd-usage',
|
|
107
|
+
]);
|
|
108
|
+
expect(body.skills.every(skill => skill.destination?.startsWith(installDir))).toBe(true);
|
|
109
|
+
await expect(readFile(path.join(installDir, 'webcmd-usage', 'SKILL.md'), 'utf8'))
|
|
110
|
+
.resolves.toContain('webcmd-usage');
|
|
111
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
112
|
+
expect(fixture.requests).toEqual([]);
|
|
113
|
+
}, 20_000);
|
|
114
|
+
it('keeps hosted auth on Cloud without local discovery', async () => {
|
|
115
|
+
const fixture = await createHostedFixture('success');
|
|
116
|
+
const result = await runCli(['auth', 'status', '-f', 'plain'], fixture.env);
|
|
117
|
+
expect(result.stderr).toBe('');
|
|
118
|
+
expect(result.status).toBe(0);
|
|
119
|
+
expect(fixture.requests).toEqual(['GET /v1/manifest', 'POST /v1/execute']);
|
|
120
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
121
|
+
}, 20_000);
|
|
122
|
+
it('rejects daemon commands in hosted mode without local discovery', async () => {
|
|
123
|
+
const fixture = await createHostedFixture('success');
|
|
124
|
+
const result = await runCli(['daemon', 'status'], fixture.env);
|
|
125
|
+
expect(result.status).toBe(78);
|
|
126
|
+
expect(result.stderr).toContain('Hosted mode has no local daemon.');
|
|
127
|
+
expect(fixture.requests).toEqual([]);
|
|
128
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
129
|
+
}, 20_000);
|
|
80
130
|
});
|
|
81
131
|
async function createHostedFixture(outcome) {
|
|
82
132
|
const root = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-lifecycle-'));
|
|
@@ -84,6 +134,7 @@ async function createHostedFixture(outcome) {
|
|
|
84
134
|
const configDir = path.join(root, 'config');
|
|
85
135
|
const userClis = path.join(root, '.webcmd', 'clis', 'lifecycle-sentinel');
|
|
86
136
|
const discoverySentinel = path.join(root, 'local-discovery-ran');
|
|
137
|
+
const requests = [];
|
|
87
138
|
await mkdir(configDir, { recursive: true });
|
|
88
139
|
await mkdir(userClis, { recursive: true });
|
|
89
140
|
await writeFile(path.join(userClis, 'sentinel.js'), [
|
|
@@ -92,7 +143,8 @@ async function createHostedFixture(outcome) {
|
|
|
92
143
|
"export const sentinel = 'cli(';",
|
|
93
144
|
'',
|
|
94
145
|
].join('\n'));
|
|
95
|
-
const server = createServer((request, response) => {
|
|
146
|
+
const server = createServer(async (request, response) => {
|
|
147
|
+
requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`);
|
|
96
148
|
if (request.url === '/v1/manifest') {
|
|
97
149
|
sendChunkedJson(response, {
|
|
98
150
|
ok: true,
|
|
@@ -103,12 +155,16 @@ async function createHostedFixture(outcome) {
|
|
|
103
155
|
webcmdPackageVersion: '0.3.0',
|
|
104
156
|
generatedAt: '2026-07-14T00:00:00.000Z',
|
|
105
157
|
},
|
|
106
|
-
commands: [command],
|
|
158
|
+
commands: [command, authCommand],
|
|
107
159
|
},
|
|
108
160
|
});
|
|
109
161
|
return;
|
|
110
162
|
}
|
|
111
163
|
if (request.url === '/v1/execute' && request.method === 'POST') {
|
|
164
|
+
let requestBody = '';
|
|
165
|
+
for await (const chunk of request)
|
|
166
|
+
requestBody += chunk;
|
|
167
|
+
const invocation = JSON.parse(requestBody);
|
|
112
168
|
if (outcome === 'failure') {
|
|
113
169
|
sendChunkedJson(response, {
|
|
114
170
|
ok: false,
|
|
@@ -118,7 +174,7 @@ async function createHostedFixture(outcome) {
|
|
|
118
174
|
help: 'Retry the lifecycle fixture.',
|
|
119
175
|
exitCode: 69,
|
|
120
176
|
},
|
|
121
|
-
execution: { id: 'exec_lifecycle', command:
|
|
177
|
+
execution: { id: 'exec_lifecycle', command: invocation.command, status: 'failed' },
|
|
122
178
|
}, 422);
|
|
123
179
|
return;
|
|
124
180
|
}
|
|
@@ -126,8 +182,10 @@ async function createHostedFixture(outcome) {
|
|
|
126
182
|
ok: true,
|
|
127
183
|
result: { value: largeOutput },
|
|
128
184
|
columns: ['value'],
|
|
129
|
-
execution: { id: 'exec_lifecycle', command:
|
|
130
|
-
trace
|
|
185
|
+
execution: { id: 'exec_lifecycle', command: invocation.command, status: 'succeeded' },
|
|
186
|
+
...(invocation.trace === 'on'
|
|
187
|
+
? { trace: { executionId: 'exec_lifecycle', receipt: traceReceipt } }
|
|
188
|
+
: {}),
|
|
131
189
|
});
|
|
132
190
|
return;
|
|
133
191
|
}
|
|
@@ -156,10 +214,13 @@ async function createHostedFixture(outcome) {
|
|
|
156
214
|
updatedAt: '2026-07-14T00:00:00.000Z',
|
|
157
215
|
})}\n`, { mode: 0o600 });
|
|
158
216
|
return {
|
|
217
|
+
root,
|
|
159
218
|
discoverySentinel,
|
|
219
|
+
requests,
|
|
160
220
|
env: {
|
|
161
221
|
...process.env,
|
|
162
222
|
HOME: root,
|
|
223
|
+
USERPROFILE: root,
|
|
163
224
|
WEBCMD_CONFIG_DIR: configDir,
|
|
164
225
|
WEBCMD_NO_UPDATE_CHECK: '1',
|
|
165
226
|
},
|