@molecule/api-ai-tools 1.11.1 → 1.13.0
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 +460 -7
- package/dist/command-position.d.ts +33 -0
- package/dist/command-position.d.ts.map +1 -1
- package/dist/command-position.js +66 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/playwright-report.d.ts +210 -0
- package/dist/playwright-report.d.ts.map +1 -0
- package/dist/playwright-report.js +406 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +8 -0
- package/dist/server-command.d.ts +9 -3
- package/dist/server-command.d.ts.map +1 -1
- package/dist/server-command.js +39 -4
- package/dist/tool-catalog.d.ts +32 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +76 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +137 -8
- package/dist/trace-artifacts.d.ts +12 -0
- package/dist/trace-artifacts.d.ts.map +1 -1
- package/dist/trace-artifacts.js +1 -0
- package/dist/utilities.d.ts +78 -4
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +229 -38
- package/package.json +2 -2
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standard Playwright machine report, kept beside a run's log instead of inferred from it.
|
|
3
|
+
*
|
|
4
|
+
* The execution wrapper already puts a background suite on Playwright's `list` reporter and
|
|
5
|
+
* preserves a file reporter the executor named ({@link ./utilities.js} `withPlaywrightRunReport`,
|
|
6
|
+
* `streamingBackgroundCommand`). That log is text: which failure a trace, a route or a locator
|
|
7
|
+
* belongs to has to be reconstructed from printed headings, and a mis-join reads one failing
|
|
8
|
+
* test's evidence as another's (xr-molecule-flash-m411). Playwright already writes the answer down
|
|
9
|
+
* itself — its built-in `json` reporter records every test's own identity (project, file, line,
|
|
10
|
+
* column, title path, status, retries) and every attachment the runner saved for THAT test, path
|
|
11
|
+
* included.
|
|
12
|
+
*
|
|
13
|
+
* So the wrapper adds the `json` reporter to a run it already rewrites, sends its output to a
|
|
14
|
+
* per-run file with the reporter's own `PLAYWRIGHT_JSON_OUTPUT_FILE` option, and this module turns
|
|
15
|
+
* that file into a small, bounded record on the tool result ({@link PlaywrightRunReport}). The
|
|
16
|
+
* whole file stays where it was written, so full evidence has a path; the record carries only what
|
|
17
|
+
* a diagnosis needs.
|
|
18
|
+
*
|
|
19
|
+
* Honesty rules, which every reader below follows:
|
|
20
|
+
*
|
|
21
|
+
* - A missing, empty, truncated or wrongly shaped file is NOT "the run failed nothing": it is
|
|
22
|
+
* `missing` / `malformed`, and a record that parsed but is not a whole run (`errors` present, no
|
|
23
|
+
* `suites`) is `partial`. Only a record with the runner's own stats and suite tree is `complete`.
|
|
24
|
+
* - The record never carries a pass/fail verdict and must never be read as one. Suite selection,
|
|
25
|
+
* retries, budgets and the platform's own proof decisions stay exactly where they were.
|
|
26
|
+
* - Nothing here parses console output, and nothing reads a report the executor asked for itself
|
|
27
|
+
* (a `json` reporter already named on the command, or a `PLAYWRIGHT_JSON_OUTPUT_FILE` the command
|
|
28
|
+
* sets): the executor's own artifact is theirs.
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
*/
|
|
32
|
+
import { posix } from 'path';
|
|
33
|
+
/**
|
|
34
|
+
* The environment variable Playwright's built-in `json` reporter takes its output path from
|
|
35
|
+
* (`resolveOutputFile("JSON", …)`), resolved against the runner's own working directory. An
|
|
36
|
+
* absolute path is used here, so the file lands in the run's scratch area whatever directory the
|
|
37
|
+
* suite runs from.
|
|
38
|
+
*/
|
|
39
|
+
export const PLAYWRIGHT_REPORT_ENV = 'PLAYWRIGHT_JSON_OUTPUT_FILE';
|
|
40
|
+
/** The file name a background task's report is written to, beside its own log. */
|
|
41
|
+
export const PLAYWRIGHT_REPORT_SUFFIX = '.playwright.json';
|
|
42
|
+
/**
|
|
43
|
+
* How big a report may be before this module refuses to summarize it. The file stays readable at
|
|
44
|
+
* its path; the record only says so. A whole 139-test suite's report is well under a megabyte; a
|
|
45
|
+
* report past this is not one a tool result should carry.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_REPORT_PARSE_CHARS = 8_000_000;
|
|
48
|
+
/** How many failing tests a record lists before the rest are counted in `omittedFailures`. */
|
|
49
|
+
export const MAX_REPORTED_FAILURES = 12;
|
|
50
|
+
/** How many attachments one failing test keeps in the record (`trace` first). */
|
|
51
|
+
export const MAX_REPORTED_ATTACHMENTS = 4;
|
|
52
|
+
/** How much of one failure's own error text the record keeps. */
|
|
53
|
+
export const MAX_REPORTED_ERROR_CHARS = 400;
|
|
54
|
+
/** How much of one printed path the record keeps. */
|
|
55
|
+
const MAX_REPORTED_PATH_CHARS = 300;
|
|
56
|
+
/** How much of one title the record keeps. */
|
|
57
|
+
const MAX_REPORTED_TITLE_CHARS = 300;
|
|
58
|
+
/**
|
|
59
|
+
* The terminal formatting Playwright bakes into an error message — `expect` diffs are written for a
|
|
60
|
+
* terminal, so the report's own `error.message` carries SGR sequences. Stripped here, before the
|
|
61
|
+
* consumer's redactor, so a result never carries `[2m`/`[22m` fragments as if they were text.
|
|
62
|
+
*/
|
|
63
|
+
// eslint-disable-next-line no-control-regex -- an escape sequence IS a control character
|
|
64
|
+
const ANSI_SEQUENCE = /\u001B\[[0-9;?]*[A-Za-z]/g;
|
|
65
|
+
/** One plain record out of unknown JSON, or null when the value is not an object. */
|
|
66
|
+
function asRecord(value) {
|
|
67
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
68
|
+
? value
|
|
69
|
+
: null;
|
|
70
|
+
}
|
|
71
|
+
/** A finite number from unknown JSON, or null. */
|
|
72
|
+
function asNumber(value) {
|
|
73
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
74
|
+
}
|
|
75
|
+
/** A non-empty string from unknown JSON, or null. */
|
|
76
|
+
function asString(value) {
|
|
77
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
78
|
+
}
|
|
79
|
+
/** A string cut to {@link MAX_REPORTED_PATH_CHARS}. */
|
|
80
|
+
function boundedPath(value) {
|
|
81
|
+
return value.length > MAX_REPORTED_PATH_CHARS
|
|
82
|
+
? `${value.slice(0, MAX_REPORTED_PATH_CHARS)}…`
|
|
83
|
+
: value;
|
|
84
|
+
}
|
|
85
|
+
/** A string cut to {@link MAX_REPORTED_TITLE_CHARS}. */
|
|
86
|
+
function boundedTitle(value) {
|
|
87
|
+
return value.length > MAX_REPORTED_TITLE_CHARS
|
|
88
|
+
? `${value.slice(0, MAX_REPORTED_TITLE_CHARS)}…`
|
|
89
|
+
: value;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The report statuses for which the report's own per-test identities and attachments may be used.
|
|
93
|
+
*
|
|
94
|
+
* `partial` is included because a partial report's suite tree, where it parsed, is still the
|
|
95
|
+
* runner's own record of which test owns which attachment; `malformed` and `missing` are not,
|
|
96
|
+
* because nothing about them is runner data.
|
|
97
|
+
*
|
|
98
|
+
* @param status - One record's {@link PlaywrightReportStatus}.
|
|
99
|
+
* @returns True when the record's tests and attachments are the runner's own.
|
|
100
|
+
*/
|
|
101
|
+
export function reportCarriesIdentities(status) {
|
|
102
|
+
return status === 'complete' || status === 'partial';
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The attachments a stored record actually carries.
|
|
106
|
+
*
|
|
107
|
+
* A record read back from a transcript is not guaranteed to be the shape this module wrote — a
|
|
108
|
+
* damaged, truncated or hand-edited one can carry a null entry, a non-array field or an attachment
|
|
109
|
+
* without a path. Every reader goes through here so a damaged record answers "no artifact", never a
|
|
110
|
+
* thrown error and never another test's trace.
|
|
111
|
+
*
|
|
112
|
+
* @param test - One entry of {@link PlaywrightRunReport.failures}, as stored.
|
|
113
|
+
* @returns Its well-shaped attachments, in the record's own order.
|
|
114
|
+
*/
|
|
115
|
+
function storedAttachments(test) {
|
|
116
|
+
const raw = test?.attachments;
|
|
117
|
+
if (!Array.isArray(raw))
|
|
118
|
+
return [];
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const entry of raw) {
|
|
121
|
+
const attachment = asRecord(entry);
|
|
122
|
+
if (attachment === null)
|
|
123
|
+
continue;
|
|
124
|
+
const path = asString(attachment.path);
|
|
125
|
+
if (path === null)
|
|
126
|
+
continue;
|
|
127
|
+
out.push({
|
|
128
|
+
name: asString(attachment.name) ?? '',
|
|
129
|
+
contentType: asString(attachment.contentType),
|
|
130
|
+
path,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The attachment paths a stored test record carries, `trace` first.
|
|
137
|
+
*
|
|
138
|
+
* @param test - One entry of {@link PlaywrightRunReport.failures}, as stored.
|
|
139
|
+
* @returns Every saved path the record can prove, in its own order.
|
|
140
|
+
*/
|
|
141
|
+
export function reportTestAttachmentPaths(test) {
|
|
142
|
+
return storedAttachments(test).map((attachment) => attachment.path);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The directory a test result's own artifacts were saved in, or null when the report names none.
|
|
146
|
+
*
|
|
147
|
+
* Playwright writes one directory per test attempt under the run's `outputDir`, and every artifact
|
|
148
|
+
* of that attempt — `trace.zip` included — sits in it. The directory is therefore the exact
|
|
149
|
+
* ownership mark for a trace: two tests' traces are never in one another's directory. A damaged
|
|
150
|
+
* record whose attachment entries carry no path answers null, so nothing is claimed from it.
|
|
151
|
+
*
|
|
152
|
+
* @param test - One entry of {@link PlaywrightRunReport.failures}, as stored.
|
|
153
|
+
* @returns The posix-normalized directory, or null.
|
|
154
|
+
*/
|
|
155
|
+
export function reportTestArtifactDir(test) {
|
|
156
|
+
const attachment = storedAttachments(test).find((one) => one.path !== '');
|
|
157
|
+
return attachment === undefined ? null : posix.dirname(attachment.path);
|
|
158
|
+
}
|
|
159
|
+
/** The attachments of one attempt, `trace` first and bounded. */
|
|
160
|
+
function attachmentsOf(result) {
|
|
161
|
+
const raw = Array.isArray(result.attachments) ? result.attachments : [];
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const entry of raw) {
|
|
164
|
+
const attachment = asRecord(entry);
|
|
165
|
+
if (attachment === null)
|
|
166
|
+
continue;
|
|
167
|
+
const path = asString(attachment.path);
|
|
168
|
+
if (path === null)
|
|
169
|
+
continue;
|
|
170
|
+
out.push({
|
|
171
|
+
name: asString(attachment.name) ?? '',
|
|
172
|
+
contentType: asString(attachment.contentType),
|
|
173
|
+
path: boundedPath(path),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// `trace` first: it is the artifact a failure diagnosis reads, and the `body` Playwright may
|
|
177
|
+
// inline is deliberately dropped — it is base64 evidence the file path already carries.
|
|
178
|
+
out.sort((left, right) => Number(right.name === 'trace') - Number(left.name === 'trace'));
|
|
179
|
+
return out.slice(0, MAX_REPORTED_ATTACHMENTS);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The attempt whose evidence describes the test's failure: the LAST attempt that did not pass.
|
|
183
|
+
*
|
|
184
|
+
* A retried test reports one result per attempt. A flaky test's final attempt passed, but the
|
|
185
|
+
* failure it is reported for happened in the earlier one, and that is where the trace was kept
|
|
186
|
+
* (`retain-on-failure`).
|
|
187
|
+
*
|
|
188
|
+
* @param results - One test's `results` array, in attempt order.
|
|
189
|
+
* @returns The chosen attempt and its index, or null when there is none.
|
|
190
|
+
*/
|
|
191
|
+
function evidenceAttempt(results) {
|
|
192
|
+
let chosen = null;
|
|
193
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
194
|
+
const result = asRecord(results[index]);
|
|
195
|
+
const status = result === null ? null : asString(result.status);
|
|
196
|
+
if (result === null || status === null)
|
|
197
|
+
continue;
|
|
198
|
+
if (status !== 'passed' && status !== 'skipped')
|
|
199
|
+
chosen = { result, index };
|
|
200
|
+
}
|
|
201
|
+
if (chosen !== null)
|
|
202
|
+
return chosen;
|
|
203
|
+
const last = asRecord(results[results.length - 1]);
|
|
204
|
+
return last === null ? null : { result: last, index: results.length - 1 };
|
|
205
|
+
}
|
|
206
|
+
/** One test's own error text, bounded and sanitized, or null. */
|
|
207
|
+
function errorOf(result, sanitize) {
|
|
208
|
+
const errors = Array.isArray(result.errors) ? result.errors : [];
|
|
209
|
+
const first = asRecord(errors[0]);
|
|
210
|
+
const message = (first === null ? null : asString(first.message)) ?? null;
|
|
211
|
+
const direct = asRecord(result.error);
|
|
212
|
+
const text = message ?? (direct === null ? null : asString(direct.message));
|
|
213
|
+
if (text === null)
|
|
214
|
+
return null;
|
|
215
|
+
const plain = text.replace(ANSI_SEQUENCE, '');
|
|
216
|
+
const bounded = plain.length > MAX_REPORTED_ERROR_CHARS ? `${plain.slice(0, MAX_REPORTED_ERROR_CHARS)}…` : plain;
|
|
217
|
+
return sanitize(bounded);
|
|
218
|
+
}
|
|
219
|
+
/** Whether one test entry should be listed: a failure, a retried failure, or an interruption. */
|
|
220
|
+
function isFailingTest(status, results) {
|
|
221
|
+
if (status === 'unexpected' || status === 'flaky')
|
|
222
|
+
return true;
|
|
223
|
+
return results.some((entry) => {
|
|
224
|
+
const result = asRecord(entry);
|
|
225
|
+
const resultStatus = result === null ? null : asString(result.status);
|
|
226
|
+
return (resultStatus === 'failed' || resultStatus === 'timedOut' || resultStatus === 'interrupted');
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Every test entry under one suite, with the suite titles that lead to it.
|
|
231
|
+
*
|
|
232
|
+
* @param suite - One suite of the report's tree.
|
|
233
|
+
* @param prefix - The titles of the suites above it.
|
|
234
|
+
* @param out - Where the entries collect.
|
|
235
|
+
* @param fileSuite - True for a top-level entry, whose title is the file path rather than a
|
|
236
|
+
* `describe` block: it is not part of a test's title path.
|
|
237
|
+
*/
|
|
238
|
+
function specsIn(suite, prefix, out, fileSuite = false) {
|
|
239
|
+
const title = fileSuite ? null : asString(suite.title);
|
|
240
|
+
const titlePath = title === null ? [...prefix] : [...prefix, title];
|
|
241
|
+
const specs = Array.isArray(suite.specs) ? suite.specs : [];
|
|
242
|
+
for (const entry of specs) {
|
|
243
|
+
const spec = asRecord(entry);
|
|
244
|
+
if (spec !== null)
|
|
245
|
+
out.push({ spec, titlePath });
|
|
246
|
+
}
|
|
247
|
+
const children = Array.isArray(suite.suites) ? suite.suites : [];
|
|
248
|
+
for (const entry of children) {
|
|
249
|
+
const child = asRecord(entry);
|
|
250
|
+
if (child !== null)
|
|
251
|
+
specsIn(child, titlePath, out);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** One record for a report this module could not parse, with the reason. */
|
|
255
|
+
function unusable(status, path, reason) {
|
|
256
|
+
return { status, path, reason, stats: null, failures: [], omittedFailures: 0 };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Summarize one Playwright `json` report into the bounded record a tool result carries.
|
|
260
|
+
*
|
|
261
|
+
* Pure text in, record out: the caller reads the file (and decides whether it may), so this is
|
|
262
|
+
* testable against genuine reports and against malformed controls alike.
|
|
263
|
+
*
|
|
264
|
+
* @param text - The report file's text, exactly as written.
|
|
265
|
+
* @param options - Where the file is, and the consumer's redactor for error text.
|
|
266
|
+
* @param options.path - The report file's path, carried on the record for full evidence.
|
|
267
|
+
* @param options.sanitize - Redactor applied to error text (defaults to the identity).
|
|
268
|
+
* @returns The bounded record; never a verdict.
|
|
269
|
+
*/
|
|
270
|
+
export function summarizePlaywrightReport(text, options) {
|
|
271
|
+
const path = options.path;
|
|
272
|
+
const sanitize = options.sanitize ?? ((value) => value);
|
|
273
|
+
if (text.trim() === '')
|
|
274
|
+
return unusable('malformed', path, 'the report file is empty');
|
|
275
|
+
if (text.length > MAX_REPORT_PARSE_CHARS)
|
|
276
|
+
return unusable('partial', path, `the report is larger than ${MAX_REPORT_PARSE_CHARS} characters and was not summarized`);
|
|
277
|
+
let root;
|
|
278
|
+
try {
|
|
279
|
+
root = JSON.parse(text);
|
|
280
|
+
}
|
|
281
|
+
catch (_error) {
|
|
282
|
+
// A truncated or non-JSON file is not runner data at all: say malformed, never zero failures.
|
|
283
|
+
return unusable('malformed', path, 'the report file is not valid JSON (it may be truncated)');
|
|
284
|
+
}
|
|
285
|
+
const report = asRecord(root);
|
|
286
|
+
if (report === null)
|
|
287
|
+
return unusable('malformed', path, 'the report is not a JSON object');
|
|
288
|
+
const rawStats = asRecord(report.stats);
|
|
289
|
+
const expected = rawStats === null ? null : asNumber(rawStats.expected);
|
|
290
|
+
const skipped = rawStats === null ? null : asNumber(rawStats.skipped);
|
|
291
|
+
const unexpected = rawStats === null ? null : asNumber(rawStats.unexpected);
|
|
292
|
+
const flaky = rawStats === null ? null : asNumber(rawStats.flaky);
|
|
293
|
+
const durationMs = rawStats === null ? null : asNumber(rawStats.duration);
|
|
294
|
+
const stats = expected === null ||
|
|
295
|
+
skipped === null ||
|
|
296
|
+
unexpected === null ||
|
|
297
|
+
flaky === null ||
|
|
298
|
+
durationMs === null
|
|
299
|
+
? null
|
|
300
|
+
: { expected, skipped, unexpected, flaky, durationMs };
|
|
301
|
+
const suites = Array.isArray(report.suites) ? report.suites : null;
|
|
302
|
+
const errors = Array.isArray(report.errors) ? report.errors.length : 0;
|
|
303
|
+
if (suites === null)
|
|
304
|
+
return unusable('partial', path, 'the report carries no suite tree (the run was cut short)');
|
|
305
|
+
// Every spec under every project, with the describe titles that lead to it. The report's
|
|
306
|
+
// top-level entries are FILE suites — their title is the file path the spec's own `file` already
|
|
307
|
+
// carries — so that level is not part of a test's title path.
|
|
308
|
+
const specs = [];
|
|
309
|
+
for (const entry of suites) {
|
|
310
|
+
const suite = asRecord(entry);
|
|
311
|
+
if (suite !== null)
|
|
312
|
+
specsIn(suite, [], specs, true);
|
|
313
|
+
}
|
|
314
|
+
const failures = [];
|
|
315
|
+
for (const { spec, titlePath } of specs) {
|
|
316
|
+
const specTitle = asString(spec.title) ?? '';
|
|
317
|
+
const file = asString(spec.file) ?? '';
|
|
318
|
+
const line = asNumber(spec.line) ?? 0;
|
|
319
|
+
const column = asNumber(spec.column) ?? 0;
|
|
320
|
+
const specId = asString(spec.id);
|
|
321
|
+
const tests = Array.isArray(spec.tests) ? spec.tests : [];
|
|
322
|
+
for (const entry of tests) {
|
|
323
|
+
const test = asRecord(entry);
|
|
324
|
+
if (test === null)
|
|
325
|
+
continue;
|
|
326
|
+
const status = asString(test.status) ?? '';
|
|
327
|
+
const results = Array.isArray(test.results) ? test.results : [];
|
|
328
|
+
if (!isFailingTest(status, results))
|
|
329
|
+
continue;
|
|
330
|
+
const evidence = evidenceAttempt(results);
|
|
331
|
+
const titles = [...titlePath, specTitle].filter((part) => part !== '');
|
|
332
|
+
failures.push({
|
|
333
|
+
id: specId,
|
|
334
|
+
project: asString(test.projectName),
|
|
335
|
+
file: boundedPath(file),
|
|
336
|
+
line,
|
|
337
|
+
column,
|
|
338
|
+
titlePath: titles.map(boundedTitle),
|
|
339
|
+
title: boundedTitle(specTitle),
|
|
340
|
+
status,
|
|
341
|
+
attempts: results.length,
|
|
342
|
+
retry: evidence === null ? 0 : (asNumber(evidence.result.retry) ?? evidence.index),
|
|
343
|
+
durationMs: evidence === null ? 0 : (asNumber(evidence.result.duration) ?? 0),
|
|
344
|
+
error: evidence === null ? null : errorOf(evidence.result, sanitize),
|
|
345
|
+
attachments: evidence === null ? [] : attachmentsOf(evidence.result),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const omittedFailures = Math.max(0, failures.length - MAX_REPORTED_FAILURES);
|
|
350
|
+
// The runner's tally and its own tree must agree. A record whose stats name more failures than
|
|
351
|
+
// the tree lists is not a whole record — a truncated write, a merged or hand-edited file — and
|
|
352
|
+
// must not be read as complete just because both fields parsed. The bound this module applies is
|
|
353
|
+
// counted in `omittedFailures`, so it is compared against the list BEFORE the cap.
|
|
354
|
+
const namedFailures = stats === null ? null : stats.unexpected + stats.flaky;
|
|
355
|
+
const treeShort = namedFailures !== null && failures.length < namedFailures;
|
|
356
|
+
// Playwright's own global errors mean the run did not walk everything it planned to (a config
|
|
357
|
+
// error, a worker crash): the per-test entries that did parse are still the runner's own.
|
|
358
|
+
const status = stats === null || errors > 0 || treeShort ? 'partial' : 'complete';
|
|
359
|
+
const reason = stats === null
|
|
360
|
+
? 'the report carries no run stats'
|
|
361
|
+
: errors > 0
|
|
362
|
+
? `the run reported ${errors} error(s) of its own`
|
|
363
|
+
: treeShort
|
|
364
|
+
? `the run’s stats name ${namedFailures} failing test(s) but its tree lists ${failures.length}`
|
|
365
|
+
: undefined;
|
|
366
|
+
return {
|
|
367
|
+
status,
|
|
368
|
+
path,
|
|
369
|
+
...(reason === undefined ? {} : { reason }),
|
|
370
|
+
stats,
|
|
371
|
+
failures: failures.slice(0, MAX_REPORTED_FAILURES),
|
|
372
|
+
omittedFailures,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The report path a command was launched with, when it is the one this platform sets.
|
|
377
|
+
*
|
|
378
|
+
* Read back from the launched script (and from a completed task's own command) so a record written
|
|
379
|
+
* before an API restart is still found: the path is the task's own id derived by
|
|
380
|
+
* {@link playwrightReportPathFor}, never a path parsed out of the executor's command.
|
|
381
|
+
*
|
|
382
|
+
* @param command - The command as it was launched.
|
|
383
|
+
* @param expectedPath - The one path this task's report may be at.
|
|
384
|
+
* @returns True when the command sets {@link PLAYWRIGHT_REPORT_ENV} to that exact path.
|
|
385
|
+
*/
|
|
386
|
+
export function commandWritesReportTo(command, expectedPath) {
|
|
387
|
+
const quoted = new RegExp(`(?:^|[\\s;])${PLAYWRIGHT_REPORT_ENV}=(['"]?)${escapeRegExp(expectedPath)}\\1(?:[\\s;]|$)`);
|
|
388
|
+
return quoted.test(command);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The report file a background task's own scratch files live beside.
|
|
392
|
+
*
|
|
393
|
+
* Derived from the task id, never from the command: the id is unique per launch, so two runs can
|
|
394
|
+
* never read one another's report, and the path survives a process restart the same way the task's
|
|
395
|
+
* log and exit file do.
|
|
396
|
+
*
|
|
397
|
+
* @param taskId - The `mol-bg-…` handle the launch returned.
|
|
398
|
+
* @returns The report file's absolute path.
|
|
399
|
+
*/
|
|
400
|
+
export function playwrightReportPathFor(taskId) {
|
|
401
|
+
return `/tmp/${taskId}${PLAYWRIGHT_REPORT_SUFFIX}`;
|
|
402
|
+
}
|
|
403
|
+
/** A string with every regular-expression metacharacter escaped. */
|
|
404
|
+
function escapeRegExp(value) {
|
|
405
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
406
|
+
}
|
package/dist/schemas.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,
|
|
1
|
+
{"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAwW1C,CAAA"}
|
package/dist/schemas.js
CHANGED
|
@@ -71,6 +71,10 @@ export const TOOL_SCHEMAS = {
|
|
|
71
71
|
parameters: {
|
|
72
72
|
type: 'object',
|
|
73
73
|
properties: {
|
|
74
|
+
intentional_redaction_placeholder: {
|
|
75
|
+
type: 'boolean',
|
|
76
|
+
description: 'Set true only when intentionally authoring a literal [REDACTED] placeholder. Default false: newly copied output placeholders are refused. This never disables secret-output masking.',
|
|
77
|
+
},
|
|
74
78
|
path: {
|
|
75
79
|
type: 'string',
|
|
76
80
|
description: 'Path to the file to write.',
|
|
@@ -89,6 +93,10 @@ export const TOOL_SCHEMAS = {
|
|
|
89
93
|
parameters: {
|
|
90
94
|
type: 'object',
|
|
91
95
|
properties: {
|
|
96
|
+
intentional_redaction_placeholder: {
|
|
97
|
+
type: 'boolean',
|
|
98
|
+
description: 'Set true only when intentionally authoring a literal [REDACTED] placeholder. Default false: newly copied output placeholders are refused. This never disables secret-output masking.',
|
|
99
|
+
},
|
|
92
100
|
path: {
|
|
93
101
|
type: 'string',
|
|
94
102
|
description: 'Path to the file to edit.',
|
package/dist/server-command.d.ts
CHANGED
|
@@ -26,14 +26,18 @@ export declare const BACKGROUNDED: RegExp;
|
|
|
26
26
|
* A program that SERVES: the scaffold's entry (`server.ts`/`server.js`), a package manager's
|
|
27
27
|
* `start`/`dev`/`serve`/`preview` script, a Vite dev or preview server, `next start|dev`, or a
|
|
28
28
|
* Python WSGI server. Matched against a command segment's own text, and against a script's SOURCE
|
|
29
|
-
* when the command only names the script file
|
|
29
|
+
* when the command only names the script file — but NEVER against an inline program's code
|
|
30
|
+
* (`node -e …`, `python3 -c …`), where this text is the user's program rather than a file being
|
|
31
|
+
* run: `require('…/node_modules/vite')` imports a module, it does not start a dev server
|
|
32
|
+
* (xr-molecule-flash-m391 +13:13, refused as "node starts a server" for exactly that line).
|
|
30
33
|
*/
|
|
31
34
|
export declare const SERVER_PROGRAM: RegExp;
|
|
32
35
|
/**
|
|
33
36
|
* Whether a command, read from its TEXT alone, starts a server that stays up in the foreground.
|
|
34
37
|
*
|
|
35
38
|
* The over-budget reader's shape: at that point the command has already run, and only its text is
|
|
36
|
-
* left to say whether "run a smaller unit" was ever the right advice.
|
|
39
|
+
* left to say whether "run a smaller unit" was ever the right advice. Inline/stdin programs
|
|
40
|
+
* remain unclassified and use the normal bounded command runner.
|
|
37
41
|
*
|
|
38
42
|
* @param command - The command as the executor sent it.
|
|
39
43
|
* @returns True when a foreground statement of the command starts a server.
|
|
@@ -46,7 +50,9 @@ export interface ForegroundServer {
|
|
|
46
50
|
}
|
|
47
51
|
/**
|
|
48
52
|
* A FOREGROUND command that starts a server, including one that only names a script whose SOURCE
|
|
49
|
-
* imports the app's server and calls its `create()`/`listen()`.
|
|
53
|
+
* imports the app's server and calls its `create()`/`listen()`. Inline/stdin programs
|
|
54
|
+
* (`node -e …`, `python3 -c …`) use normal bounded execution: interpreting arbitrary
|
|
55
|
+
* program text is outside this filename/script preflight's contract.
|
|
50
56
|
*
|
|
51
57
|
* The script read is best-effort: a path the backend will not answer for is a command this reading
|
|
52
58
|
* cannot classify, never a refusal. A backgrounded launch, a test runner and a test file are all
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-command.d.ts","sourceRoot":"","sources":["../src/server-command.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,KAAK,sBAAsB,EAAyB,MAAM,oBAAoB,CAAA;AAKvF;;;GAGG;AACH,eAAO,MAAM,YAAY,QAA6C,CAAA;AAEtE
|
|
1
|
+
{"version":3,"file":"server-command.d.ts","sourceRoot":"","sources":["../src/server-command.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,KAAK,sBAAsB,EAAyB,MAAM,oBAAoB,CAAA;AAKvF;;;GAGG;AACH,eAAO,MAAM,YAAY,QAA6C,CAAA;AAEtE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,QACmP,CAAA;AA8G9Q;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAI5D;AAED,0EAA0E;AAC1E,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAC5C,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAmBlC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAC5C,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAiBxC;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAO5F;AAED;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAG0B,CAAA"}
|
package/dist/server-command.js
CHANGED
|
@@ -28,7 +28,10 @@ export const BACKGROUNDED = /(?:^|[^&>])&(?!&|>)|\b(?:nohup|setsid)\b/;
|
|
|
28
28
|
* A program that SERVES: the scaffold's entry (`server.ts`/`server.js`), a package manager's
|
|
29
29
|
* `start`/`dev`/`serve`/`preview` script, a Vite dev or preview server, `next start|dev`, or a
|
|
30
30
|
* Python WSGI server. Matched against a command segment's own text, and against a script's SOURCE
|
|
31
|
-
* when the command only names the script file
|
|
31
|
+
* when the command only names the script file — but NEVER against an inline program's code
|
|
32
|
+
* (`node -e …`, `python3 -c …`), where this text is the user's program rather than a file being
|
|
33
|
+
* run: `require('…/node_modules/vite')` imports a module, it does not start a dev server
|
|
34
|
+
* (xr-molecule-flash-m391 +13:13, refused as "node starts a server" for exactly that line).
|
|
32
35
|
*/
|
|
33
36
|
export const SERVER_PROGRAM = /\bserver\.[cm]?[jt]s\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:start|dev|serve|preview)\b|\bvite\s+(?:preview|dev|serve)\b|\bvite(?![.\w-])(?!\s+(?:build|optimize)\b)|\bnext\s+(?:start|dev)\b|\b(?:uvicorn|gunicorn)\b|\bflask\s+run\b|\bpython\d?\s+-m\s+http\.server\b/;
|
|
34
37
|
/** Programs that run a script FILE, so the file's own source decides whether it serves. */
|
|
@@ -66,6 +69,26 @@ const SERVE_SCRIPTS = /^(?:start|dev|serve|preview)$/;
|
|
|
66
69
|
const TEST_RUNNERS = /^(?:hurl|jest|mocha|playwright|vitest)$/;
|
|
67
70
|
/** A script's SOURCE that binds a listener even when it does not name a server file. */
|
|
68
71
|
const STARTS_LISTENING = /\b(?:server|app)\.listen\s*\(/;
|
|
72
|
+
/** Node inline-program flags; the shell parser already separated their arguments. */
|
|
73
|
+
const NODE_INLINE_FLAG = /^(?:-[A-Za-z]*[ep]|--(?:eval|print)(?:=|$))/;
|
|
74
|
+
/** Python inline programs use -c, including combined interpreter flags. */
|
|
75
|
+
const PYTHON_INLINE_FLAG = /^-[A-Za-z]*c$/;
|
|
76
|
+
/**
|
|
77
|
+
* Inline/stdin programs have no server filename to classify. Their source is arbitrary
|
|
78
|
+
* code; the normal bounded command runner, rather than another partial JS/Python
|
|
79
|
+
* parser, owns their execution. Named script and package-server checks remain below.
|
|
80
|
+
* @param segment - One parsed shell statement.
|
|
81
|
+
* @returns Whether the interpreter reads an inline/stdin program.
|
|
82
|
+
*/
|
|
83
|
+
function isInlineProgram(segment) {
|
|
84
|
+
const flag = /^python(?:3(?:\.\d+)?)?$/.test(segment.program)
|
|
85
|
+
? PYTHON_INLINE_FLAG
|
|
86
|
+
: NODE_INLINE_FLAG;
|
|
87
|
+
return segment.args.some((word) => {
|
|
88
|
+
const arg = bare(word);
|
|
89
|
+
return arg === '-' || flag.test(arg);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
69
92
|
/** A word with its shell quotes taken off (`'src/server.ts'` → `src/server.ts`). */
|
|
70
93
|
function bare(word) {
|
|
71
94
|
const match = /^(['"])(.*)\1$/s.exec(word);
|
|
@@ -75,6 +98,8 @@ function bare(word) {
|
|
|
75
98
|
* Whether one parsed statement starts a server: a package manager's serving script, or a program
|
|
76
99
|
* that IS a server, named in command position (so `cat server.ts` is a read, not a launch).
|
|
77
100
|
*
|
|
101
|
+
* Inline/stdin programs use normal bounded execution: their code is not a server filename.
|
|
102
|
+
*
|
|
78
103
|
* @param segment - One statement of the command.
|
|
79
104
|
* @returns True when the statement starts a server that does not exit.
|
|
80
105
|
*/
|
|
@@ -87,7 +112,11 @@ function runsServerProgram(segment) {
|
|
|
87
112
|
if (script !== undefined && SERVE_SCRIPTS.test(script))
|
|
88
113
|
return true;
|
|
89
114
|
}
|
|
90
|
-
|
|
115
|
+
if (!SERVER_PROGRAMS.has(segment.program))
|
|
116
|
+
return false;
|
|
117
|
+
if (isInlineProgram(segment))
|
|
118
|
+
return false;
|
|
119
|
+
return SERVER_PROGRAM.test(segment.text);
|
|
91
120
|
}
|
|
92
121
|
/**
|
|
93
122
|
* The script file a runner statement executes, when it names one.
|
|
@@ -98,6 +127,9 @@ function runsServerProgram(segment) {
|
|
|
98
127
|
function scriptPath(segment) {
|
|
99
128
|
if (!SCRIPT_RUNNERS.has(segment.program))
|
|
100
129
|
return null;
|
|
130
|
+
// An inline program (`node -e …`) is code, not a file to read.
|
|
131
|
+
if (isInlineProgram(segment))
|
|
132
|
+
return null;
|
|
101
133
|
for (const arg of segment.args) {
|
|
102
134
|
const word = bare(arg);
|
|
103
135
|
if (word.startsWith('-'))
|
|
@@ -111,7 +143,8 @@ function scriptPath(segment) {
|
|
|
111
143
|
* Whether a command, read from its TEXT alone, starts a server that stays up in the foreground.
|
|
112
144
|
*
|
|
113
145
|
* The over-budget reader's shape: at that point the command has already run, and only its text is
|
|
114
|
-
* left to say whether "run a smaller unit" was ever the right advice.
|
|
146
|
+
* left to say whether "run a smaller unit" was ever the right advice. Inline/stdin programs
|
|
147
|
+
* remain unclassified and use the normal bounded command runner.
|
|
115
148
|
*
|
|
116
149
|
* @param command - The command as the executor sent it.
|
|
117
150
|
* @returns True when a foreground statement of the command starts a server.
|
|
@@ -124,7 +157,9 @@ export function startsServerProgram(command) {
|
|
|
124
157
|
}
|
|
125
158
|
/**
|
|
126
159
|
* A FOREGROUND command that starts a server, including one that only names a script whose SOURCE
|
|
127
|
-
* imports the app's server and calls its `create()`/`listen()`.
|
|
160
|
+
* imports the app's server and calls its `create()`/`listen()`. Inline/stdin programs
|
|
161
|
+
* (`node -e …`, `python3 -c …`) use normal bounded execution: interpreting arbitrary
|
|
162
|
+
* program text is outside this filename/script preflight's contract.
|
|
128
163
|
*
|
|
129
164
|
* The script read is best-effort: a path the backend will not answer for is a command this reading
|
|
130
165
|
* cannot classify, never a refusal. A backgrounded launch, a test runner and a test file are all
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AITool } from '@molecule/api-ai';
|
|
2
|
+
/** Options for exposing a small tool set while retaining access to other allowed tools. */
|
|
3
|
+
export interface DeferredToolCatalogOptions {
|
|
4
|
+
/** The caller's current permission/mode-filtered tools. Discovery never grants access. */
|
|
5
|
+
available: () => readonly AITool[];
|
|
6
|
+
/** Tools that should be offered without a discovery call. */
|
|
7
|
+
initialNames: ReadonlySet<string>;
|
|
8
|
+
/** Previously loaded names, in load order; useful across turns and compaction. */
|
|
9
|
+
loadedNames?: readonly string[];
|
|
10
|
+
/** Persist names after a successful load. No tool content is passed to this callback. */
|
|
11
|
+
onLoad?: (names: string[]) => void;
|
|
12
|
+
}
|
|
13
|
+
/** A provider-independent deferred catalog. The caller still executes and authorizes tools. */
|
|
14
|
+
export interface DeferredToolCatalog {
|
|
15
|
+
loader: AITool;
|
|
16
|
+
/** Initial tools, loader, then requested tools in stable load order. */
|
|
17
|
+
select: () => AITool[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Offer common tools and let the model load other schemas by exact name.
|
|
21
|
+
*
|
|
22
|
+
* This is ordinary allowlist selection, not semantic routing or authorization.
|
|
23
|
+
* Pass tools already filtered by the current mode and permissions. Loading never
|
|
24
|
+
* executes a tool; the caller must keep its normal execution-time guards. Loaded
|
|
25
|
+
* schemas append in request order and stay available, avoiding an LRU that changes
|
|
26
|
+
* the schema prefix on every request. Only names need persistence across turns.
|
|
27
|
+
*
|
|
28
|
+
* @param options - Allowed tools, initial selection and optional durable load state.
|
|
29
|
+
* @returns A loader tool and the current provider-facing selection.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createDeferredToolCatalog(options: DeferredToolCatalogOptions): DeferredToolCatalog;
|
|
32
|
+
//# sourceMappingURL=tool-catalog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-catalog.d.ts","sourceRoot":"","sources":["../src/tool-catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAE9C,2FAA2F;AAC3F,MAAM,WAAW,0BAA0B;IACzC,0FAA0F;IAC1F,SAAS,EAAE,MAAM,SAAS,MAAM,EAAE,CAAA;IAClC,6DAA6D;IAC7D,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IACjC,kFAAkF;IAClF,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,yFAAyF;IACzF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;CACnC;AAED,+FAA+F;AAC/F,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAA;IACd,wEAAwE;IACxE,MAAM,EAAE,MAAM,MAAM,EAAE,CAAA;CACvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,0BAA0B,GAClC,mBAAmB,CAsErB"}
|