ravensight-playtest 0.2.6 → 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 +65 -1
- package/package.json +1 -1
- package/src/cli.js +1 -0
- package/src/commands/fakeCore.js +36 -7
- package/src/commands/run.js +149 -6
- package/src/commands/upload.js +10 -0
- package/src/run/aggregate.js +9 -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 +126 -3
- package/src/run/drivers/webdriver.js +1 -0
- package/src/run/dropped.js +294 -0
- package/src/run/personaLoop.js +218 -12
- package/src/run/placeholders.js +186 -0
- package/src/run/resume.js +16 -1
- package/src/run/synthetic.js +14 -1
- package/src/run/tools.js +10 -4
- package/src/states.js +4 -2
- package/src/upload/index.js +9 -1
- package/src/upload/queue.js +13 -5
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
|
/**
|
|
@@ -361,6 +474,16 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
|
|
|
361
474
|
url: current.url(),
|
|
362
475
|
elapsedMs: Date.now() - started
|
|
363
476
|
};
|
|
477
|
+
// The page title is what a note's "after" state is checked against in
|
|
478
|
+
// the report step. A page that cannot answer has no title, not an error.
|
|
479
|
+
try {
|
|
480
|
+
if (typeof current.title === 'function') {
|
|
481
|
+
const title = await current.title();
|
|
482
|
+
if (title) observation.title = String(title);
|
|
483
|
+
}
|
|
484
|
+
} catch {
|
|
485
|
+
// No title is still an observation.
|
|
486
|
+
}
|
|
364
487
|
// Canvas builds (a Godot or Unity web export is one <canvas> and
|
|
365
488
|
// nothing else) have no accessibility tree worth reading, so vision
|
|
366
489
|
// is the only way to play them. Spend an image only then.
|
|
@@ -397,10 +520,10 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
|
|
|
397
520
|
try {
|
|
398
521
|
switch (action.kind) {
|
|
399
522
|
case 'key':
|
|
400
|
-
await current.keyboard.press(
|
|
523
|
+
await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
|
|
401
524
|
break;
|
|
402
525
|
case 'type':
|
|
403
|
-
await current
|
|
526
|
+
await typeText(current, action);
|
|
404
527
|
if (action.submit) await current.keyboard.press('Enter');
|
|
405
528
|
break;
|
|
406
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 };
|