ravensight-playtest 0.2.7 → 0.2.8
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 +34 -0
- package/package.json +1 -1
- package/src/cli.js +1 -0
- package/src/commands/run.js +51 -3
- package/src/commands/upload.js +10 -0
- package/src/run/aggregate.js +8 -2
- package/src/run/args.js +2 -1
- package/src/run/drivers/godot.js +5 -0
- package/src/run/drivers/stdio.js +1 -0
- package/src/run/drivers/web.js +116 -3
- package/src/run/drivers/webdriver.js +1 -0
- package/src/run/dropped.js +294 -0
- package/src/run/personaLoop.js +33 -8
- package/src/run/placeholders.js +186 -0
- package/src/run/resume.js +16 -1
- package/src/run/synthetic.js +2 -0
- package/src/run/tools.js +10 -4
- package/src/upload/index.js +9 -1
- package/src/upload/queue.js +13 -5
package/README.md
CHANGED
|
@@ -206,6 +206,7 @@ splitting the job in two is what gets everything played.
|
|
|
206
206
|
| `--upload-video` | Upload `session.webm` as well as the reports. |
|
|
207
207
|
| `--upload-transcript` | Upload `transcript.jsonl` as well as the reports. |
|
|
208
208
|
| `--dry-upload` | List every file and byte that would be sent, and send nothing. |
|
|
209
|
+
| `--key-modifier <meta\|control>` | The modifier the browser driver uses for editing shortcuts such as select all. Defaults to this machine's own (Command on macOS, Control elsewhere); set it when the browser's platform is not this machine's. `RAVENSIGHT_KEY_MODIFIER` does the same. |
|
|
209
210
|
| `--headed`, `--headless` | Show or hide the browser. |
|
|
210
211
|
| `--verbose` | Print each turn. |
|
|
211
212
|
|
|
@@ -257,6 +258,38 @@ How the report step reads what happened:
|
|
|
257
258
|
- The model is told never to write calendar dates. `report.md` carries the
|
|
258
259
|
CLI's own `Generated at` timestamp under its title, and the aggregate report's
|
|
259
260
|
header uses its `generated_at`.
|
|
261
|
+
- A template slot the model copied instead of filling (`**Date:** <run date>`,
|
|
262
|
+
`<driver>`, `<N actions>`) is filled from the CLI's own facts, or "not
|
|
263
|
+
recorded" when it has none, in `report.md`, the prose of `report.json` and
|
|
264
|
+
the aggregate. Other angle brackets are left as written: keys such as
|
|
265
|
+
`<Enter>`, elements such as `<form>`, types such as `Array<string>`, links,
|
|
266
|
+
code and quoted game text.
|
|
267
|
+
- The server keeps a finding only when one of its evidence references points
|
|
268
|
+
at a file the run uploaded. After delivery the CLI marks the findings it
|
|
269
|
+
dropped (by the server's `dropped_finding_ids`, or by applying the same rule
|
|
270
|
+
to the files it uploaded when its count matches): `dropped: true` in
|
|
271
|
+
`report.json`, out of the summary in `report.md` and into a section headed
|
|
272
|
+
"Dropped by the server (no usable evidence)", and out of the aggregate and
|
|
273
|
+
the synthetic telemetry, so the local report counts what the dashboard
|
|
274
|
+
counts. When it cannot tell which, `report.md` says how many and that the
|
|
275
|
+
dashboard is the record. The rewritten files are journalled as local edits,
|
|
276
|
+
and `upload <job>` never re-sends a completed run's report files.
|
|
277
|
+
|
|
278
|
+
### What personas do well
|
|
279
|
+
|
|
280
|
+
A persona plays like a first time player at human speed: menus, onboarding,
|
|
281
|
+
the first few minutes, unclear text, screens that do not respond, and whether
|
|
282
|
+
it would come back. On the web, when a persona replaces a field's value (the
|
|
283
|
+
`type` tool with `replace`), the driver clears it with `fill`, the same way on
|
|
284
|
+
macOS, Windows and Linux; ordinary typing is keystrokes. An editing shortcut
|
|
285
|
+
such as select all uses the platform's own modifier (Command on a Mac, Control
|
|
286
|
+
elsewhere, or `--key-modifier`).
|
|
287
|
+
|
|
288
|
+
Each persona starts at the game's URL as a fresh player with no account, so a
|
|
289
|
+
sign-up form with validation can use up a whole unit before the game itself is
|
|
290
|
+
reached. Point `--build-url` at a guest or demo path, or say where to start in
|
|
291
|
+
the brief's `how_to_start` note (for example "choose Play as guest"), when the
|
|
292
|
+
sign-up is not what you want tested.
|
|
260
293
|
|
|
261
294
|
### fix
|
|
262
295
|
|
|
@@ -429,6 +462,7 @@ CI can treat 2 as a success with a warning.
|
|
|
429
462
|
| `RAVENSIGHT_API_URL` | The API base URL. Defaults to `https://api.ravensight.io`. |
|
|
430
463
|
| `RAVENSIGHT_DASHBOARD_URL` | Where the dashboard is, if it is not derivable from the API URL. |
|
|
431
464
|
| `RAVENSIGHT_GAME_ID` | The game, when there is no linked repo. |
|
|
465
|
+
| `RAVENSIGHT_KEY_MODIFIER` | `meta` or `control`: the browser driver's modifier for editing shortcuts, as `--key-modifier`. The flag wins when both are set. |
|
|
432
466
|
| `RAVENSIGHT_INGEST_KEY` | The game's ingest key, so a web run can emit its synthetic session into your event stream. `ingest_key` in `.ravensight/config.json` does the same. Without either the run still reports; it just sends no telemetry. |
|
|
433
467
|
| `RAVENSIGHT_GODOT` | Path to the Godot binary. Tried first, then `$GODOT`, then `$GODOT_PATH`, then the PATH, then the platform's usual install locations. `--godot-path` beats all of them. |
|
|
434
468
|
| `RAVENSIGHT_FFMPEG` | Path to ffmpeg. |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -63,6 +63,7 @@ Run options (parsed by the runner, see src/run/args.js):
|
|
|
63
63
|
--upload-video upload session.webm as well as the reports
|
|
64
64
|
--upload-transcript upload transcript.jsonl as well as the reports
|
|
65
65
|
--dry-upload list every file and byte that would be sent, send nothing
|
|
66
|
+
--key-modifier <name> meta or control: the modifier for editing shortcuts in the browser, default the platform's own
|
|
66
67
|
--headed show the browser
|
|
67
68
|
--headless hide it
|
|
68
69
|
--verbose print each turn
|
package/src/commands/run.js
CHANGED
|
@@ -13,6 +13,7 @@ import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.
|
|
|
13
13
|
import { readBridgeConfig } from "../run/drivers/stdio.js";
|
|
14
14
|
import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
|
|
15
15
|
import { runPersona } from '../run/personaLoop.js';
|
|
16
|
+
import { reconcileDroppedFindings } from '../run/dropped.js';
|
|
16
17
|
import * as stateFile from '../run/state.js';
|
|
17
18
|
import { emitRunEvents } from '../run/synthetic.js';
|
|
18
19
|
|
|
@@ -90,6 +91,8 @@ async function run(flags, injected, deps, log) {
|
|
|
90
91
|
const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
|
|
91
92
|
|
|
92
93
|
if (options.controller && !['standard', 'jev'].includes(options.controller)) { log('Choose --controller standard or --controller jev.'); return EXIT.ENVIRONMENT; }
|
|
94
|
+
const keyModifier = resolveKeyModifier(options.keyModifier);
|
|
95
|
+
if (keyModifier.error) { log(keyModifier.error); return EXIT.ENVIRONMENT; }
|
|
93
96
|
const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
|
|
94
97
|
const target = resolveTarget(options, context.config, driverName);
|
|
95
98
|
if (!target) {
|
|
@@ -497,7 +500,7 @@ export async function playPersonaRun({
|
|
|
497
500
|
// Headless is right for a browser and wrong for Godot: a headless Godot
|
|
498
501
|
// routes no GUI input, so it is a smoke-test mode rather than a way to play.
|
|
499
502
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
500
|
-
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
|
|
503
|
+
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier: options.keyModifier }));
|
|
501
504
|
// The ledger is created here rather than inside the loop so the heartbeat
|
|
502
505
|
// can feed it the server's own figure for what this run has spent. Prices
|
|
503
506
|
// are server side only, so that answer is the only real one.
|
|
@@ -563,7 +566,7 @@ export async function playPersonaRun({
|
|
|
563
566
|
const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
|
|
564
567
|
await stateFile.patchRun(repoRoot, journal, runId, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
|
|
565
568
|
const delivery = {};
|
|
566
|
-
const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, delivery });
|
|
569
|
+
const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, repoRoot, delivery });
|
|
567
570
|
if (isReportStepBudget(failed)) {
|
|
568
571
|
// The money ran out while the report was being written. The partial
|
|
569
572
|
// report went up and the run is completed as budget_exceeded from
|
|
@@ -713,6 +716,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
713
716
|
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
|
|
714
717
|
if (answer && answer.dropped_findings > 0) {
|
|
715
718
|
log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
719
|
+
await markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount: answer.dropped_findings, droppedIds: answer.dropped_finding_ids, options, log });
|
|
716
720
|
}
|
|
717
721
|
if (answer && Array.isArray(answer.report_lint) && answer.report_lint.length > 0) {
|
|
718
722
|
log(`run ${runId}: report lint: ${answer.report_lint.join('; ')}`);
|
|
@@ -729,6 +733,28 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
729
733
|
}
|
|
730
734
|
}
|
|
731
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Bring the local report into line with what the server kept, so report.md,
|
|
738
|
+
* report.json, the aggregate and the telemetry count what the dashboard
|
|
739
|
+
* counts. A failure here never fails the delivery: the run is already
|
|
740
|
+
* complete on the server.
|
|
741
|
+
*/
|
|
742
|
+
async function markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount, droppedIds, options, log }) {
|
|
743
|
+
try {
|
|
744
|
+
const result = await reconcileDroppedFindings({ directory, droppedCount, droppedIds, options, journal: { jobId, runId, repoRoot } });
|
|
745
|
+
if (!result) return;
|
|
746
|
+
if (outcome) {
|
|
747
|
+
outcome.report = result.report;
|
|
748
|
+
outcome.reportMarkdown = result.markdown;
|
|
749
|
+
}
|
|
750
|
+
log(result.identified
|
|
751
|
+
? `run ${runId}: marked ${result.dropped.join(', ')} as dropped in report.md and report.json, under "Dropped by the server".`
|
|
752
|
+
: `run ${runId}: could not tell which finding(s) the server dropped, so report.md says so and the dashboard is the record.`);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
log(`run ${runId}: could not mark the dropped finding(s) in the local report (${String(error && error.message)}).`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
732
758
|
/**
|
|
733
759
|
* Whether a run ran out of money in the report step: it is budget_exceeded
|
|
734
760
|
* and the server had already accepted `reporting` (or `uploading`) for it.
|
|
@@ -1040,9 +1066,12 @@ export async function resolveSetup(deps, options, log) {
|
|
|
1040
1066
|
*
|
|
1041
1067
|
* @returns {Object}
|
|
1042
1068
|
*/
|
|
1043
|
-
export function driverOptions({ headless, driverName, target, directory, entry, jobId }) {
|
|
1069
|
+
export function driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier }) {
|
|
1070
|
+
// Only the browser driver sends key chords with a modifier.
|
|
1071
|
+
const modifier = driverName === 'playwright_web' ? resolveKeyModifier(keyModifier).modifier : null;
|
|
1044
1072
|
return {
|
|
1045
1073
|
headless,
|
|
1074
|
+
...(modifier ? { modifier } : {}),
|
|
1046
1075
|
playtest: {
|
|
1047
1076
|
token: entry.playtest_token || null,
|
|
1048
1077
|
runId: entry.run_id,
|
|
@@ -1054,6 +1083,25 @@ export function driverOptions({ headless, driverName, target, directory, entry,
|
|
|
1054
1083
|
};
|
|
1055
1084
|
}
|
|
1056
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* The modifier the web driver uses for editing shortcuts, from
|
|
1088
|
+
* `--key-modifier meta|control`, else `RAVENSIGHT_KEY_MODIFIER`, else the
|
|
1089
|
+
* platform's own (Command on macOS, Control elsewhere, decided in the driver).
|
|
1090
|
+
* For a remote or containerised browser whose platform is not this machine's.
|
|
1091
|
+
*
|
|
1092
|
+
* @param {string} [value] the flag's value
|
|
1093
|
+
* @param {Object} [env]
|
|
1094
|
+
* @returns {{modifier: 'Meta'|'Control'|null, error?: string}}
|
|
1095
|
+
*/
|
|
1096
|
+
export function resolveKeyModifier(value, env = process.env) {
|
|
1097
|
+
const raw = value !== undefined && value !== null && value !== '' ? value : env.RAVENSIGHT_KEY_MODIFIER;
|
|
1098
|
+
if (raw === undefined || raw === null || raw === '') return { modifier: null };
|
|
1099
|
+
const key = String(raw).trim().toLowerCase();
|
|
1100
|
+
if (['meta', 'cmd', 'command'].includes(key)) return { modifier: 'Meta' };
|
|
1101
|
+
if (['control', 'ctrl'].includes(key)) return { modifier: 'Control' };
|
|
1102
|
+
return { modifier: null, error: `Choose --key-modifier meta or --key-modifier control (or set RAVENSIGHT_KEY_MODIFIER), not "${raw}".` };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1057
1105
|
/**
|
|
1058
1106
|
* The persona reports on disk, for the aggregate. A partial report (written
|
|
1059
1107
|
* from the notes when the money ran out) is loaded as a `budget_exceeded`
|
package/src/commands/upload.js
CHANGED
|
@@ -8,6 +8,9 @@ import { pending } from '../upload/queue.js';
|
|
|
8
8
|
import { uploadRunDir } from '../upload/index.js';
|
|
9
9
|
import { JOB_UPLOAD_PATHS } from '../upload/allowlist.js';
|
|
10
10
|
import { ui } from '../ui/index.js';
|
|
11
|
+
|
|
12
|
+
/** Report files never re-sent for a run the journal marks completed. */
|
|
13
|
+
const COMPLETED_RUN_REPORTS = Object.freeze(['report.md', 'report.json']);
|
|
11
14
|
import { CliError, ExitCode } from '../errors.js';
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -73,11 +76,18 @@ async function drainJob(context, jobId, flags) {
|
|
|
73
76
|
const results = [];
|
|
74
77
|
|
|
75
78
|
for (const runId of runIds) {
|
|
79
|
+
// A completed run's report went to the server with `complete`, which
|
|
80
|
+
// validated it; the copy on disk may since have been marked locally
|
|
81
|
+
// (dropped findings), and sending it again would replace the validated
|
|
82
|
+
// artifact, or be refused once the job is closed.
|
|
83
|
+
const completed = Boolean(state && state.runs && state.runs[runId] && state.runs[runId].completed);
|
|
76
84
|
const result = await uploadRunDir(paths.runDir(jobId, runId, { repoRoot }), {
|
|
77
85
|
api: context.api,
|
|
78
86
|
gameId,
|
|
79
87
|
jobId,
|
|
80
88
|
runId,
|
|
89
|
+
exclude: completed ? COMPLETED_RUN_REPORTS : null,
|
|
90
|
+
excludeReason: 'the run is complete; its report was delivered with complete',
|
|
81
91
|
includeVideo,
|
|
82
92
|
includeTranscript,
|
|
83
93
|
dryRun: Boolean(flags.dryRun),
|
package/src/run/aggregate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { fillPlaceholders } from './placeholders.js';
|
|
2
3
|
import { writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { parseJsonArray, parseJsonObject } from './json.js';
|
|
@@ -331,7 +332,10 @@ export async function buildAggregate({
|
|
|
331
332
|
persona: run.persona,
|
|
332
333
|
state: run.state,
|
|
333
334
|
report: run.report,
|
|
334
|
-
|
|
335
|
+
// A finding the server dropped for want of evidence is marked on disk
|
|
336
|
+
// after delivery (./dropped.js) and kept out of the aggregate, so the
|
|
337
|
+
// job report counts what the dashboard counts.
|
|
338
|
+
findings: (run.report.findings || []).filter(finding => !(finding && finding.dropped === true))
|
|
335
339
|
}));
|
|
336
340
|
const personasRun = new Set(inputs.map(run => run.persona).filter(Boolean)).size;
|
|
337
341
|
|
|
@@ -624,7 +628,9 @@ export function renderAggregateMarkdown(json) {
|
|
|
624
628
|
? `Observations dropped for lack of evidence:\n${json.observations.map(o => `- ${o.persona}: ${o.text}`).join('\n')}`
|
|
625
629
|
: 'No dropped observations.');
|
|
626
630
|
lines.push('');
|
|
627
|
-
|
|
631
|
+
// The synthesis writes the TL;DR, and a model can copy a template slot into
|
|
632
|
+
// it as easily as into a persona report.
|
|
633
|
+
return fillPlaceholders(lines.join('\n'), { generatedAt: json.generated_at, game: json.game_id });
|
|
628
634
|
}
|
|
629
635
|
|
|
630
636
|
/**
|
package/src/run/args.js
CHANGED
|
@@ -22,7 +22,8 @@ const VALUED = new Map([
|
|
|
22
22
|
['--cli', 'cli'],
|
|
23
23
|
['--max-actions', 'maxActions'],
|
|
24
24
|
['--concurrency', 'concurrency'],
|
|
25
|
-
['--quality', 'quality']
|
|
25
|
+
['--quality', 'quality'],
|
|
26
|
+
['--key-modifier', 'keyModifier']
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
/** Options that are just present or absent. */
|
package/src/run/drivers/godot.js
CHANGED
|
@@ -360,6 +360,11 @@ export class GodotDriver {
|
|
|
360
360
|
if (!action || typeof action.kind !== 'string') {
|
|
361
361
|
return { ok: false, error: 'action needs a kind', changed: false };
|
|
362
362
|
}
|
|
363
|
+
// Typing more characters is not replacing a value, and claiming it was
|
|
364
|
+
// would join the old text to the new one without saying so.
|
|
365
|
+
if (action.kind === 'type' && (action.replace || action.target)) {
|
|
366
|
+
return { ok: false, error: 'replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus', changed: false };
|
|
367
|
+
}
|
|
363
368
|
try {
|
|
364
369
|
await this._perform(action);
|
|
365
370
|
} catch (error) {
|
package/src/run/drivers/stdio.js
CHANGED
|
@@ -79,6 +79,7 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
|
|
|
79
79
|
observe,
|
|
80
80
|
async act(action) {
|
|
81
81
|
if (!['type', 'key', 'click', 'wait'].includes(action.kind)) return { ok: false, changed: false, error: 'Unsupported bridge action.' };
|
|
82
|
+
if (action.kind === 'type' && (action.replace || action.target)) return { ok: false, changed: false, error: 'replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus' };
|
|
82
83
|
const before = lastHash;
|
|
83
84
|
const safe = action.kind === 'type' ? { kind: 'type', text: String(action.text || '').slice(0, 2000), submit: Boolean(action.submit) }
|
|
84
85
|
: action.kind === 'key' ? { kind: 'key', key: String(action.key || '').slice(0, 64) }
|
package/src/run/drivers/web.js
CHANGED
|
@@ -35,6 +35,12 @@ export const MAX_WAIT_MS = 10000;
|
|
|
35
35
|
/** How long a single Playwright call may take before it is an error, not a hang. */
|
|
36
36
|
export const ACTION_TIMEOUT_MS = 15000;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* How long a replace waits for a focused field. Short, because nothing
|
|
40
|
+
* focused is an ordinary answer (a canvas game), not a page still loading.
|
|
41
|
+
*/
|
|
42
|
+
export const FOCUSED_FILL_TIMEOUT_MS = 2000;
|
|
43
|
+
|
|
38
44
|
/**
|
|
39
45
|
* The web observation ceiling. An aria snapshot is re-sent on every turn the
|
|
40
46
|
* observation stays in context, and the run's model budget is fixed, so a busy
|
|
@@ -116,6 +122,74 @@ export function unquoteKey(body) {
|
|
|
116
122
|
return body;
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The modifier a platform's editing shortcuts use: Command on macOS,
|
|
127
|
+
* Control everywhere else. Chromium follows the host's conventions even
|
|
128
|
+
* headless, so on a Mac `Control+A` in a text field moves the caret to the
|
|
129
|
+
* start of the line instead of selecting the text, and a persona that
|
|
130
|
+
* "selects all and retypes" ends up with two values joined together.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} [platform] a `process.platform` value
|
|
133
|
+
* @returns {'Meta'|'Control'}
|
|
134
|
+
*/
|
|
135
|
+
export function platformModifier(platform = process.platform) {
|
|
136
|
+
return platform === 'darwin' ? 'Meta' : 'Control';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Editing shortcuts (select all, copy, cut, paste, undo, redo) that follow the platform modifier. */
|
|
140
|
+
const EDITING_KEYS = new Set(['a', 'c', 'v', 'x', 'z', 'y']);
|
|
141
|
+
|
|
142
|
+
/** Spellings a model writes for a modifier, mapped to Playwright's names. */
|
|
143
|
+
const MODIFIER_ALIASES = Object.freeze({
|
|
144
|
+
ctrl: 'Control',
|
|
145
|
+
control: 'Control',
|
|
146
|
+
cmd: 'Meta',
|
|
147
|
+
command: 'Meta',
|
|
148
|
+
meta: 'Meta',
|
|
149
|
+
super: 'Meta',
|
|
150
|
+
win: 'Meta',
|
|
151
|
+
mod: 'ControlOrMeta',
|
|
152
|
+
controlormeta: 'ControlOrMeta',
|
|
153
|
+
alt: 'Alt',
|
|
154
|
+
option: 'Alt',
|
|
155
|
+
opt: 'Alt',
|
|
156
|
+
shift: 'Shift'
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rewrite a key chord the persona asked for into one that does the same
|
|
161
|
+
* thing on this machine.
|
|
162
|
+
*
|
|
163
|
+
* Modifier spellings are normalised (`Ctrl`, `Cmd`, `Option`), and an
|
|
164
|
+
* editing shortcut held with Control or Command (`Control+A`, `Cmd+V`,
|
|
165
|
+
* `Ctrl+Shift+Z`) uses the platform modifier, because the persona means
|
|
166
|
+
* "select all", not "the Control key". Any other chord is left as written:
|
|
167
|
+
* a game that binds Control+S to something of its own wants Control on
|
|
168
|
+
* every platform.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} key
|
|
171
|
+
* @param {'Meta'|'Control'} modifier the platform modifier
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
export function normalizeChord(key, modifier) {
|
|
175
|
+
const value = String(key == null ? '' : key);
|
|
176
|
+
// A lone "+" is the plus key, and "Shift++" is Shift and plus.
|
|
177
|
+
const parts = value.length > 1 ? value.split(/\+(?=.)/) : [value];
|
|
178
|
+
if (parts.length < 2) return value;
|
|
179
|
+
const mapped = parts.map((part, index) => {
|
|
180
|
+
if (index === parts.length - 1) return part;
|
|
181
|
+
return MODIFIER_ALIASES[part.trim().toLowerCase()] || part;
|
|
182
|
+
});
|
|
183
|
+
const last = mapped[mapped.length - 1];
|
|
184
|
+
const editing = EDITING_KEYS.has(String(last).toLowerCase());
|
|
185
|
+
return mapped.map((part, index) => {
|
|
186
|
+
if (index === mapped.length - 1) return part;
|
|
187
|
+
if (part === 'ControlOrMeta') return modifier;
|
|
188
|
+
if (editing && (part === 'Control' || part === 'Meta')) return modifier;
|
|
189
|
+
return part;
|
|
190
|
+
}).join('+');
|
|
191
|
+
}
|
|
192
|
+
|
|
119
193
|
const KNOWN_SELECTOR_ENGINES = /^(role|text|css|xpath|id|data-testid|internal:)=/;
|
|
120
194
|
|
|
121
195
|
/**
|
|
@@ -231,9 +305,14 @@ function injectPlaytestIdentity(identity) {
|
|
|
231
305
|
* @param {{token?: string, runId?: string, jobId?: string, persona?: string}} [opts.playtest]
|
|
232
306
|
* the run's playtest identity, handed to the page so the game's own SDK
|
|
233
307
|
* session is marked synthetic
|
|
308
|
+
* @param {string} [opts.platform] the host platform, `process.platform` by
|
|
309
|
+
* default; decides the editing modifier
|
|
310
|
+
* @param {'Meta'|'Control'} [opts.modifier] the editing modifier, overriding
|
|
311
|
+
* the one the platform implies
|
|
234
312
|
* @returns {Object} a driver
|
|
235
313
|
*/
|
|
236
|
-
export function createWebDriver({ importer, headless = true, playtest = null } = {}) {
|
|
314
|
+
export function createWebDriver({ importer, headless = true, playtest = null, platform = process.platform, modifier = null } = {}) {
|
|
315
|
+
const editModifier = modifier === 'Meta' || modifier === 'Control' ? modifier : platformModifier(platform);
|
|
237
316
|
let browser = null;
|
|
238
317
|
let context = null;
|
|
239
318
|
let page = null;
|
|
@@ -297,8 +376,42 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
|
|
|
297
376
|
return truncateObservation(snap.aria ? compactAriaSnapshot(snap.text) : snap.text, WEB_OBSERVATION_MAX_CHARS);
|
|
298
377
|
}
|
|
299
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Type, or with `replace` put this text in place of what the field holds.
|
|
381
|
+
*
|
|
382
|
+
* Replacing goes through Playwright's `fill`, which clears the field
|
|
383
|
+
* natively on every platform, rather than a select-all chord: the chord is
|
|
384
|
+
* Command+A on a Mac and Control+A elsewhere, and getting it wrong joins the
|
|
385
|
+
* old value to the new one. A target names the field; without one the
|
|
386
|
+
* focused element is filled. Only an element `fill` refuses (a canvas, a
|
|
387
|
+
* custom widget) falls back to the platform select-all, then typing.
|
|
388
|
+
*
|
|
389
|
+
* @param {any} current the page
|
|
390
|
+
* @param {{text: string, target?: string, replace?: boolean}} action
|
|
391
|
+
*/
|
|
392
|
+
async function typeText(current, action) {
|
|
393
|
+
const text = String(action.text == null ? '' : action.text);
|
|
394
|
+
const field = action.target ? resolveLocator(current, action.target) : null;
|
|
395
|
+
if (action.replace) {
|
|
396
|
+
try {
|
|
397
|
+
await (field || current.locator('*:focus')).fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
|
|
398
|
+
return;
|
|
399
|
+
} catch {
|
|
400
|
+
// Not something fill can write to. Focus it if it was named, then
|
|
401
|
+
// clear it the way a player on this machine would.
|
|
402
|
+
if (field) await field.click({ timeout: ACTION_TIMEOUT_MS });
|
|
403
|
+
await current.keyboard.press(`${editModifier}+a`);
|
|
404
|
+
await current.keyboard.press('Backspace');
|
|
405
|
+
}
|
|
406
|
+
} else if (field) {
|
|
407
|
+
await field.click({ timeout: ACTION_TIMEOUT_MS });
|
|
408
|
+
}
|
|
409
|
+
if (text) await current.keyboard.type(text, { delay: 20 });
|
|
410
|
+
}
|
|
411
|
+
|
|
300
412
|
return {
|
|
301
413
|
name: 'playwright_web',
|
|
414
|
+
editModifier,
|
|
302
415
|
keepObservations: WEB_KEEP_OBSERVATIONS,
|
|
303
416
|
|
|
304
417
|
/**
|
|
@@ -407,10 +520,10 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
|
|
|
407
520
|
try {
|
|
408
521
|
switch (action.kind) {
|
|
409
522
|
case 'key':
|
|
410
|
-
await current.keyboard.press(
|
|
523
|
+
await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
|
|
411
524
|
break;
|
|
412
525
|
case 'type':
|
|
413
|
-
await current
|
|
526
|
+
await typeText(current, action);
|
|
414
527
|
if (action.submit) await current.keyboard.press('Enter');
|
|
415
528
|
break;
|
|
416
529
|
case 'click': {
|
|
@@ -51,6 +51,7 @@ export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=gl
|
|
|
51
51
|
}else if(action.kind==='swipe'){
|
|
52
52
|
const from=point(action.from),to=point(action.to);await actions('pointer',[{type:'pointerMove',duration:0,origin:'viewport',...from},{type:'pointerDown',button:0},{type:'pointerMove',duration:Math.min(2000,Math.max(100,Number(action.durationMs)||500)),origin:'viewport',...to},{type:'pointerUp',button:0}],touch);
|
|
53
53
|
}else if(action.kind==='key'||action.kind==='type'){
|
|
54
|
+
if(action.kind==='type'&&(action.replace||action.target))throw new DriverError('replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus');
|
|
54
55
|
const chars=action.kind==='type'?Array.from(String(action.text||'').slice(0,2000)):[KEYS[action.key]||action.key];
|
|
55
56
|
if(chars.some(key=>typeof key!=='string'||Array.from(key).length!==1))throw new DriverError('Unsupported device key.');
|
|
56
57
|
if(action.submit)chars.push(KEYS.Enter);
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sha256File } from '../fsutil.js';
|
|
4
|
+
import { planRunDir } from '../upload/index.js';
|
|
5
|
+
import { record, STATUS } from '../upload/queue.js';
|
|
6
|
+
import { TRANSCRIPT_PATH, VIDEO_PATHS } from '../upload/allowlist.js';
|
|
7
|
+
import { RUN_FILES } from './paths.js';
|
|
8
|
+
import { fillPlaceholders } from './placeholders.js';
|
|
9
|
+
import { writeReportFiles } from './report.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Findings the server dropped, marked in the local report so it agrees with
|
|
13
|
+
* the dashboard.
|
|
14
|
+
*
|
|
15
|
+
* At `complete` the server keeps a finding only when at least one of its
|
|
16
|
+
* evidence refs resolves to a file the run actually uploaded, and answers
|
|
17
|
+
* with how many it dropped, not which. Without this step the CLI said "the
|
|
18
|
+
* server dropped 1 finding" while report.md and report.json still listed it,
|
|
19
|
+
* major severity and all, and the dashboard showed one fewer.
|
|
20
|
+
*
|
|
21
|
+
* The CLI uploaded the files itself, so it can apply the server's rule to
|
|
22
|
+
* the same list and name them. When its count matches the server's, each
|
|
23
|
+
* one is marked `dropped: true` in report.json and moved in report.md to a
|
|
24
|
+
* section of its own: kept for the developer to read, out of the top issues,
|
|
25
|
+
* the aggregate and the synthetic telemetry. When the counts disagree it
|
|
26
|
+
* does not guess which; it says how many the server dropped and points at
|
|
27
|
+
* the dashboard.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The section dropped findings are moved to. */
|
|
31
|
+
export const DROPPED_HEADING = '## Dropped by the server (no usable evidence)';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The uploaded path, or candidate paths, an evidence entry needs. The same
|
|
35
|
+
* mapping as the server's `manifestPathForEvidence`.
|
|
36
|
+
*
|
|
37
|
+
* @param {{type?: string, ref?: string}} evidence
|
|
38
|
+
* @returns {string|Array<string>|null}
|
|
39
|
+
*/
|
|
40
|
+
export function evidencePath(evidence) {
|
|
41
|
+
if (!evidence || typeof evidence.type !== 'string') return null;
|
|
42
|
+
const ref = typeof evidence.ref === 'string' ? evidence.ref : '';
|
|
43
|
+
switch (evidence.type) {
|
|
44
|
+
case 'screenshot':
|
|
45
|
+
return ref || null;
|
|
46
|
+
case 'video_ts':
|
|
47
|
+
return [...VIDEO_PATHS];
|
|
48
|
+
case 'transcript_step':
|
|
49
|
+
return TRANSCRIPT_PATH;
|
|
50
|
+
case 'file_line':
|
|
51
|
+
return ref ? ref.split(':')[0] : null;
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The findings none of whose evidence resolves to an uploaded file.
|
|
59
|
+
*
|
|
60
|
+
* @param {Object} report
|
|
61
|
+
* @param {Iterable<string>} uploaded the run relative paths that were uploaded
|
|
62
|
+
* @returns {Array<Object>}
|
|
63
|
+
*/
|
|
64
|
+
export function unbackedFindings(report, uploaded) {
|
|
65
|
+
const paths = new Set(uploaded);
|
|
66
|
+
const findings = Array.isArray(report && report.findings) ? report.findings : [];
|
|
67
|
+
return findings.filter(finding => {
|
|
68
|
+
if (!finding || finding.dropped === true) return false;
|
|
69
|
+
const evidence = Array.isArray(finding.evidence) ? finding.evidence : [];
|
|
70
|
+
return !evidence.some(entry => {
|
|
71
|
+
const wanted = evidencePath(entry);
|
|
72
|
+
if (wanted === null) return false;
|
|
73
|
+
return (Array.isArray(wanted) ? wanted : [wanted]).some(candidate => paths.has(candidate));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* report.json with the named findings marked.
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} report
|
|
82
|
+
* @param {Set<string>} ids
|
|
83
|
+
* @returns {Object}
|
|
84
|
+
*/
|
|
85
|
+
export function markDroppedFindings(report, ids) {
|
|
86
|
+
return {
|
|
87
|
+
...report,
|
|
88
|
+
findings: (report.findings || []).map(finding => (finding && ids.has(finding.id) ? { ...finding, dropped: true } : finding))
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SECTION = /^## /;
|
|
93
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* report.md with the dropped findings out of the summary and in a section of
|
|
97
|
+
* their own.
|
|
98
|
+
*
|
|
99
|
+
* A list item that names a dropped finding id and no kept one is removed
|
|
100
|
+
* wherever it is, which is how "Top issues" names them (`F003 ...`). A
|
|
101
|
+
* section left with nothing under it says so. Then the dropped findings are
|
|
102
|
+
* listed under {@link DROPPED_HEADING}, replacing one written before, so a
|
|
103
|
+
* second reconcile does not stack them.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} markdown
|
|
106
|
+
* @param {Array<Object>} dropped the dropped findings, from report.json
|
|
107
|
+
* @param {Array<Object>} kept the findings the server kept
|
|
108
|
+
* @param {{unidentified?: number}} [opts] a count the CLI could not attribute
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } = {}) {
|
|
112
|
+
const lines = withoutDroppedSection(String(markdown || '').replace(/\s+$/, '')).split('\n');
|
|
113
|
+
const droppedIds = dropped.map(finding => String(finding.id)).filter(Boolean);
|
|
114
|
+
const keptIds = kept.map(finding => String(finding.id)).filter(Boolean);
|
|
115
|
+
const names = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(id)}(?![0-9])`).test(line);
|
|
116
|
+
|
|
117
|
+
const out = [];
|
|
118
|
+
let section = null;
|
|
119
|
+
let sectionRemoved = 0;
|
|
120
|
+
let sectionKept = 0;
|
|
121
|
+
const closeSection = () => {
|
|
122
|
+
if (section !== null && sectionRemoved > 0 && sectionKept === 0) {
|
|
123
|
+
// Trailing blank lines stay after the note, as they were after the list.
|
|
124
|
+
let blanks = 0;
|
|
125
|
+
while (out.length > 0 && out[out.length - 1].trim() === '') {
|
|
126
|
+
out.pop();
|
|
127
|
+
blanks += 1;
|
|
128
|
+
}
|
|
129
|
+
out.push('- Nothing here that the server kept. See the dropped findings below.');
|
|
130
|
+
for (let i = 0; i < blanks; i += 1) out.push('');
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
if (SECTION.test(line)) {
|
|
135
|
+
closeSection();
|
|
136
|
+
section = line;
|
|
137
|
+
sectionRemoved = 0;
|
|
138
|
+
sectionKept = 0;
|
|
139
|
+
out.push(line);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (LIST_ITEM.test(line) && droppedIds.some(id => names(line, id)) && !keptIds.some(id => names(line, id))) {
|
|
143
|
+
sectionRemoved += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (LIST_ITEM.test(line)) sectionKept += 1;
|
|
147
|
+
out.push(line);
|
|
148
|
+
}
|
|
149
|
+
closeSection();
|
|
150
|
+
|
|
151
|
+
const tail = ['', DROPPED_HEADING];
|
|
152
|
+
if (dropped.length > 0) {
|
|
153
|
+
tail.push(
|
|
154
|
+
'The server keeps a finding only when at least one of its evidence references points at a file this run uploaded. These did not, so the dashboard does not show them and they are not counted. They are kept here so you can judge them yourself.',
|
|
155
|
+
''
|
|
156
|
+
);
|
|
157
|
+
for (const finding of dropped) {
|
|
158
|
+
const evidence = (Array.isArray(finding.evidence) ? finding.evidence : [])
|
|
159
|
+
.map(entry => `${entry && entry.type} ${entry && entry.ref}`)
|
|
160
|
+
.join(', ') || 'none';
|
|
161
|
+
// The title is the model's prose, copied here; fill any slot it carries first.
|
|
162
|
+
tail.push(`- ${finding.id} (${finding.severity}, ${finding.category}): ${fillPlaceholders(String(finding.title || ''))}. Evidence cited: ${evidence}.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (unidentified > 0) {
|
|
166
|
+
tail.push(
|
|
167
|
+
`The server dropped ${unidentified} finding(s) with no usable evidence, and this report could not tell which, so every finding above is still listed and counted here. The dashboard shows the findings the server kept.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return `${out.join('\n')}\n${tail.join('\n')}\n`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Remove a dropped section written by an earlier reconcile.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} markdown
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
function withoutDroppedSection(markdown) {
|
|
180
|
+
const lines = markdown.split('\n');
|
|
181
|
+
const start = lines.findIndex(line => line.trim() === DROPPED_HEADING);
|
|
182
|
+
if (start === -1) return markdown;
|
|
183
|
+
let end = lines.findIndex((line, index) => index > start && SECTION.test(line));
|
|
184
|
+
if (end === -1) end = lines.length;
|
|
185
|
+
return [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\s+$/, '');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function escapeRegExp(value) {
|
|
189
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mark the findings the server dropped in a run directory's report files.
|
|
194
|
+
*
|
|
195
|
+
* The server's own list (`dropped_finding_ids`) is used when it sends one.
|
|
196
|
+
* Without it the CLI applies the server's evidence rule to its upload plan,
|
|
197
|
+
* and marks only when that count matches the server's. Findings marked by an
|
|
198
|
+
* earlier reconcile count towards the server's number, so running it twice
|
|
199
|
+
* changes nothing.
|
|
200
|
+
*
|
|
201
|
+
* Rewriting the two files changes their sha256, which would make `upload
|
|
202
|
+
* <job>` send them again over the validated artifacts. So each new hash is
|
|
203
|
+
* recorded in the job's upload journal as a local edit, which the journal
|
|
204
|
+
* treats as already delivered.
|
|
205
|
+
*
|
|
206
|
+
* @param {Object} args
|
|
207
|
+
* @param {string} args.directory the run directory
|
|
208
|
+
* @param {number} args.droppedCount `dropped_findings` from the complete answer
|
|
209
|
+
* @param {Array<string>} [args.droppedIds] `dropped_finding_ids` from the complete answer, when the server sends it
|
|
210
|
+
* @param {{uploadVideo?: boolean, uploadTranscript?: boolean}} [args.options] what was uploaded
|
|
211
|
+
* @param {{jobId: string, runId: string, repoRoot?: string}} [args.journal] where to record the local edit
|
|
212
|
+
* @param {(dir: string, opts: Object) => Promise<{files: Array<{path: string}>}>} [args.plan] test seam
|
|
213
|
+
* @returns {Promise<{report: Object, markdown: string, identified: boolean, dropped: Array<string>, source: string}|null>}
|
|
214
|
+
* null when there was nothing to mark or no report on disk
|
|
215
|
+
*/
|
|
216
|
+
export async function reconcileDroppedFindings({ directory, droppedCount, droppedIds = null, options = {}, journal = null, plan = planRunDir }) {
|
|
217
|
+
const serverIds = Array.isArray(droppedIds) ? droppedIds.map(String).filter(Boolean) : null;
|
|
218
|
+
const count = serverIds && serverIds.length > 0 ? serverIds.length : Number(droppedCount) || 0;
|
|
219
|
+
if (count <= 0) return null;
|
|
220
|
+
let report;
|
|
221
|
+
let markdown;
|
|
222
|
+
try {
|
|
223
|
+
report = JSON.parse(await readFile(path.join(directory, RUN_FILES.reportJson), 'utf8'));
|
|
224
|
+
markdown = await readFile(path.join(directory, RUN_FILES.reportMd), 'utf8');
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
if (!report || !Array.isArray(report.findings)) return null;
|
|
229
|
+
|
|
230
|
+
const already = report.findings.filter(finding => finding && finding.dropped === true).map(finding => finding.id);
|
|
231
|
+
let ids;
|
|
232
|
+
let identified;
|
|
233
|
+
let source;
|
|
234
|
+
if (serverIds && serverIds.length > 0) {
|
|
235
|
+
const present = new Set(report.findings.filter(Boolean).map(finding => finding.id));
|
|
236
|
+
ids = new Set(serverIds.filter(id => present.has(id)));
|
|
237
|
+
identified = ids.size === serverIds.length;
|
|
238
|
+
source = 'server';
|
|
239
|
+
} else {
|
|
240
|
+
const planned = await plan(directory, { video: Boolean(options.uploadVideo), transcript: Boolean(options.uploadTranscript) });
|
|
241
|
+
const unbacked = unbackedFindings(report, planned.files.map(file => file.path)).map(finding => finding.id);
|
|
242
|
+
identified = already.length + unbacked.length === count;
|
|
243
|
+
ids = new Set(identified ? [...already, ...unbacked] : already);
|
|
244
|
+
source = 'plan';
|
|
245
|
+
}
|
|
246
|
+
const marked = markDroppedFindings(report, ids);
|
|
247
|
+
const dropped = marked.findings.filter(finding => finding && finding.dropped === true);
|
|
248
|
+
const kept = marked.findings.filter(finding => !(finding && finding.dropped === true));
|
|
249
|
+
const rewritten = rewriteMarkdown(markdown, dropped, kept, { unidentified: identified ? 0 : count - dropped.length });
|
|
250
|
+
await writeReportFiles({ runDirectory: directory, markdown: rewritten, report: marked });
|
|
251
|
+
await recordLocalEdits(directory, journal);
|
|
252
|
+
return { report: marked, markdown: rewritten, identified, dropped: [...ids], source };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Record the rewritten report files' hashes as local edits, so the upload
|
|
257
|
+
* journal treats them as delivered rather than owed.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} directory
|
|
260
|
+
* @param {{jobId: string, runId: string, repoRoot?: string}|null} journal
|
|
261
|
+
*/
|
|
262
|
+
async function recordLocalEdits(directory, journal) {
|
|
263
|
+
const where = journal && journal.jobId && journal.repoRoot ? journal : journalFromDirectory(directory) || (journal && journal.jobId ? journal : null);
|
|
264
|
+
if (!where) return;
|
|
265
|
+
for (const name of [RUN_FILES.reportMd, RUN_FILES.reportJson]) {
|
|
266
|
+
const file = path.join(directory, name);
|
|
267
|
+
const info = await stat(file);
|
|
268
|
+
await record(where.jobId, {
|
|
269
|
+
kind: 'run',
|
|
270
|
+
run_id: where.runId,
|
|
271
|
+
path: name,
|
|
272
|
+
sha256: await sha256File(file),
|
|
273
|
+
size: info.size,
|
|
274
|
+
status: STATUS.LOCAL_EDIT,
|
|
275
|
+
detail: 'dropped findings marked after complete; the delivered copy stays the record'
|
|
276
|
+
}, where.repoRoot ? { repoRoot: where.repoRoot } : {});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The job, run and repository a run directory belongs to, from its own path
|
|
282
|
+
* (`<repo>/.ravensight/jobs/<jobId>/runs/<runId>`), or null for any other.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} directory
|
|
285
|
+
* @returns {{jobId: string, runId: string, repoRoot: string}|null}
|
|
286
|
+
*/
|
|
287
|
+
export function journalFromDirectory(directory) {
|
|
288
|
+
const parts = path.resolve(directory).split(path.sep);
|
|
289
|
+
const n = parts.length;
|
|
290
|
+
if (n < 6 || parts[n - 2] !== 'runs' || parts[n - 4] !== 'jobs' || parts[n - 5] !== '.ravensight') return null;
|
|
291
|
+
return { jobId: parts[n - 3], runId: parts[n - 1], repoRoot: parts.slice(0, n - 5).join(path.sep) || path.sep };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export default { reconcileDroppedFindings, unbackedFindings, markDroppedFindings, rewriteMarkdown, evidencePath, DROPPED_HEADING };
|
package/src/run/personaLoop.js
CHANGED
|
@@ -11,6 +11,7 @@ import { assertNoSecrets } from './secretScan.js';
|
|
|
11
11
|
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
12
12
|
import { createTranscript } from './transcript.js';
|
|
13
13
|
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
14
|
+
import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* One persona's playthrough: PLAYING then REPORTING (spec 03).
|
|
@@ -651,7 +652,8 @@ async function reportPhase({
|
|
|
651
652
|
'',
|
|
652
653
|
'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
|
|
653
654
|
'',
|
|
654
|
-
'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated.',
|
|
655
|
+
'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated, so leave the template\'s Date out rather than writing a placeholder for it.',
|
|
656
|
+
'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
|
|
655
657
|
'',
|
|
656
658
|
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
657
659
|
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
|
|
@@ -764,7 +766,11 @@ async function reportPhase({
|
|
|
764
766
|
if (!markdown) {
|
|
765
767
|
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
766
768
|
}
|
|
767
|
-
|
|
769
|
+
const generatedAt = new Date().toISOString();
|
|
770
|
+
const facts = reportFacts({ persona, driverName, target, play });
|
|
771
|
+
markdown = stampGeneratedAt(markdown, generatedAt, facts);
|
|
772
|
+
// The same slots in the prose the dashboard shows from report.json.
|
|
773
|
+
report = fillReportPlaceholders(report, { generatedAt, ...facts });
|
|
768
774
|
|
|
769
775
|
await writeReportFiles({ runDirectory, markdown, report });
|
|
770
776
|
return { report, markdown, lint };
|
|
@@ -1083,14 +1089,14 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
|
|
|
1083
1089
|
play,
|
|
1084
1090
|
reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
|
|
1085
1091
|
});
|
|
1086
|
-
const stamped = stampGeneratedAt(markdown, new Date().toISOString());
|
|
1092
|
+
const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1087
1093
|
await writeReportFiles({ runDirectory, markdown: stamped, report });
|
|
1088
1094
|
outcome.report = report;
|
|
1089
1095
|
outcome.reportMarkdown = stamped;
|
|
1090
1096
|
}
|
|
1091
1097
|
|
|
1092
1098
|
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1093
|
-
const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString());
|
|
1099
|
+
const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1094
1100
|
assertNoSecrets('report.md', markdown);
|
|
1095
1101
|
await writeFile(path.join(runDirectory, RUN_FILES.reportMd), markdown, 'utf8');
|
|
1096
1102
|
}
|
|
@@ -1216,13 +1222,32 @@ export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] }
|
|
|
1216
1222
|
* @param {string} generatedAt an ISO timestamp
|
|
1217
1223
|
* @returns {string}
|
|
1218
1224
|
*/
|
|
1219
|
-
export function stampGeneratedAt(markdown, generatedAt) {
|
|
1225
|
+
export function stampGeneratedAt(markdown, generatedAt, facts = {}) {
|
|
1220
1226
|
const line = `**Generated at:** ${generatedAt}`;
|
|
1221
1227
|
const lines = String(markdown || '').split('\n').filter(entry => !/^\*\*Generated at:\*\* /.test(entry));
|
|
1222
1228
|
const heading = lines.findIndex(entry => /^# /.test(entry));
|
|
1223
|
-
if (heading === -1)
|
|
1224
|
-
lines.splice(heading + 1, 0, line);
|
|
1225
|
-
|
|
1229
|
+
if (heading === -1) lines.unshift(line, '');
|
|
1230
|
+
else lines.splice(heading + 1, 0, line);
|
|
1231
|
+
// The template's own slots (`**Date:** <date>`) are filled from what the
|
|
1232
|
+
// runner knows, because the prompt forbids the model a calendar date and a
|
|
1233
|
+
// model that obeys both writes the placeholder itself.
|
|
1234
|
+
return fillPlaceholders(lines.join('\n'), { generatedAt, ...facts });
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* What the runner knows about a run, for the template slots a report left
|
|
1239
|
+
* unfilled.
|
|
1240
|
+
*
|
|
1241
|
+
* @returns {{driver: string, target: string, persona: string, actionsTaken: number, quitReason: string}}
|
|
1242
|
+
*/
|
|
1243
|
+
function reportFacts({ persona, driverName, target, play }) {
|
|
1244
|
+
return {
|
|
1245
|
+
driver: driverName,
|
|
1246
|
+
target,
|
|
1247
|
+
persona: persona ? persona.name || persona.slug : undefined,
|
|
1248
|
+
actionsTaken: play ? play.actionsTaken : undefined,
|
|
1249
|
+
quitReason: play ? play.quitReason : undefined
|
|
1250
|
+
};
|
|
1226
1251
|
}
|
|
1227
1252
|
|
|
1228
1253
|
/**
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template placeholders a model copied into a report instead of filling.
|
|
3
|
+
*
|
|
4
|
+
* The persona skill's report template reads `**Date:** <date> ...`, and the
|
|
5
|
+
* report prompt says never to write a calendar date, so a model that obeys
|
|
6
|
+
* both writes the placeholder itself (`<run date>`) and the developer reads
|
|
7
|
+
* it as the report's date.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately narrow, because angle brackets in a report are usually real:
|
|
10
|
+
* a key (`<Enter>`, `<Ctrl+A>`), an element (`<form>`, `<title>`), a type
|
|
11
|
+
* (`Array<string>`), a link, or the game's own text. So only two things are
|
|
12
|
+
* treated as a slot:
|
|
13
|
+
*
|
|
14
|
+
* 1. A slot the runner knows (date, driver, target, persona, actions, quit
|
|
15
|
+
* reason, game, version), filled from its own facts, or "not recorded"
|
|
16
|
+
* when it has no value for it.
|
|
17
|
+
* 2. Lowercase words in brackets on a `**Field:**` line, the shape of the
|
|
18
|
+
* template's header, which become "not recorded".
|
|
19
|
+
*
|
|
20
|
+
* Never touched: a `<` straight after a word character, a key name, anything
|
|
21
|
+
* quoted, HTML, autolinks, code spans and fenced blocks.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What a slot with no value becomes. */
|
|
25
|
+
export const NOT_RECORDED = 'not recorded';
|
|
26
|
+
|
|
27
|
+
const HTML_TAGS = new Set([
|
|
28
|
+
'a', 'abbr', 'article', 'aside', 'b', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'code', 'dd', 'del',
|
|
29
|
+
'details', 'dialog', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5',
|
|
30
|
+
'h6', 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'li', 'link', 'main', 'mark',
|
|
31
|
+
'meta', 'nav', 'ol', 'option', 'p', 'picture', 'pre', 'q', 's', 'samp', 'script', 'section', 'select', 'small',
|
|
32
|
+
'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea',
|
|
33
|
+
'tfoot', 'th', 'thead', 'title', 'tr', 'u', 'ul', 'video'
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const KEY_NAMES = new Set([
|
|
37
|
+
'enter', 'return', 'tab', 'space', 'spacebar', 'esc', 'escape', 'backspace', 'delete', 'del', 'insert', 'home', 'end',
|
|
38
|
+
'pageup', 'pagedown', 'up', 'down', 'left', 'right', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift',
|
|
39
|
+
'ctrl', 'control', 'alt', 'option', 'cmd', 'command', 'meta', 'super', 'win', 'fn', 'capslock'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** `<...>` with no nested bracket and no line break, short enough to be a template slot. */
|
|
43
|
+
const ANGLE = /<([^<>\n]{1,80})>/g;
|
|
44
|
+
|
|
45
|
+
/** A line in the template's header shape: `**Date:** ...`. */
|
|
46
|
+
const FIELD_LINE = /\*\*[^*\n]+:\*\*/;
|
|
47
|
+
|
|
48
|
+
const QUOTES = { '"': '"', "'": "'", '“': '”', '‘': '’', '`': '`' };
|
|
49
|
+
|
|
50
|
+
function normalise(inner) {
|
|
51
|
+
return String(inner).trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether the bracket is a slot the runner knows, and its value if it has one.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} inner
|
|
58
|
+
* @param {Object} facts
|
|
59
|
+
* @returns {{known: boolean, value: string|null}}
|
|
60
|
+
*/
|
|
61
|
+
function knownSlot(inner, facts) {
|
|
62
|
+
const key = normalise(inner);
|
|
63
|
+
const date = facts.generatedAt ? String(facts.generatedAt).slice(0, 10) : null;
|
|
64
|
+
const actions = facts.actionsTaken === null || facts.actionsTaken === undefined || !Number.isFinite(Number(facts.actionsTaken))
|
|
65
|
+
? null
|
|
66
|
+
: Number(facts.actionsTaken);
|
|
67
|
+
const slot = value => ({ known: true, value: value === null || value === undefined || value === '' ? null : String(value) });
|
|
68
|
+
if (/^(run |report |session )?(date|timestamp|date and time)$/.test(key) || key === 'generated at') return slot(date);
|
|
69
|
+
if (/^driver( name)?$/.test(key)) return slot(facts.driver);
|
|
70
|
+
if (/^(target|target url|build url|game url)$/.test(key)) return slot(facts.target);
|
|
71
|
+
if (/^persona( name| slug)?$/.test(key)) return slot(facts.persona);
|
|
72
|
+
if (/^(n|number of) actions$/.test(key)) return slot(actions === null ? null : `${actions} actions`);
|
|
73
|
+
if (key === 'actions taken') return slot(actions);
|
|
74
|
+
if (/^(quit|stop) reason$/.test(key)) return slot(facts.quitReason);
|
|
75
|
+
if (key === 'game' || key === 'game name') return slot(facts.game);
|
|
76
|
+
if (key === 'version' || key === 'game version') return slot(facts.version);
|
|
77
|
+
return { known: false, value: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The replacement for one bracket match, or null to leave it as written.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} text the prose segment the match is in
|
|
84
|
+
* @param {string} whole the match, brackets included
|
|
85
|
+
* @param {string} inner
|
|
86
|
+
* @param {number} offset
|
|
87
|
+
* @param {string} line the whole line, for the field check
|
|
88
|
+
* @param {Object} facts
|
|
89
|
+
* @returns {string|null}
|
|
90
|
+
*/
|
|
91
|
+
function replacementFor(text, whole, inner, offset, line, facts) {
|
|
92
|
+
const before = text[offset - 1];
|
|
93
|
+
const after = text[offset + whole.length];
|
|
94
|
+
// `Array<string>`, `Map<K, V>`: a type, not a slot.
|
|
95
|
+
if (before !== undefined && /\w/.test(before)) return null;
|
|
96
|
+
// The game's own words, quoted as evidence.
|
|
97
|
+
if (before !== undefined && QUOTES[before] !== undefined && QUOTES[before] === after) return null;
|
|
98
|
+
const value = String(inner).trim();
|
|
99
|
+
if (!/^[A-Za-z]/.test(value)) return null;
|
|
100
|
+
// A key or a chord: <Enter>, <Ctrl+A>, <Shift + Tab>.
|
|
101
|
+
if (value.includes('+') || KEY_NAMES.has(value.toLowerCase())) return null;
|
|
102
|
+
const known = knownSlot(value, facts);
|
|
103
|
+
if (known.known) return known.value || NOT_RECORDED;
|
|
104
|
+
if (FIELD_LINE.test(line) && /^[a-z]+(?: [a-z]+)*$/.test(value) && !HTML_TAGS.has(value)) return NOT_RECORDED;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Walk the prose of a markdown document (not fenced blocks, not code spans),
|
|
110
|
+
* handing each segment and its whole line to `fn`.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} markdown
|
|
113
|
+
* @param {(text: string, line: string) => string} fn
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function mapProse(markdown, fn) {
|
|
117
|
+
let fenced = false;
|
|
118
|
+
return String(markdown == null ? '' : markdown).split('\n').map(line => {
|
|
119
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
120
|
+
fenced = !fenced;
|
|
121
|
+
return line;
|
|
122
|
+
}
|
|
123
|
+
if (fenced) return line;
|
|
124
|
+
return line.split(/(`[^`]*`)/).map(part => (part.length > 1 && part.startsWith('`') && part.endsWith('`') ? part : fn(part, line))).join('');
|
|
125
|
+
}).join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fill the slots the runner knows, and the header fields it does not, from
|
|
130
|
+
* its own facts. Everything else in angle brackets is left as written.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} markdown
|
|
133
|
+
* @param {{generatedAt?: string, driver?: string, target?: string, persona?: string,
|
|
134
|
+
* actionsTaken?: number, quitReason?: string, game?: string, version?: string}} [facts]
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function fillPlaceholders(markdown, facts = {}) {
|
|
138
|
+
return mapProse(markdown, (text, line) => text.replace(ANGLE, (whole, inner, offset) => {
|
|
139
|
+
const replacement = replacementFor(text, whole, inner, offset, line, facts);
|
|
140
|
+
return replacement === null ? whole : replacement;
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every slot `fillPlaceholders` would still replace, for tests and a last check.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} markdown
|
|
148
|
+
* @returns {Array<string>}
|
|
149
|
+
*/
|
|
150
|
+
export function findPlaceholders(markdown) {
|
|
151
|
+
const found = [];
|
|
152
|
+
mapProse(markdown, (text, line) => {
|
|
153
|
+
for (const match of text.matchAll(ANGLE)) {
|
|
154
|
+
if (replacementFor(text, match[0], match[1], match.index, line, {}) !== null) found.push(match[0]);
|
|
155
|
+
}
|
|
156
|
+
return text;
|
|
157
|
+
});
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The same filling over the prose fields of report.json, which the dashboard
|
|
163
|
+
* shows: titles, descriptions, repro steps and quotes.
|
|
164
|
+
*
|
|
165
|
+
* @param {Object} report
|
|
166
|
+
* @param {Object} [facts]
|
|
167
|
+
* @returns {Object}
|
|
168
|
+
*/
|
|
169
|
+
export function fillReportPlaceholders(report, facts = {}) {
|
|
170
|
+
if (!report || !Array.isArray(report.findings)) return report;
|
|
171
|
+
const fill = value => (typeof value === 'string' ? fillPlaceholders(value, facts) : value);
|
|
172
|
+
return {
|
|
173
|
+
...report,
|
|
174
|
+
findings: report.findings.map(finding => {
|
|
175
|
+
if (!finding || typeof finding !== 'object') return finding;
|
|
176
|
+
const out = { ...finding };
|
|
177
|
+
for (const field of ['title', 'description', 'persona_quote']) {
|
|
178
|
+
if (field in out) out[field] = fill(out[field]);
|
|
179
|
+
}
|
|
180
|
+
if (Array.isArray(out.repro_steps)) out.repro_steps = out.repro_steps.map(fill);
|
|
181
|
+
return out;
|
|
182
|
+
})
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default { fillPlaceholders, findPlaceholders, fillReportPlaceholders, NOT_RECORDED };
|
package/src/run/resume.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getDeps } from './deps.js';
|
|
|
3
3
|
import EXIT from './exit.js';
|
|
4
4
|
import { runDir as runDirFor } from './paths.js';
|
|
5
5
|
import { buildPartialReport, readTranscript, shouldFinalize } from './regenerate.js';
|
|
6
|
+
import { reconcileDroppedFindings } from './dropped.js';
|
|
6
7
|
import { createReportValidator, writeReportFiles } from './report.js';
|
|
7
8
|
import * as stateFile from './state.js';
|
|
8
9
|
import { playJob, resolvePersonas } from '../commands/run.js';
|
|
@@ -221,7 +222,7 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
|
|
|
221
222
|
// `interrupted` can go to `reporting`, which is where this run actually is:
|
|
222
223
|
// it has a report now. Then `uploading`, then the delivery.
|
|
223
224
|
await api.runs.transition(gameId, jobId, row.run_id, 'reporting', { reason: 'rebuilt from transcript' });
|
|
224
|
-
await deps.finalizeRun(directory, {
|
|
225
|
+
const answer = await deps.finalizeRun(directory, {
|
|
225
226
|
api,
|
|
226
227
|
gameId,
|
|
227
228
|
jobId,
|
|
@@ -237,6 +238,20 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
|
|
|
237
238
|
report_md: markdown
|
|
238
239
|
}
|
|
239
240
|
});
|
|
241
|
+
if (answer && answer.dropped_findings > 0) {
|
|
242
|
+
log(`run ${row.run_id}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
243
|
+
try {
|
|
244
|
+
await reconcileDroppedFindings({
|
|
245
|
+
directory,
|
|
246
|
+
droppedCount: answer.dropped_findings,
|
|
247
|
+
droppedIds: answer.dropped_finding_ids,
|
|
248
|
+
options,
|
|
249
|
+
journal: { jobId, runId: row.run_id, repoRoot }
|
|
250
|
+
});
|
|
251
|
+
} catch (markError) {
|
|
252
|
+
log(`run ${row.run_id}: could not mark the dropped finding(s) in the local report (${String(markError && markError.message)}).`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
240
255
|
} catch (error) {
|
|
241
256
|
log(`run ${row.run_id}: could not be finalised (${String(error && error.message)}); it will be replayed.`);
|
|
242
257
|
return false;
|
package/src/run/synthetic.js
CHANGED
|
@@ -193,6 +193,8 @@ export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, pageSdk
|
|
|
193
193
|
|
|
194
194
|
session.track('playtest_run_started', { driver });
|
|
195
195
|
for (const finding of Array.isArray(report && report.findings) ? report.findings : []) {
|
|
196
|
+
// The server kept no record of a dropped finding, so neither does the game's telemetry.
|
|
197
|
+
if (finding && finding.dropped === true) continue;
|
|
196
198
|
session.track('playtest_finding', {
|
|
197
199
|
finding_id: finding.id,
|
|
198
200
|
severity: finding.severity,
|
package/src/run/tools.js
CHANGED
|
@@ -86,7 +86,7 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
name: 'type',
|
|
89
|
-
description: 'Type text. Spends an action. Set submit to press Enter afterwards.',
|
|
89
|
+
description: 'Type text. Spends an action. Set submit to press Enter afterwards. To correct or clear a text field, set replace: the field is emptied first and holds only this text afterwards (an empty text clears it). Do that rather than a select-all key chord, which differs between platforms.',
|
|
90
90
|
input_schema: {
|
|
91
91
|
type: 'object',
|
|
92
92
|
additionalProperties: false,
|
|
@@ -94,6 +94,8 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
94
94
|
properties: {
|
|
95
95
|
text: { type: 'string' },
|
|
96
96
|
submit: { type: 'boolean' },
|
|
97
|
+
replace: { type: 'boolean', description: 'Empty the field first, so it holds only this text. playwright_web only; other drivers refuse it.' },
|
|
98
|
+
target: { type: 'string', description: 'playwright_web only, other drivers refuse it: the field to type into, named like a click target, such as textbox "Email". Without it, the focused field.' },
|
|
97
99
|
thought_in_character: thought,
|
|
98
100
|
note
|
|
99
101
|
}
|
|
@@ -101,7 +103,7 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
101
103
|
},
|
|
102
104
|
{
|
|
103
105
|
name: 'key',
|
|
104
|
-
description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1.',
|
|
106
|
+
description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1. Hold a modifier with +, such as Shift+Tab; editing shortcuts such as Control+A are sent with this platform\'s own modifier. To clear a text field, use type with replace instead.',
|
|
105
107
|
input_schema: {
|
|
106
108
|
type: 'object',
|
|
107
109
|
additionalProperties: false,
|
|
@@ -187,8 +189,12 @@ export function actionFor(toolUse) {
|
|
|
187
189
|
return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
|
|
188
190
|
? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
|
|
189
191
|
: { kind: 'click', target: String(input.target || '') };
|
|
190
|
-
case 'type':
|
|
191
|
-
|
|
192
|
+
case 'type': {
|
|
193
|
+
const action = { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
|
|
194
|
+
if (input.replace === true) action.replace = true;
|
|
195
|
+
if (typeof input.target === 'string' && input.target.trim()) action.target = input.target;
|
|
196
|
+
return action;
|
|
197
|
+
}
|
|
192
198
|
case 'key':
|
|
193
199
|
return { kind: 'key', key: String(input.key || '') };
|
|
194
200
|
case 'navigate':
|
package/src/upload/index.js
CHANGED
|
@@ -274,7 +274,7 @@ export async function uploadRunDir(runDir, options) {
|
|
|
274
274
|
const {
|
|
275
275
|
api, gameId, jobId, runId = null,
|
|
276
276
|
includeVideo = false, includeTranscript = false,
|
|
277
|
-
dryRun = false, force = false, only = null, allowedPaths = null,
|
|
277
|
+
dryRun = false, force = false, only = null, allowedPaths = null, exclude = null, excludeReason = 'excluded',
|
|
278
278
|
onFile, repoRoot, fetch: fetchImpl, sleep
|
|
279
279
|
} = options;
|
|
280
280
|
|
|
@@ -295,6 +295,14 @@ export async function uploadRunDir(runDir, options) {
|
|
|
295
295
|
files = files.filter(file => wanted.has(file.path));
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
if (Array.isArray(exclude) && exclude.length > 0) {
|
|
299
|
+
const unwanted = new Set(exclude);
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
if (unwanted.has(file.path)) skipped.push({ path: file.path, reason: excludeReason });
|
|
302
|
+
}
|
|
303
|
+
files = files.filter(file => !unwanted.has(file.path));
|
|
304
|
+
}
|
|
305
|
+
|
|
298
306
|
if (!force) {
|
|
299
307
|
const done = await uploadedKeys(jobId, journalOptions);
|
|
300
308
|
const remaining = [];
|
package/src/upload/queue.js
CHANGED
|
@@ -14,7 +14,10 @@ import { paths } from '../paths.js';
|
|
|
14
14
|
* was.
|
|
15
15
|
*
|
|
16
16
|
* Each line is `{ at, kind, run_id, path, sha256, size, status, detail }`.
|
|
17
|
-
* `status` is one of `queued`, `uploaded`, `failed`, `skipped`.
|
|
17
|
+
* `status` is one of `queued`, `uploaded`, `failed`, `skipped`, `local_edit`.
|
|
18
|
+
* A `local_edit` is a file the CLI changed on disk after it was delivered
|
|
19
|
+
* (dropped findings marked in a run's report): the delivered copy is the
|
|
20
|
+
* record, so the new hash counts as done and is never sent again.
|
|
18
21
|
*
|
|
19
22
|
* Idempotency is by sha256: a path recorded `uploaded` with the same hash is
|
|
20
23
|
* not sent again, which is what makes `resume` and the manual `upload <job>`
|
|
@@ -25,9 +28,13 @@ export const STATUS = Object.freeze({
|
|
|
25
28
|
QUEUED: 'queued',
|
|
26
29
|
UPLOADED: 'uploaded',
|
|
27
30
|
FAILED: 'failed',
|
|
28
|
-
SKIPPED: 'skipped'
|
|
31
|
+
SKIPPED: 'skipped',
|
|
32
|
+
LOCAL_EDIT: 'local_edit'
|
|
29
33
|
});
|
|
30
34
|
|
|
35
|
+
/** Statuses that mean "nothing left to send at this hash". */
|
|
36
|
+
const DONE = new Set([STATUS.UPLOADED, STATUS.LOCAL_EDIT]);
|
|
37
|
+
|
|
31
38
|
/**
|
|
32
39
|
* Append one entry.
|
|
33
40
|
* @param {string} jobId
|
|
@@ -70,7 +77,8 @@ export async function readQueue(jobId, options = {}) {
|
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
/**
|
|
73
|
-
* The set of `<runId>|<path>|<sha256>` triples already recorded as uploaded
|
|
80
|
+
* The set of `<runId>|<path>|<sha256>` triples already recorded as uploaded,
|
|
81
|
+
* or as a local edit of a file that was.
|
|
74
82
|
* The sha256 is part of the key on purpose: a corrected file at the same path
|
|
75
83
|
* has a different hash and must be sent again.
|
|
76
84
|
* @param {string} jobId
|
|
@@ -80,7 +88,7 @@ export async function readQueue(jobId, options = {}) {
|
|
|
80
88
|
export async function uploadedKeys(jobId, options = {}) {
|
|
81
89
|
const done = new Set();
|
|
82
90
|
for (const entry of await readQueue(jobId, options)) {
|
|
83
|
-
if (entry.status
|
|
91
|
+
if (DONE.has(entry.status)) {
|
|
84
92
|
done.add(keyFor(entry.run_id || null, entry.path, entry.sha256));
|
|
85
93
|
}
|
|
86
94
|
}
|
|
@@ -105,7 +113,7 @@ export async function pending(jobId, options = {}) {
|
|
|
105
113
|
for (const entry of entries) {
|
|
106
114
|
if (!entry.path) continue;
|
|
107
115
|
const key = keyFor(entry.run_id || null, entry.path, entry.sha256);
|
|
108
|
-
if (entry.status
|
|
116
|
+
if (DONE.has(entry.status)) done.add(key);
|
|
109
117
|
latest.set(key, entry);
|
|
110
118
|
}
|
|
111
119
|
return [...latest.entries()]
|