ravensight-playtest 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -8
- package/docs/ADAPTERS.md +2 -0
- package/docs/NATIVE_RUNNER.md +243 -0
- package/package.json +2 -1
- package/src/cli.js +6 -0
- package/src/commands/check.js +330 -106
- package/src/commands/run.js +55 -2
- package/src/run/aggregate.js +97 -33
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +376 -41
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/personaLoop.js +247 -37
- package/src/run/regenerate.js +24 -4
- package/src/run/resume.js +7 -1
- package/src/run/tools.js +31 -10
- package/src/run/usage.js +19 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// The free capability probe for the native drivers. It asks the developer's own
|
|
2
|
+
// local W3C automation server the three questions a persona run depends on, in
|
|
3
|
+
// the order a run would hit them, and spends nothing: no job, no reservation, no
|
|
4
|
+
// model call. `check` prints it as a row and `run` refuses to register a native
|
|
5
|
+
// job when it fails.
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { readDriverConfig } from './webdriver.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Capability keys that name the app a session opens, in the order they are
|
|
12
|
+
* reported. A path or URL (`app`) is the build; a bundle id or package names an
|
|
13
|
+
* app already installed on the device or simulator.
|
|
14
|
+
*/
|
|
15
|
+
export const APP_KEYS = Object.freeze([
|
|
16
|
+
'appium:app',
|
|
17
|
+
'app',
|
|
18
|
+
'appium:bundleId',
|
|
19
|
+
'bundleId',
|
|
20
|
+
'appium:appPackage',
|
|
21
|
+
'appPackage'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const STATUS_TIMEOUT_MS = 10_000;
|
|
25
|
+
// The first session on a fresh simulator builds and installs the automation
|
|
26
|
+
// agent, which takes minutes, not seconds.
|
|
27
|
+
const SESSION_TIMEOUT_MS = 180_000;
|
|
28
|
+
const CLOSE_TIMEOUT_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The app the capabilities name, or null.
|
|
32
|
+
* @param {Object} capabilities
|
|
33
|
+
* @returns {{key: string, value: string, kind: 'path'|'url'|'id'}|null}
|
|
34
|
+
*/
|
|
35
|
+
export function appReference(capabilities = {}) {
|
|
36
|
+
for (const key of APP_KEYS) {
|
|
37
|
+
const value = capabilities[key];
|
|
38
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
39
|
+
if (key === 'appium:app' || key === 'app') {
|
|
40
|
+
return { key, value, kind: /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? 'url' : 'path' };
|
|
41
|
+
}
|
|
42
|
+
return { key, value, kind: 'id' };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function message(error) {
|
|
48
|
+
if (!error) return 'unknown error';
|
|
49
|
+
if (error.name === 'TimeoutError') return 'timed out';
|
|
50
|
+
const cause = error.cause && (error.cause.code || error.cause.message);
|
|
51
|
+
return String(cause || error.message || error).split('\n')[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The same response cap the native driver applies. */
|
|
55
|
+
export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
/** Read a response body, refusing anything past the cap. */
|
|
58
|
+
async function boundedText(response) {
|
|
59
|
+
if (!response.body) return '';
|
|
60
|
+
const reader = response.body.getReader();
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
try {
|
|
64
|
+
for (;;) {
|
|
65
|
+
const part = await reader.read();
|
|
66
|
+
if (part.done) break;
|
|
67
|
+
size += part.value.length;
|
|
68
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
69
|
+
await reader.cancel();
|
|
70
|
+
throw new Error('automation server response exceeded 8 MB');
|
|
71
|
+
}
|
|
72
|
+
chunks.push(Buffer.from(part.value));
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
reader.releaseLock();
|
|
76
|
+
}
|
|
77
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One W3C request with a timeout. Answers `{ ok, status, value }` and never
|
|
82
|
+
* throws for an HTTP error, so the caller can say which step failed.
|
|
83
|
+
*/
|
|
84
|
+
async function call(fetchImpl, method, url, body, timeoutMs) {
|
|
85
|
+
const response = await fetchImpl(url, {
|
|
86
|
+
method,
|
|
87
|
+
redirect: 'error',
|
|
88
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
...(body ? { body: JSON.stringify(body) } : {})
|
|
91
|
+
});
|
|
92
|
+
let data = null;
|
|
93
|
+
if (response.status !== 204) {
|
|
94
|
+
const text = await boundedText(response);
|
|
95
|
+
try {
|
|
96
|
+
data = text ? JSON.parse(text) : null;
|
|
97
|
+
} catch {
|
|
98
|
+
data = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const value = data && typeof data === 'object' ? data.value : undefined;
|
|
102
|
+
const error = value && typeof value === 'object' && value.error
|
|
103
|
+
? `${value.error}${value.message ? `: ${String(value.message).split('\n')[0]}` : ''}`
|
|
104
|
+
: null;
|
|
105
|
+
return { ok: response.ok && !error, status: response.status, value, error };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The session ids the server lists, or null when it lists none. `GET /sessions`
|
|
110
|
+
* is not in the W3C standard; some servers answer it and many do not, so every
|
|
111
|
+
* failure here is null, never an error.
|
|
112
|
+
*/
|
|
113
|
+
async function listSessions(fetchImpl, endpoint, timeoutMs) {
|
|
114
|
+
try {
|
|
115
|
+
const answer = await call(fetchImpl, 'GET', `${endpoint}/sessions`, null, timeoutMs);
|
|
116
|
+
if (!answer.ok || !Array.isArray(answer.value)) return null;
|
|
117
|
+
return answer.value.map(entry => entry && (entry.id || entry.sessionId)).filter(id => typeof id === 'string');
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Which platformName each native driver accepts. Desktop, Unity and Unreal
|
|
125
|
+
* builds are desktop apps, so a mobile platform there is a config mistake.
|
|
126
|
+
*/
|
|
127
|
+
export function platformMismatch(driverName, platformName) {
|
|
128
|
+
const platform = String(platformName || '').toLowerCase();
|
|
129
|
+
if (driverName === 'ios_driver' && platform !== 'ios') return 'iOS';
|
|
130
|
+
if (driverName === 'android_driver' && platform !== 'android') return 'Android';
|
|
131
|
+
if (['desktop_driver', 'unity_driver', 'unreal_driver'].includes(driverName) && ['ios', 'android'].includes(platform)) {
|
|
132
|
+
return 'a desktop platform (for example mac or windows)';
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The wording each driver family needs in a fix. */
|
|
138
|
+
export function platformWords(driverName) {
|
|
139
|
+
if (driverName === 'ios_driver') {
|
|
140
|
+
return {
|
|
141
|
+
family: 'ios',
|
|
142
|
+
build: 'build the app for the simulator first, then point appium:app at the .app it produced',
|
|
143
|
+
noApp: 'add appium:app with the path to the built .app, or appium:bundleId for an installed one',
|
|
144
|
+
relative: 'use an absolute path to the built .app so check can confirm it exists',
|
|
145
|
+
installed: 'the automation server row confirms it; if that row was skipped, install the build on the simulator first',
|
|
146
|
+
session: 'boot the simulator and check the capabilities: platformName, deviceName, platformVersion and automationName must match a device the server can drive',
|
|
147
|
+
app: 'point appium:app at the built .app, or appium:bundleId at an app installed on the simulator'
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (driverName === 'android_driver') {
|
|
151
|
+
return {
|
|
152
|
+
family: 'android',
|
|
153
|
+
build: 'build the .apk first, then point appium:app at it',
|
|
154
|
+
noApp: 'add appium:app with the path to the built .apk, or appium:appPackage for an installed one',
|
|
155
|
+
relative: 'use an absolute path to the .apk so check can confirm it exists',
|
|
156
|
+
installed: 'the automation server row confirms it; if that row was skipped, install the .apk on the device or emulator first',
|
|
157
|
+
session: 'start the emulator or connect the device and check the capabilities: platformName, deviceName and automationName must match a device the server can drive',
|
|
158
|
+
app: 'point appium:app at the built .apk, or appium:appPackage at an app installed on the device or emulator'
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
family: 'desktop',
|
|
163
|
+
build: 'build the game first, then point the app capability at what it produced',
|
|
164
|
+
noApp: 'add the capability your automation server uses to name the build (for example appium:app)',
|
|
165
|
+
relative: 'use an absolute path to the build so check can confirm it exists',
|
|
166
|
+
installed: 'the automation server row confirms it',
|
|
167
|
+
session: 'the automation server could not open the build; check the capabilities your server needs for this platform',
|
|
168
|
+
app: 'the automation server could not open the build; check the app capability points at it'
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Probe the automation server a native run would use.
|
|
174
|
+
*
|
|
175
|
+
* Steps, each only after the previous one passed:
|
|
176
|
+
* 1. read the driver config (loopback endpoint, W3C capabilities);
|
|
177
|
+
* 2. `GET /status`: the server is reachable;
|
|
178
|
+
* 3. `POST /session` with the run's own capabilities: a session can be created;
|
|
179
|
+
* 4. `GET /session/:id/source`: the app the capabilities name opened and
|
|
180
|
+
* answers, which is what "the app resolves" means to a persona;
|
|
181
|
+
* 5. `DELETE /session/:id`: the session is closed again.
|
|
182
|
+
*
|
|
183
|
+
* @param {Object} options
|
|
184
|
+
* @param {string} options.driverName
|
|
185
|
+
* @param {string|null} options.target the driver config file
|
|
186
|
+
* @param {typeof fetch} [options.fetch] test seam
|
|
187
|
+
* @param {{status?: number, session?: number, close?: number}} [options.timeouts]
|
|
188
|
+
* @param {(line: string) => void} [options.onProgress] told before the slow part starts
|
|
189
|
+
* @returns {Promise<{
|
|
190
|
+
* endpoint: string|null, reachable: boolean|null, session: boolean|null,
|
|
191
|
+
* closed: boolean|null, app: boolean|null, appRef: Object|null,
|
|
192
|
+
* failed: 'config'|'reachable'|'session'|'session_timeout'|'app'|null, detail: string, leaked: boolean
|
|
193
|
+
* }>}
|
|
194
|
+
*/
|
|
195
|
+
export async function probeAutomation({ driverName, target, fetch: fetchImpl = globalThis.fetch, timeouts = {}, onProgress = () => {} } = {}) {
|
|
196
|
+
const result = { endpoint: null, reachable: null, session: null, closed: null, app: null, appRef: null, failed: null, detail: '', leaked: false };
|
|
197
|
+
const fail = (step, detail) => Object.assign(result, { failed: step, detail });
|
|
198
|
+
|
|
199
|
+
if (!target) {
|
|
200
|
+
return fail('config', `no driver config, so there is no automation server to probe for ${driverName}`);
|
|
201
|
+
}
|
|
202
|
+
let config;
|
|
203
|
+
try {
|
|
204
|
+
config = await readDriverConfig(target);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return fail('config', `driver config ${target}: ${message(error)}`);
|
|
207
|
+
}
|
|
208
|
+
result.endpoint = config.endpoint;
|
|
209
|
+
result.appRef = appReference(config.capabilities);
|
|
210
|
+
const wanted = platformMismatch(driverName, config.capabilities.platformName);
|
|
211
|
+
if (wanted) {
|
|
212
|
+
return fail('config', `${driverName} needs platformName ${wanted}, and the driver config says ${config.capabilities.platformName}`);
|
|
213
|
+
}
|
|
214
|
+
onProgress(`probing ${config.endpoint}, this can take up to 3 minutes on a fresh simulator`);
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const status = await call(fetchImpl, 'GET', `${config.endpoint}/status`, null, timeouts.status ?? STATUS_TIMEOUT_MS);
|
|
218
|
+
if (!status.ok) return Object.assign(fail('reachable', `${config.endpoint}/status answered ${status.error || `HTTP ${status.status}`}`), { reachable: false });
|
|
219
|
+
if (status.value && status.value.ready === false) {
|
|
220
|
+
return Object.assign(fail('reachable', `${config.endpoint} answered but says it is not ready${status.value.message ? `: ${status.value.message}` : ''}`), { reachable: false });
|
|
221
|
+
}
|
|
222
|
+
result.reachable = true;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
result.reachable = false;
|
|
225
|
+
return fail('reachable', `nothing answered at ${config.endpoint} (${message(error)})`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let sessionId = null;
|
|
229
|
+
// Taken only so a timed out create can find the session it may have left
|
|
230
|
+
// behind. Servers without `GET /sessions` answer null and nothing is closed.
|
|
231
|
+
const before = await listSessions(fetchImpl, config.endpoint, timeouts.status ?? STATUS_TIMEOUT_MS);
|
|
232
|
+
try {
|
|
233
|
+
const created = await call(
|
|
234
|
+
fetchImpl,
|
|
235
|
+
'POST',
|
|
236
|
+
`${config.endpoint}/session`,
|
|
237
|
+
{ capabilities: { alwaysMatch: config.capabilities, firstMatch: [{}] } },
|
|
238
|
+
timeouts.session ?? SESSION_TIMEOUT_MS
|
|
239
|
+
);
|
|
240
|
+
sessionId = created.ok && created.value && typeof created.value.sessionId === 'string' ? created.value.sessionId : null;
|
|
241
|
+
if (!sessionId) {
|
|
242
|
+
result.session = false;
|
|
243
|
+
return fail('session', `the server did not create a session: ${created.error || `HTTP ${created.status}`}`);
|
|
244
|
+
}
|
|
245
|
+
result.session = true;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
result.session = false;
|
|
248
|
+
if (error && error.name === 'TimeoutError') {
|
|
249
|
+
// The server may finish creating the session after the abort. Close any
|
|
250
|
+
// session that appeared since the list above, best effort.
|
|
251
|
+
const after = before ? await listSessions(fetchImpl, config.endpoint, timeouts.status ?? STATUS_TIMEOUT_MS) : null;
|
|
252
|
+
const strays = after ? after.filter(id => !before.includes(id)) : [];
|
|
253
|
+
for (const id of strays) {
|
|
254
|
+
try {
|
|
255
|
+
await call(fetchImpl, 'DELETE', `${config.endpoint}/session/${encodeURIComponent(id)}`, null, timeouts.close ?? CLOSE_TIMEOUT_MS);
|
|
256
|
+
} catch {
|
|
257
|
+
// Best effort: the detail already says a session may still be open.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
result.leaked = true;
|
|
261
|
+
return fail('session_timeout', `creating a session timed out; a session may still be open on the server; restart it or wait before retrying${strays.length ? ` (asked the server to close ${strays.length} session${strays.length === 1 ? '' : 's'} that appeared meanwhile)` : ''}`);
|
|
262
|
+
}
|
|
263
|
+
return fail('session', `creating a session failed (${message(error)})`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const sessionUrl = `${config.endpoint}/session/${encodeURIComponent(sessionId)}`;
|
|
267
|
+
try {
|
|
268
|
+
if (!result.appRef) {
|
|
269
|
+
result.app = false;
|
|
270
|
+
fail('app', `the capabilities name no app (one of ${APP_KEYS.join(', ')}), so a session opens nothing for a persona to play`);
|
|
271
|
+
} else {
|
|
272
|
+
const source = await call(fetchImpl, 'GET', `${sessionUrl}/source`, null, timeouts.status ?? STATUS_TIMEOUT_MS);
|
|
273
|
+
result.app = source.ok;
|
|
274
|
+
if (!source.ok) fail('app', `${result.appRef.value} did not answer in the session: ${source.error || `HTTP ${source.status}`}`);
|
|
275
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
result.app = false;
|
|
278
|
+
fail('app', `reading the app in the session failed (${message(error)})`);
|
|
279
|
+
} finally {
|
|
280
|
+
try {
|
|
281
|
+
const closed = await call(fetchImpl, 'DELETE', sessionUrl, null, timeouts.close ?? CLOSE_TIMEOUT_MS);
|
|
282
|
+
result.closed = closed.ok;
|
|
283
|
+
} catch {
|
|
284
|
+
result.closed = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (result.failed) return result;
|
|
288
|
+
|
|
289
|
+
result.detail = `${config.endpoint} reachable, session created and ${result.closed ? 'closed' : 'NOT closed'}, app ${result.appRef.value} opened`;
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* What the native build is, from the driver config alone. Nothing is launched.
|
|
295
|
+
*
|
|
296
|
+
* @param {Object} options
|
|
297
|
+
* @param {string|null} options.target
|
|
298
|
+
* @param {string} [options.repoRoot]
|
|
299
|
+
* @returns {Promise<{status: 'pass'|'warn'|'fail', detail: string, fix: string|null, ref: string|null}>}
|
|
300
|
+
*/
|
|
301
|
+
export async function inspectNativeBuild({ target, repoRoot = process.cwd(), driverName = 'ios_driver' } = {}) {
|
|
302
|
+
const words = platformWords(driverName);
|
|
303
|
+
if (!target) {
|
|
304
|
+
return {
|
|
305
|
+
status: 'fail',
|
|
306
|
+
detail: 'no driver config: a native run has nothing to open',
|
|
307
|
+
fix: 'write a driver config (see docs/NATIVE_RUNNER.md) and pass --driver-config <file>, or set build to { "kind": "driver_config", "ref": "<file>" } in .ravensight/config.json',
|
|
308
|
+
ref: null
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
let config;
|
|
312
|
+
try {
|
|
313
|
+
config = await readDriverConfig(path.resolve(repoRoot, target));
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return { status: 'fail', detail: `driver config ${target}: ${message(error)}`, fix: 'fix the driver config; see docs/NATIVE_RUNNER.md', ref: null };
|
|
316
|
+
}
|
|
317
|
+
const app = appReference(config.capabilities);
|
|
318
|
+
if (!app) {
|
|
319
|
+
return {
|
|
320
|
+
status: 'fail',
|
|
321
|
+
detail: `${target} names no app (one of ${APP_KEYS.join(', ')})`,
|
|
322
|
+
fix: words.noApp,
|
|
323
|
+
ref: null
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (app.kind === 'id') {
|
|
327
|
+
return {
|
|
328
|
+
status: 'warn',
|
|
329
|
+
detail: `${app.key} ${app.value}: an installed app, which only the automation server can confirm`,
|
|
330
|
+
fix: words.installed,
|
|
331
|
+
ref: app.value
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (app.kind === 'url' || !path.isAbsolute(app.value)) {
|
|
335
|
+
return {
|
|
336
|
+
status: 'warn',
|
|
337
|
+
detail: `${app.key} ${app.value}: resolved by the automation server, not checked on disk`,
|
|
338
|
+
fix: words.relative,
|
|
339
|
+
ref: app.value
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
await fs.stat(app.value);
|
|
344
|
+
} catch {
|
|
345
|
+
return {
|
|
346
|
+
status: 'fail',
|
|
347
|
+
detail: `${app.key} ${app.value} does not exist`,
|
|
348
|
+
fix: words.build,
|
|
349
|
+
ref: app.value
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return { status: 'pass', detail: `${app.key} ${app.value} (from ${target})`, fix: null, ref: app.value };
|
|
353
|
+
}
|
package/src/run/dropped.js
CHANGED
|
@@ -108,8 +108,8 @@ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
|
108
108
|
* @param {{unidentified?: number}} [opts] a count the CLI could not attribute
|
|
109
109
|
* @returns {string}
|
|
110
110
|
*/
|
|
111
|
-
export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } = {}) {
|
|
112
|
-
const lines = withoutDroppedSection(String(markdown || '').replace(/\s+$/, '')).split('\n');
|
|
111
|
+
export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0, heading = DROPPED_HEADING, explanation = null, emptyNote = null } = {}) {
|
|
112
|
+
const lines = withoutDroppedSection(String(markdown || '').replace(/\s+$/, ''), heading).split('\n');
|
|
113
113
|
const droppedIds = dropped.map(finding => String(finding.id)).filter(Boolean);
|
|
114
114
|
const keptIds = kept.map(finding => String(finding.id)).filter(Boolean);
|
|
115
115
|
const names = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(id)}(?![0-9])`).test(line);
|
|
@@ -126,7 +126,7 @@ export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } =
|
|
|
126
126
|
out.pop();
|
|
127
127
|
blanks += 1;
|
|
128
128
|
}
|
|
129
|
-
out.push('- Nothing here that the server kept. See the dropped findings below.');
|
|
129
|
+
out.push(emptyNote || '- Nothing here that the server kept. See the dropped findings below.');
|
|
130
130
|
for (let i = 0; i < blanks; i += 1) out.push('');
|
|
131
131
|
}
|
|
132
132
|
};
|
|
@@ -148,10 +148,10 @@ export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } =
|
|
|
148
148
|
}
|
|
149
149
|
closeSection();
|
|
150
150
|
|
|
151
|
-
const tail = ['',
|
|
151
|
+
const tail = ['', heading];
|
|
152
152
|
if (dropped.length > 0) {
|
|
153
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.',
|
|
154
|
+
explanation || '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
155
|
''
|
|
156
156
|
);
|
|
157
157
|
for (const finding of dropped) {
|
|
@@ -176,9 +176,9 @@ export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } =
|
|
|
176
176
|
* @param {string} markdown
|
|
177
177
|
* @returns {string}
|
|
178
178
|
*/
|
|
179
|
-
function withoutDroppedSection(markdown) {
|
|
179
|
+
function withoutDroppedSection(markdown, heading = DROPPED_HEADING) {
|
|
180
180
|
const lines = markdown.split('\n');
|
|
181
|
-
const start = lines.findIndex(line => line.trim() ===
|
|
181
|
+
const start = lines.findIndex(line => line.trim() === heading);
|
|
182
182
|
if (start === -1) return markdown;
|
|
183
183
|
let end = lines.findIndex((line, index) => index > start && SECTION.test(line));
|
|
184
184
|
if (end === -1) end = lines.length;
|