@vostride/agent-qa-plugin-kit 0.0.1 → 0.3.1

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.
@@ -0,0 +1,969 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { z } from "zod";
7
+ import { createServer } from "node:http";
8
+ // src/protocol/messages.ts
9
+ const OPTIONAL_PLUGIN_METHODS = [
10
+ "check",
11
+ "uploadApp",
12
+ "started",
13
+ "end"
14
+ ];
15
+ const PLUGIN_SERVES = [
16
+ "playwright",
17
+ "cdp",
18
+ "appium"
19
+ ];
20
+ const PLUGIN_RUN_FACTS = [
21
+ "testName",
22
+ "runId",
23
+ "failure"
24
+ ];
25
+ const PLUGIN_FAILURE_KINDS = [
26
+ "unreachable",
27
+ "auth",
28
+ "capacity",
29
+ "timeout",
30
+ "version",
31
+ "refused",
32
+ "ended",
33
+ "lost"
34
+ ];
35
+ const PLUGIN_ERROR_CODES = {
36
+ parse: -32700,
37
+ invalidRequest: -32600,
38
+ methodNotFound: -32601,
39
+ invalidParams: -32602,
40
+ plugin: -32e3,
41
+ cancelled: -32800
42
+ };
43
+ const CANCEL_METHOD = "$/cancel";
44
+ const MAX_MESSAGE_BYTES = 1048576;
45
+ const JsonObjectSchema = z.record(z.string(), z.unknown());
46
+ const HandleSchema = z.json();
47
+ const RequestIdSchema = z.union([z.string(), z.number()]);
48
+ z.object({
49
+ protocolVersions: z.array(z.number().int()),
50
+ host: z.object({
51
+ pid: z.number().int(),
52
+ agentQaVersion: z.string(),
53
+ playwrightVersion: z.string(),
54
+ webdriverioVersion: z.string()
55
+ }),
56
+ entry: z.string(),
57
+ options: JsonObjectSchema,
58
+ runFacts: z.object({ runId: z.string().optional() }).optional()
59
+ });
60
+ const InitializeResultSchema = z.object({
61
+ protocolVersion: z.number().int(),
62
+ plugin: z.object({
63
+ name: z.string(),
64
+ version: z.string()
65
+ }),
66
+ serves: z.array(z.enum(PLUGIN_SERVES)),
67
+ methods: z.array(z.enum(OPTIONAL_PLUGIN_METHODS))
68
+ });
69
+ z.object({ remotes: z.array(z.object({
70
+ name: z.string(),
71
+ settings: JsonObjectSchema
72
+ })) });
73
+ z.object({
74
+ state: z.enum(["ok", "warn"]),
75
+ lines: z.array(z.string())
76
+ });
77
+ const WebSessionSchema = z.object({
78
+ platform: z.literal("web"),
79
+ engine: z.enum([
80
+ "chromium",
81
+ "firefox",
82
+ "webkit"
83
+ ]),
84
+ context: z.enum(["new", "reuse"])
85
+ });
86
+ const DeviceSessionSchema = z.object({
87
+ platform: z.enum(["android", "ios"]),
88
+ capabilities: JsonObjectSchema
89
+ });
90
+ z.object({
91
+ remote: z.string(),
92
+ settings: JsonObjectSchema,
93
+ session: z.discriminatedUnion("platform", [WebSessionSchema, DeviceSessionSchema]),
94
+ run: z.object({ testName: z.string().optional() }).optional()
95
+ });
96
+ const AcquireResultSchema = z.object({
97
+ handle: HandleSchema,
98
+ protocol: z.enum(PLUGIN_SERVES),
99
+ url: z.string(),
100
+ headers: z.record(z.string(), z.string()).optional(),
101
+ capabilities: JsonObjectSchema.optional(),
102
+ mask: z.array(z.string()).optional(),
103
+ link: z.string().optional()
104
+ });
105
+ z.object({
106
+ remote: z.string(),
107
+ settings: JsonObjectSchema,
108
+ platform: z.enum(["android", "ios"]),
109
+ path: z.string(),
110
+ sha256: z.string(),
111
+ size: z.number().int().nonnegative()
112
+ });
113
+ z.object({ ref: z.string() });
114
+ z.object({
115
+ handle: HandleSchema,
116
+ sessionId: z.string().optional()
117
+ });
118
+ z.object({
119
+ link: z.string().optional(),
120
+ appiumVersion: z.string().optional()
121
+ });
122
+ z.object({
123
+ handle: HandleSchema,
124
+ outcome: z.enum([
125
+ "passed",
126
+ "failed",
127
+ "error",
128
+ "cancelled"
129
+ ]),
130
+ reason: z.string().optional()
131
+ });
132
+ z.object({ handle: HandleSchema });
133
+ z.object({ id: RequestIdSchema });
134
+ z.object({
135
+ id: RequestIdSchema,
136
+ message: z.string(),
137
+ queued: z.number().int().nonnegative().optional(),
138
+ percent: z.number().min(0).max(100).optional()
139
+ });
140
+ const PluginErrorDataSchema = z.object({
141
+ kind: z.enum(PLUGIN_FAILURE_KINDS),
142
+ path: z.string().optional(),
143
+ handle: HandleSchema.optional(),
144
+ supported: z.array(z.number().int()).optional(),
145
+ requested: z.array(z.number().int()).optional()
146
+ });
147
+ // src/protocol/manifest.ts
148
+ const PLUGIN_MANIFEST_FIELD = "agent-qa";
149
+ const PackagePathSchema = z.string().min(1);
150
+ const RemotePluginManifestSchema = z.strictObject({
151
+ kind: z.literal("remote"),
152
+ runs: z.literal("process"),
153
+ protocol: z.number().int().positive(),
154
+ entry: PackagePathSchema,
155
+ serves: z.array(z.enum(PLUGIN_SERVES)).nonempty(),
156
+ methods: z.array(z.enum(OPTIONAL_PLUGIN_METHODS)),
157
+ options: PackagePathSchema,
158
+ settings: PackagePathSchema,
159
+ runFacts: z.array(z.enum(PLUGIN_RUN_FACTS)).optional(),
160
+ skill: PackagePathSchema.optional()
161
+ });
162
+ const ModelAuthManifestSchema = z.strictObject({
163
+ kind: z.literal("model-auth"),
164
+ runs: z.literal("in-process"),
165
+ entry: PackagePathSchema,
166
+ options: PackagePathSchema.optional()
167
+ });
168
+ const ModelProviderManifestSchema = z.strictObject({
169
+ kind: z.literal("model-provider"),
170
+ runs: z.literal("in-process"),
171
+ entry: PackagePathSchema,
172
+ options: PackagePathSchema.optional()
173
+ });
174
+ const PluginManifestSchema = z.discriminatedUnion("kind", [
175
+ RemotePluginManifestSchema,
176
+ ModelAuthManifestSchema,
177
+ ModelProviderManifestSchema
178
+ ]);
179
+ // src/emit.ts
180
+ const OPTIONS_SCHEMA_PATH = "./dist/options.schema.json";
181
+ const SETTINGS_SCHEMA_PATH = "./dist/settings.schema.json";
182
+ function pluginManifest(definition) {
183
+ const manifest = PluginManifestSchema.parse({
184
+ protocol: 1,
185
+ kind: "remote",
186
+ runs: "process",
187
+ entry: "./dist/main.js",
188
+ serves: [...definition.serves],
189
+ methods: [...definition.methods],
190
+ options: OPTIONS_SCHEMA_PATH,
191
+ settings: SETTINGS_SCHEMA_PATH,
192
+ ...definition.runFacts === void 0 ? {} : { runFacts: [...definition.runFacts] },
193
+ ...definition.skill === void 0 ? {} : { skill: definition.skill }
194
+ });
195
+ if (manifest.kind !== "remote") throw new Error("a plugin built with the kit is remote");
196
+ return manifest;
197
+ }
198
+ // src/package-root.ts
199
+ function findPackageRoot(fromDir) {
200
+ let dir = fromDir;
201
+ for (;;) {
202
+ if (existsSync(join(dir, "package.json"))) return dir;
203
+ const parent = dirname(dir);
204
+ if (parent === dir) return void 0;
205
+ dir = parent;
206
+ }
207
+ }
208
+ function readPackageJson(packageRoot) {
209
+ const parsed = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
210
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
211
+ }
212
+ // src/protocol/environment.ts
213
+ const PROXY_NAMES = [
214
+ "HTTP_PROXY",
215
+ "HTTPS_PROXY",
216
+ "NO_PROXY",
217
+ "ALL_PROXY"
218
+ ];
219
+ const PLUGIN_ENVIRONMENT_NAMES = [
220
+ "PATH",
221
+ "HOME",
222
+ "TMPDIR",
223
+ "TEMP",
224
+ "TMP",
225
+ "LANG",
226
+ "TZ",
227
+ ...PROXY_NAMES,
228
+ ...PROXY_NAMES.map((name) => name.toLowerCase()),
229
+ "NODE_EXTRA_CA_CERTS",
230
+ "SSL_CERT_FILE"
231
+ ];
232
+ const PLUGIN_WINDOWS_ENVIRONMENT_NAMES = [
233
+ "SystemRoot",
234
+ "ComSpec",
235
+ "PATHEXT",
236
+ "USERPROFILE",
237
+ "APPDATA",
238
+ "LOCALAPPDATA"
239
+ ];
240
+ function pluginEnvironment(env, platform) {
241
+ const windows = platform === "win32";
242
+ const names = new Set(PLUGIN_ENVIRONMENT_NAMES);
243
+ if (windows) for (const name of PLUGIN_WINDOWS_ENVIRONMENT_NAMES) names.add(name.toUpperCase());
244
+ const passes = (key) => {
245
+ if (key.startsWith("LC_")) return true;
246
+ if (names.has(key)) return true;
247
+ return windows && names.has(key.toUpperCase());
248
+ };
249
+ const out = {};
250
+ for (const [key, value] of Object.entries(env)) {
251
+ if (value === void 0 || key.toUpperCase() === "NODE_OPTIONS") continue;
252
+ if (passes(key)) out[key] = value;
253
+ }
254
+ return out;
255
+ }
256
+ // src/protocol/framing.ts
257
+ function encodeMessage(message) {
258
+ return `${JSON.stringify(message)}\n`;
259
+ }
260
+ const NEWLINE = 10;
261
+ function createLineDecoder({ maxLineBytes, onLine, onOverflow }) {
262
+ const encoder = new TextEncoder();
263
+ const decoder = new TextDecoder();
264
+ let parts = [];
265
+ let size = 0;
266
+ let discarding = false;
267
+ const append = (segment) => {
268
+ if (discarding || segment.length === 0) return;
269
+ if (size + segment.length > maxLineBytes) {
270
+ discarding = true;
271
+ parts = [];
272
+ size = 0;
273
+ onOverflow();
274
+ return;
275
+ }
276
+ parts.push(new Uint8Array(segment));
277
+ size += segment.length;
278
+ };
279
+ const finishLine = () => {
280
+ const wasDiscarding = discarding;
281
+ const lineParts = parts;
282
+ const lineSize = size;
283
+ parts = [];
284
+ size = 0;
285
+ discarding = false;
286
+ if (wasDiscarding || lineSize === 0) return;
287
+ const bytes = new Uint8Array(lineSize);
288
+ let offset = 0;
289
+ for (const part of lineParts) {
290
+ bytes.set(part, offset);
291
+ offset += part.length;
292
+ }
293
+ const line = decoder.decode(bytes).replace(/\r$/, "");
294
+ if (line.trim() !== "") onLine(line);
295
+ };
296
+ return {
297
+ push(chunk) {
298
+ const bytes = typeof chunk === "string" ? encoder.encode(chunk) : chunk;
299
+ let start = 0;
300
+ while (start <= bytes.length) {
301
+ const newline = bytes.indexOf(NEWLINE, start);
302
+ if (newline === -1) {
303
+ append(bytes.subarray(start));
304
+ return;
305
+ }
306
+ append(bytes.subarray(start, newline));
307
+ finishLine();
308
+ start = newline + 1;
309
+ }
310
+ },
311
+ end() {
312
+ finishLine();
313
+ }
314
+ };
315
+ }
316
+ // src/testing/conformance.ts
317
+ const DEFAULT_CALL_TIMEOUT_MS = 1e4;
318
+ const CANCEL_ANSWER_WINDOW_MS = 5e3;
319
+ const EXIT_WINDOW_MS = 5e3;
320
+ const STDIO_DRAIN_MS = 1e3;
321
+ const DRAFT_2020_12 = /^https:\/\/json-schema\.org\/draft\/2020-12\/schema#?$/;
322
+ const DEFAULT_SESSION = {
323
+ platform: "web",
324
+ engine: "chromium",
325
+ context: "new"
326
+ };
327
+ function isRecord(value) {
328
+ return typeof value === "object" && value !== null && !Array.isArray(value);
329
+ }
330
+ function errorText(error) {
331
+ return error instanceof Error ? error.message : String(error);
332
+ }
333
+ function kitVersion() {
334
+ const root = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
335
+ const version = root === void 0 ? void 0 : readPackageJson(root).version;
336
+ return typeof version === "string" ? version : "unknown";
337
+ }
338
+ function hostIdentity(host) {
339
+ return {
340
+ pid: process.pid,
341
+ agentQaVersion: kitVersion(),
342
+ playwrightVersion: "unknown",
343
+ webdriverioVersion: "unknown",
344
+ ...host
345
+ };
346
+ }
347
+ function readManifestField(packageDir) {
348
+ const parsed = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
349
+ const field = isRecord(parsed) ? parsed[PLUGIN_MANIFEST_FIELD] : void 0;
350
+ return isRecord(field) ? field.plugin : void 0;
351
+ }
352
+ function insidePackage(packageRoot, path) {
353
+ const target = join(packageRoot, path);
354
+ if (!existsSync(target)) return void 0;
355
+ const real = realpathSync(target);
356
+ const fromRoot = relative(packageRoot, real);
357
+ if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) return void 0;
358
+ return real;
359
+ }
360
+ function checkManifest(packageDir) {
361
+ const failed = (detail) => ({ check: {
362
+ name: "manifest",
363
+ ok: false,
364
+ detail
365
+ } });
366
+ let packageRoot;
367
+ let field;
368
+ try {
369
+ packageRoot = realpathSync(packageDir);
370
+ field = readManifestField(packageRoot);
371
+ } catch (error) {
372
+ return failed(`package.json could not be read: ${errorText(error)}`);
373
+ }
374
+ if (field === void 0) return failed(`package.json has no ${PLUGIN_MANIFEST_FIELD}.plugin`);
375
+ const parsed = PluginManifestSchema.safeParse(field);
376
+ if (!parsed.success) return failed(parsed.error.issues.map((issue) => `${[
377
+ PLUGIN_MANIFEST_FIELD,
378
+ "plugin",
379
+ ...issue.path.map(String)
380
+ ].join(".")}: ${issue.message}`).join("; "));
381
+ const manifest = parsed.data;
382
+ if (manifest.kind !== "remote") return failed(`a conformance run checks a remote plugin, and this one is ${manifest.kind}`);
383
+ const problems = [];
384
+ const resolved = (key, path) => {
385
+ const real = insidePackage(packageRoot, path);
386
+ if (real === void 0) problems.push(`${key} ${path} does not resolve inside the package`);
387
+ return real;
388
+ };
389
+ const entry = resolved("entry", manifest.entry);
390
+ for (const key of ["options", "settings"]) {
391
+ const file = resolved(key, manifest[key]);
392
+ if (file === void 0) continue;
393
+ try {
394
+ const schema = JSON.parse(readFileSync(file, "utf8"));
395
+ if (!isRecord(schema) || typeof schema.$schema !== "string") problems.push(`${key} ${manifest[key]} names no $schema`);
396
+ else if (!DRAFT_2020_12.test(schema.$schema)) problems.push(`${key} ${manifest[key]} is ${schema.$schema}, not JSON Schema draft 2020-12`);
397
+ } catch (error) {
398
+ problems.push(`${key} ${manifest[key]} is not JSON: ${errorText(error)}`);
399
+ }
400
+ }
401
+ if (manifest.skill !== void 0) resolved("skill", manifest.skill);
402
+ if (problems.length > 0) return failed(problems.join("; "));
403
+ return {
404
+ check: {
405
+ name: "manifest",
406
+ ok: true
407
+ },
408
+ manifest,
409
+ ...entry ? { entry } : {}
410
+ };
411
+ }
412
+ function within(promise, ms, what) {
413
+ let timer;
414
+ const timeout = new Promise((_, reject) => {
415
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${what} got no answer within ${ms} ms`)), ms);
416
+ });
417
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
418
+ }
419
+ function startPlugin(entry, cwd) {
420
+ const child = spawn(process.execPath, [entry], {
421
+ cwd,
422
+ env: pluginEnvironment(process.env, process.platform),
423
+ stdio: [
424
+ "pipe",
425
+ "pipe",
426
+ "pipe"
427
+ ]
428
+ });
429
+ const waiting = /* @__PURE__ */ new Map();
430
+ const problems = [];
431
+ let stderr = "";
432
+ let exitCode = null;
433
+ let exited = false;
434
+ child.stdin.on("error", () => {});
435
+ child.stderr.on("data", (chunk) => {
436
+ stderr += chunk.toString();
437
+ });
438
+ const decoder = createLineDecoder({
439
+ maxLineBytes: MAX_MESSAGE_BYTES,
440
+ onLine: (line) => {
441
+ let message;
442
+ try {
443
+ message = JSON.parse(line);
444
+ } catch {
445
+ problems.push(line);
446
+ return;
447
+ }
448
+ if (!isRecord(message) || message.jsonrpc !== "2.0") {
449
+ problems.push(line);
450
+ return;
451
+ }
452
+ const resolve = typeof message.id === "number" ? waiting.get(message.id) : void 0;
453
+ if (resolve === void 0 || typeof message.id !== "number") return;
454
+ waiting.delete(message.id);
455
+ resolve(message);
456
+ },
457
+ onOverflow: () => problems.push(`a line longer than ${MAX_MESSAGE_BYTES} bytes`)
458
+ });
459
+ child.stdout.on("data", (chunk) => decoder.push(chunk));
460
+ const exit = new Promise((resolve) => child.on("exit", (code) => {
461
+ exited = true;
462
+ exitCode = code;
463
+ resolve(code);
464
+ }));
465
+ const closed = new Promise((resolve) => child.on("close", () => resolve()));
466
+ let next = 0;
467
+ const plugin = {
468
+ send(method, params) {
469
+ next += 1;
470
+ const id = next;
471
+ const answered = new Promise((resolve, reject) => {
472
+ waiting.set(id, resolve);
473
+ exit.then((code) => {
474
+ const reason = stderr.trim().split("\n")[0] ?? "";
475
+ reject(/* @__PURE__ */ new Error(`the plugin exited with ${code} before answering ${method} ${reason}`));
476
+ });
477
+ });
478
+ answered.catch(() => {});
479
+ child.stdin.write(encodeMessage({
480
+ jsonrpc: "2.0",
481
+ id,
482
+ method,
483
+ params
484
+ }));
485
+ return {
486
+ id,
487
+ answered
488
+ };
489
+ },
490
+ request(method, params, timeoutMs) {
491
+ return within(plugin.send(method, params).answered, timeoutMs, method);
492
+ },
493
+ notify(method, params) {
494
+ child.stdin.write(encodeMessage({
495
+ jsonrpc: "2.0",
496
+ method,
497
+ params
498
+ }));
499
+ },
500
+ stdoutProblems() {
501
+ return [...problems];
502
+ },
503
+ closeStdin() {
504
+ child.stdin.end();
505
+ },
506
+ async exitedWithin(ms) {
507
+ if (exited) return {
508
+ exited: true,
509
+ code: exitCode
510
+ };
511
+ try {
512
+ return {
513
+ exited: true,
514
+ code: await within(exit, ms, "exit")
515
+ };
516
+ } catch {
517
+ return {
518
+ exited: false,
519
+ code: null
520
+ };
521
+ }
522
+ },
523
+ async drained(ms) {
524
+ await within(closed, ms, "stdout").catch(() => {});
525
+ decoder.end();
526
+ },
527
+ hasExited() {
528
+ return exited;
529
+ },
530
+ kill() {
531
+ if (!exited) child.kill("SIGKILL");
532
+ }
533
+ };
534
+ return plugin;
535
+ }
536
+ function describeError(answer) {
537
+ if (answer.error === void 0) return `answered ${JSON.stringify(answer.result)}`;
538
+ const data = PluginErrorDataSchema.safeParse(answer.error.data);
539
+ const kind = data.success ? ` (kind ${data.data.kind})` : "";
540
+ return `answered ${answer.error.code}: ${answer.error.message}${kind}`;
541
+ }
542
+ function errorHandle(answer) {
543
+ const data = answer.error?.data;
544
+ return isRecord(data) ? data.handle : void 0;
545
+ }
546
+ async function checkHandshake(plugin, manifest, input, timeoutMs) {
547
+ const name = "handshake";
548
+ let answer;
549
+ try {
550
+ answer = await plugin.request("initialize", {
551
+ protocolVersions: [1],
552
+ host: hostIdentity(input.host),
553
+ entry: "conformance",
554
+ options: input.options
555
+ }, timeoutMs);
556
+ } catch (error) {
557
+ return {
558
+ name,
559
+ ok: false,
560
+ detail: errorText(error)
561
+ };
562
+ }
563
+ if (answer.error !== void 0) {
564
+ const refusal = answer.error.code === PLUGIN_ERROR_CODES.plugin && typeof answer.error.message === "string" && answer.error.message !== "" && PluginErrorDataSchema.safeParse(answer.error.data).success;
565
+ if (input.quick === true && refusal) return {
566
+ name,
567
+ ok: true,
568
+ detail: `initialize refused: ${describeError(answer)}`
569
+ };
570
+ return {
571
+ name,
572
+ ok: false,
573
+ detail: `initialize ${describeError(answer)}`
574
+ };
575
+ }
576
+ const result = InitializeResultSchema.safeParse(answer.result);
577
+ if (!result.success) return {
578
+ name,
579
+ ok: false,
580
+ detail: `initialize answered a malformed result: ${result.error.message}`
581
+ };
582
+ const sorted = (values) => [...values].sort();
583
+ const problems = [];
584
+ if (result.data.protocolVersion !== 1) problems.push(`protocol ${result.data.protocolVersion}, not 1`);
585
+ if (!isDeepStrictEqual(sorted(result.data.serves), sorted(manifest.serves))) problems.push(`serves ${result.data.serves.join(", ")}, and the manifest says ${manifest.serves.join(", ")}`);
586
+ if (!isDeepStrictEqual(sorted(result.data.methods), sorted(manifest.methods))) problems.push(`methods ${result.data.methods.join(", ") || "none"}, and the manifest says ${manifest.methods.join(", ") || "none"}`);
587
+ return problems.length === 0 ? {
588
+ name,
589
+ ok: true
590
+ } : {
591
+ name,
592
+ ok: false,
593
+ detail: `initialize answered ${problems.join("; ")}`
594
+ };
595
+ }
596
+ async function checkUnknownMethod(plugin, timeoutMs) {
597
+ const name = "unknown-method";
598
+ try {
599
+ const answer = await plugin.request("agent-qa/none", {}, timeoutMs);
600
+ return answer.error?.code === PLUGIN_ERROR_CODES.methodNotFound ? {
601
+ name,
602
+ ok: true
603
+ } : {
604
+ name,
605
+ ok: false,
606
+ detail: `agent-qa/none ${describeError(answer)}, not ${PLUGIN_ERROR_CODES.methodNotFound}`
607
+ };
608
+ } catch (error) {
609
+ return {
610
+ name,
611
+ ok: false,
612
+ detail: errorText(error)
613
+ };
614
+ }
615
+ }
616
+ async function release(plugin, handle, timeoutMs) {
617
+ return plugin.request("release", { handle }, timeoutMs);
618
+ }
619
+ async function checkIdempotentRelease(plugin, acquireParams, timeoutMs) {
620
+ const name = "idempotent-release";
621
+ try {
622
+ const acquired = await plugin.request("acquire", acquireParams, timeoutMs);
623
+ if (acquired.error !== void 0) {
624
+ const handle = errorHandle(acquired);
625
+ if (handle !== void 0) await release(plugin, handle, timeoutMs).catch(() => {});
626
+ return {
627
+ name,
628
+ ok: false,
629
+ detail: `acquire ${describeError(acquired)}`
630
+ };
631
+ }
632
+ const result = AcquireResultSchema.safeParse(acquired.result);
633
+ if (!result.success) return {
634
+ name,
635
+ ok: false,
636
+ detail: `acquire answered a malformed result: ${result.error.message}`
637
+ };
638
+ for (const attempt of ["first", "second"]) {
639
+ const released = await release(plugin, result.data.handle, timeoutMs);
640
+ if (released.error !== void 0 || !isDeepStrictEqual(released.result, {})) return {
641
+ name,
642
+ ok: false,
643
+ detail: `the ${attempt} release ${describeError(released)}`
644
+ };
645
+ }
646
+ return {
647
+ name,
648
+ ok: true
649
+ };
650
+ } catch (error) {
651
+ return {
652
+ name,
653
+ ok: false,
654
+ detail: errorText(error)
655
+ };
656
+ }
657
+ }
658
+ async function checkAnsweredCancel(plugin, acquireParams, timeoutMs) {
659
+ const name = "answered-cancel";
660
+ try {
661
+ const acquire = plugin.send("acquire", acquireParams);
662
+ plugin.notify(CANCEL_METHOD, { id: acquire.id });
663
+ const answer = await within(acquire.answered, CANCEL_ANSWER_WINDOW_MS, "a cancelled acquire");
664
+ if (answer.error === void 0) {
665
+ const result = AcquireResultSchema.safeParse(answer.result);
666
+ if (result.success) await release(plugin, result.data.handle, timeoutMs).catch(() => {});
667
+ return result.success ? {
668
+ name,
669
+ ok: true
670
+ } : {
671
+ name,
672
+ ok: false,
673
+ detail: "a cancelled acquire answered a malformed result"
674
+ };
675
+ }
676
+ const handle = errorHandle(answer);
677
+ if (handle !== void 0) await release(plugin, handle, timeoutMs).catch(() => {});
678
+ return answer.error.code === PLUGIN_ERROR_CODES.cancelled ? {
679
+ name,
680
+ ok: true
681
+ } : {
682
+ name,
683
+ ok: false,
684
+ detail: `a cancelled acquire ${describeError(answer)}, not a result or ${PLUGIN_ERROR_CODES.cancelled}`
685
+ };
686
+ } catch (error) {
687
+ return {
688
+ name,
689
+ ok: false,
690
+ detail: errorText(error)
691
+ };
692
+ }
693
+ }
694
+ async function checkExitOnStdinClose(plugin) {
695
+ const name = "exit-on-stdin-close";
696
+ if (plugin.hasExited()) return {
697
+ name,
698
+ ok: false,
699
+ detail: "the plugin exited before its stdin closed"
700
+ };
701
+ plugin.closeStdin();
702
+ const { exited } = await plugin.exitedWithin(EXIT_WINDOW_MS);
703
+ return exited ? {
704
+ name,
705
+ ok: true
706
+ } : {
707
+ name,
708
+ ok: false,
709
+ detail: `the plugin was still running ${EXIT_WINDOW_MS} ms after stdin closed`
710
+ };
711
+ }
712
+ async function runConformance(input) {
713
+ const timeoutMs = input.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
714
+ const checked = checkManifest(input.packageDir);
715
+ const checks = [checked.check];
716
+ if (checked.manifest === void 0 || checked.entry === void 0) return {
717
+ ok: false,
718
+ checks
719
+ };
720
+ const plugin = startPlugin(checked.entry, input.packageDir);
721
+ try {
722
+ checks.push(await checkHandshake(plugin, checked.manifest, input, timeoutMs));
723
+ checks.push(await checkUnknownMethod(plugin, timeoutMs));
724
+ if (input.quick !== true) {
725
+ const acquireParams = {
726
+ remote: "conformance",
727
+ settings: input.settings ?? {},
728
+ session: input.session ?? DEFAULT_SESSION
729
+ };
730
+ checks.push(await checkIdempotentRelease(plugin, acquireParams, timeoutMs));
731
+ checks.push(await checkAnsweredCancel(plugin, acquireParams, timeoutMs));
732
+ }
733
+ const exitCheck = await checkExitOnStdinClose(plugin);
734
+ if (exitCheck.ok) await plugin.drained(STDIO_DRAIN_MS);
735
+ const unclean = plugin.stdoutProblems();
736
+ checks.push(unclean.length === 0 ? {
737
+ name: "clean-stdout",
738
+ ok: true
739
+ } : {
740
+ name: "clean-stdout",
741
+ ok: false,
742
+ detail: `stdout carried ${unclean.length} line(s) that are not JSON-RPC, the first: ${unclean[0]?.slice(0, 200)}`
743
+ });
744
+ checks.push(exitCheck);
745
+ } finally {
746
+ plugin.kill();
747
+ }
748
+ return {
749
+ ok: checks.every((check) => check.ok),
750
+ checks
751
+ };
752
+ }
753
+ function expectManifestCurrent(packageDir, definition) {
754
+ const expected = { ...pluginManifest(definition) };
755
+ const field = readManifestField(packageDir);
756
+ const actual = isRecord(field) ? field : {};
757
+ const differing = [.../* @__PURE__ */ new Set([...Object.keys(expected), ...Object.keys(actual)])].sort().filter((key) => !isDeepStrictEqual(actual[key], expected[key]));
758
+ if (differing.length > 0) throw new Error(`package.json's ${PLUGIN_MANIFEST_FIELD}.plugin differs from the definition in ${differing.join(", ")}; run the build's emit step`);
759
+ }
760
+ // src/testing/loopback-fake.ts
761
+ function splitPath(path) {
762
+ return path.split("/").filter((segment) => segment !== "");
763
+ }
764
+ function compile(key, route) {
765
+ const match = /^([A-Z]+) (\/\S*)$/.exec(key);
766
+ if (match?.[1] === void 0 || match[2] === void 0) throw new Error(`a loopback route is 'METHOD /path', not '${key}'`);
767
+ if (Array.isArray(route) && route.length === 0) throw new Error(`the loopback route '${key}' has no replies`);
768
+ return {
769
+ key,
770
+ method: match[1],
771
+ segments: splitPath(match[2]),
772
+ route,
773
+ calls: []
774
+ };
775
+ }
776
+ function decodeSegment(segment) {
777
+ try {
778
+ return decodeURIComponent(segment);
779
+ } catch {
780
+ return segment;
781
+ }
782
+ }
783
+ function matchParams(route, method, path) {
784
+ if (route.method !== method) return void 0;
785
+ const segments = splitPath(path);
786
+ if (segments.length !== route.segments.length) return void 0;
787
+ const params = {};
788
+ for (const [index, expected] of route.segments.entries()) {
789
+ const actual = segments[index] ?? "";
790
+ if (expected.startsWith(":")) params[expected.slice(1)] = decodeSegment(actual);
791
+ else if (expected !== actual) return void 0;
792
+ }
793
+ return params;
794
+ }
795
+ function lowerCasedHeaders(request) {
796
+ const headers = {};
797
+ for (const [name, value] of Object.entries(request.headers)) {
798
+ if (value === void 0) continue;
799
+ headers[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
800
+ }
801
+ return headers;
802
+ }
803
+ function parseBody(text) {
804
+ if (text.length === 0) return void 0;
805
+ try {
806
+ return JSON.parse(text);
807
+ } catch {
808
+ return text;
809
+ }
810
+ }
811
+ function send(response, reply) {
812
+ const [contentType, text] = reply.json !== void 0 ? ["application/json; charset=utf-8", JSON.stringify(reply.json)] : ["text/plain; charset=utf-8", reply.text ?? ""];
813
+ response.writeHead(reply.status, {
814
+ "content-type": contentType,
815
+ "content-length": Buffer.byteLength(text),
816
+ ...reply.headers
817
+ });
818
+ response.end(text);
819
+ }
820
+ async function startLoopbackFake(input) {
821
+ const routes = Object.entries(input.routes).map(([key, route]) => compile(key, route));
822
+ const requests = [];
823
+ const sockets = /* @__PURE__ */ new Set();
824
+ let routeFailure;
825
+ const answer = (record, response) => {
826
+ for (const route of routes) {
827
+ const params = matchParams(route, record.method, record.path);
828
+ if (params === void 0) continue;
829
+ const request = {
830
+ ...record,
831
+ params
832
+ };
833
+ requests.push(request);
834
+ route.calls.push(request);
835
+ const reply = typeof route.route === "function" ? route.route(request) : route.route[Math.min(route.calls.length, route.route.length) - 1] ?? "hold";
836
+ if (reply !== "hold") send(response, reply);
837
+ return;
838
+ }
839
+ requests.push(record);
840
+ send(response, {
841
+ status: 404,
842
+ json: { message: "no route" }
843
+ });
844
+ };
845
+ const server = createServer((request, response) => {
846
+ const chunks = [];
847
+ request.on("data", (chunk) => chunks.push(chunk));
848
+ request.on("end", () => {
849
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
850
+ const record = {
851
+ method: request.method ?? "GET",
852
+ path: url.pathname,
853
+ query: Object.fromEntries(url.searchParams),
854
+ params: {},
855
+ headers: lowerCasedHeaders(request),
856
+ body: parseBody(Buffer.concat(chunks).toString("utf8"))
857
+ };
858
+ try {
859
+ answer(record, response);
860
+ } catch (error) {
861
+ routeFailure ??= error;
862
+ if (!response.headersSent) send(response, {
863
+ status: 500,
864
+ json: { message: `the route threw: ${String(error)}` }
865
+ });
866
+ }
867
+ });
868
+ });
869
+ server.on("connection", (socket) => {
870
+ sockets.add(socket);
871
+ socket.on("close", () => sockets.delete(socket));
872
+ });
873
+ await new Promise((ready, failed) => {
874
+ server.once("error", failed);
875
+ server.listen(0, "127.0.0.1", () => ready());
876
+ });
877
+ const { port } = server.address();
878
+ const origin = `http://127.0.0.1:${String(port)}`;
879
+ let closing;
880
+ return {
881
+ url: origin,
882
+ origin,
883
+ requests,
884
+ routeCalls(key) {
885
+ return [...routes.find((route) => route.key === key)?.calls ?? []];
886
+ },
887
+ close() {
888
+ closing ??= new Promise((closed, failed) => {
889
+ for (const socket of sockets) socket.destroy();
890
+ server.close(() => routeFailure === void 0 ? closed() : failed(routeFailure));
891
+ });
892
+ return closing;
893
+ }
894
+ };
895
+ }
896
+ // src/testing/scripted-plugin.ts
897
+ const OPEN_OBJECT_SCHEMA = {
898
+ $schema: "https://json-schema.org/draft/2020-12/schema",
899
+ type: "object"
900
+ };
901
+ const SKILL = `---
902
+ name: scripted
903
+ description: A scripted agent-qa plugin that answers each call as its options say.
904
+ ---
905
+
906
+ # Scripted plugin
907
+
908
+ Each call follows \`options.script[method]\`: \`ok\`, \`hold\`, \`ignoreCancel\`, \`crash\`,
909
+ \`uncaught\` (a Node deprecation warning, then an exception nothing catches), \`noise\`,
910
+ \`consoleLog\`, \`{ delayMs }\`, \`{ error }\` or \`{ errorAfterCreate }\`. A list of
911
+ steps gives each call the next one, and the last repeats. \`options.stderr[method]\` is written
912
+ to stderr before the step. Acquire answers \`options.answer\`, or the JSON in the file
913
+ \`options.answerFile\` names.
914
+ With \`options.recordProcess\`, the initialize ledger line also records the process, its
915
+ environment's names, its options, the host and the run facts. With \`options.recordParams\`, the
916
+ acquire line also records the session and run, the uploadApp line the settings, platform, path,
917
+ sha256 and size, the started line the session id, and the end line the outcome and reason.
918
+ Acquire and uploadApp send \`options.progress\`, or \`options.progressByRemote[remote]\`, as
919
+ progress before their step, and with \`options.progressEveryMs\` send them again, one each
920
+ interval, until they answer. uploadApp answers \`options.upload\`.
921
+ `;
922
+ function scriptedMainFile() {
923
+ const kitRoot = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
924
+ const file = kitRoot === void 0 ? void 0 : join(kitRoot, "dist", "scripted-plugin.js");
925
+ if (file === void 0 || !existsSync(file)) throw new Error("the scripted plugin needs the kit built: run pnpm build in the plugin kit");
926
+ return file;
927
+ }
928
+ function writeScriptedPlugin(dir, input = {}) {
929
+ const manifest = PluginManifestSchema.parse({
930
+ kind: "remote",
931
+ runs: "process",
932
+ protocol: 1,
933
+ entry: "./main.mjs",
934
+ serves: [...input.serves ?? PLUGIN_SERVES],
935
+ methods: [...input.methods ?? OPTIONAL_PLUGIN_METHODS],
936
+ options: "./options.schema.json",
937
+ settings: "./settings.schema.json",
938
+ ...input.runFacts === void 0 ? {} : { runFacts: [...input.runFacts] },
939
+ skill: "./skill"
940
+ });
941
+ const packageJson = {
942
+ name: input.packageName ?? "@acme/agent-qa-plugin-scripted",
943
+ version: input.version ?? "1.0.0",
944
+ private: true,
945
+ type: "module",
946
+ keywords: ["agent-qa-plugin"],
947
+ exports: { "./package.json": "./package.json" },
948
+ [PLUGIN_MANIFEST_FIELD]: { plugin: manifest }
949
+ };
950
+ const mainUrl = pathToFileURL(scriptedMainFile()).href;
951
+ mkdirSync(join(dir, "skill"), { recursive: true });
952
+ writeFileSync(join(dir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
953
+ const openSchema = `${JSON.stringify(OPEN_OBJECT_SCHEMA, null, 2)}\n`;
954
+ writeFileSync(join(dir, "options.schema.json"), openSchema);
955
+ writeFileSync(join(dir, "settings.schema.json"), openSchema);
956
+ writeFileSync(join(dir, "skill", "SKILL.md"), SKILL);
957
+ const timing = {
958
+ ...input.timing?.cancelAnswerGraceMs === void 0 ? {} : { cancelAnswerGraceMs: input.timing.cancelAnswerGraceMs },
959
+ ...input.timing?.hostPidPollMs === void 0 ? {} : { hostPidPollMs: input.timing.hostPidPollMs }
960
+ };
961
+ writeFileSync(join(dir, "main.mjs"), `import { startScriptedPlugin } from ${JSON.stringify(mainUrl)}\n\nstartScriptedPlugin(${JSON.stringify(timing)})\n`);
962
+ return {
963
+ dir,
964
+ entry: join(dir, "main.mjs")
965
+ };
966
+ }
967
+ export { expectManifestCurrent, runConformance, startLoopbackFake, writeScriptedPlugin };
968
+
969
+ //# sourceMappingURL=testing.js.map