@rehearsal-labs/retest 0.1.0 → 0.2.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 +6 -0
- package/dist/agent/session.d.ts +21 -2
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +73 -11
- package/dist/api/app-page.d.ts +39 -3
- package/dist/api/app-page.d.ts.map +1 -1
- package/dist/api/app-page.js +108 -0
- package/dist/api/dialog.d.ts +38 -0
- package/dist/api/dialog.d.ts.map +1 -0
- package/dist/api/dialog.js +78 -0
- package/dist/api/drag.d.ts +32 -0
- package/dist/api/drag.d.ts.map +1 -0
- package/dist/api/drag.js +26 -0
- package/dist/api/files.d.ts +49 -0
- package/dist/api/files.d.ts.map +1 -0
- package/dist/api/files.js +168 -0
- package/dist/api/inbox.d.ts +97 -0
- package/dist/api/inbox.d.ts.map +1 -0
- package/dist/api/inbox.js +191 -0
- package/dist/api/locator-recipes.d.ts +1 -1
- package/dist/api/locator-recipes.d.ts.map +1 -1
- package/dist/api/locator-recipes.js +4 -3
- package/dist/api/page.d.ts +214 -4
- package/dist/api/page.d.ts.map +1 -1
- package/dist/api/route.d.ts +21 -0
- package/dist/api/route.d.ts.map +1 -0
- package/dist/api/route.js +99 -0
- package/dist/api/test-run.d.ts +127 -1
- package/dist/api/test-run.d.ts.map +1 -1
- package/dist/api/test-run.js +293 -0
- package/dist/assertions/expect.d.ts +91 -2
- package/dist/assertions/expect.d.ts.map +1 -1
- package/dist/assertions/expect.js +98 -0
- package/dist/assertions/look-until.d.ts +5 -0
- package/dist/assertions/look-until.d.ts.map +1 -1
- package/dist/assertions/look-until.js +1 -1
- package/dist/assertions/poll-layout.d.ts +24 -0
- package/dist/assertions/poll-layout.d.ts.map +1 -0
- package/dist/assertions/poll-layout.js +63 -0
- package/dist/assertions/screenshot.d.ts +36 -0
- package/dist/assertions/screenshot.d.ts.map +1 -0
- package/dist/assertions/screenshot.js +125 -0
- package/dist/browser/accessibility.d.ts.map +1 -1
- package/dist/browser/accessibility.js +22 -1
- package/dist/browser/actionability.d.ts +9 -6
- package/dist/browser/actionability.d.ts.map +1 -1
- package/dist/browser/actionability.js +17 -8
- package/dist/browser/browser.d.ts.map +1 -1
- package/dist/browser/browser.js +15 -4
- package/dist/browser/cdp/connection.d.ts +6 -0
- package/dist/browser/cdp/connection.d.ts.map +1 -1
- package/dist/browser/cdp/connection.js +8 -0
- package/dist/browser/checked-state.d.ts +2 -2
- package/dist/browser/checked-state.d.ts.map +1 -1
- package/dist/browser/chromium-process.d.ts +9 -2
- package/dist/browser/chromium-process.d.ts.map +1 -1
- package/dist/browser/chromium-process.js +17 -4
- package/dist/browser/contract.d.ts +119 -5
- package/dist/browser/contract.d.ts.map +1 -1
- package/dist/browser/dialogs.d.ts +157 -0
- package/dist/browser/dialogs.d.ts.map +1 -0
- package/dist/browser/dialogs.js +271 -0
- package/dist/browser/downloads.d.ts +71 -0
- package/dist/browser/downloads.d.ts.map +1 -0
- package/dist/browser/downloads.js +253 -0
- package/dist/browser/drag.d.ts +50 -0
- package/dist/browser/drag.d.ts.map +1 -0
- package/dist/browser/drag.js +584 -0
- package/dist/browser/electron-window-watch.d.ts +32 -0
- package/dist/browser/electron-window-watch.d.ts.map +1 -0
- package/dist/browser/electron-window-watch.js +93 -0
- package/dist/browser/electron.d.ts +13 -2
- package/dist/browser/electron.d.ts.map +1 -1
- package/dist/browser/electron.js +53 -8
- package/dist/browser/element-queries.d.ts +51 -15
- package/dist/browser/element-queries.d.ts.map +1 -1
- package/dist/browser/element-queries.js +92 -13
- package/dist/browser/emulation.d.ts.map +1 -1
- package/dist/browser/emulation.js +7 -5
- package/dist/browser/file-chooser.d.ts +75 -0
- package/dist/browser/file-chooser.d.ts.map +1 -0
- package/dist/browser/file-chooser.js +449 -0
- package/dist/browser/firefox/input.d.ts +5 -0
- package/dist/browser/firefox/input.d.ts.map +1 -1
- package/dist/browser/firefox/input.js +6 -3
- package/dist/browser/firefox/page.d.ts +11 -1
- package/dist/browser/firefox/page.d.ts.map +1 -1
- package/dist/browser/firefox/page.js +66 -7
- package/dist/browser/frames.d.ts +205 -0
- package/dist/browser/frames.d.ts.map +1 -0
- package/dist/browser/frames.js +657 -0
- package/dist/browser/input-guard.d.ts +18 -5
- package/dist/browser/input-guard.d.ts.map +1 -1
- package/dist/browser/input-guard.js +65 -6
- package/dist/browser/isolated-world.d.ts +8 -1
- package/dist/browser/isolated-world.d.ts.map +1 -1
- package/dist/browser/layout.d.ts +120 -0
- package/dist/browser/layout.d.ts.map +1 -0
- package/dist/browser/layout.js +213 -0
- package/dist/browser/locate.d.ts +10 -0
- package/dist/browser/locate.d.ts.map +1 -1
- package/dist/browser/locate.js +24 -6
- package/dist/browser/origin-document.d.ts +4 -0
- package/dist/browser/origin-document.d.ts.map +1 -1
- package/dist/browser/origin-document.js +9 -1
- package/dist/browser/page-scripts.d.ts +48 -0
- package/dist/browser/page-scripts.d.ts.map +1 -1
- package/dist/browser/page-scripts.js +515 -39
- package/dist/browser/page.d.ts +60 -1
- package/dist/browser/page.d.ts.map +1 -1
- package/dist/browser/page.js +275 -32
- package/dist/browser/popups.d.ts +166 -0
- package/dist/browser/popups.d.ts.map +1 -0
- package/dist/browser/popups.js +317 -0
- package/dist/browser/routes.d.ts +97 -0
- package/dist/browser/routes.d.ts.map +1 -0
- package/dist/browser/routes.js +226 -0
- package/dist/browser/screenshot.d.ts +44 -0
- package/dist/browser/screenshot.d.ts.map +1 -0
- package/dist/browser/screenshot.js +51 -0
- package/dist/browser/shadow-candidates.d.ts +20 -0
- package/dist/browser/shadow-candidates.d.ts.map +1 -0
- package/dist/browser/shadow-candidates.js +77 -0
- package/dist/browser/webkit/page.d.ts +11 -1
- package/dist/browser/webkit/page.d.ts.map +1 -1
- package/dist/browser/webkit/page.js +53 -2
- package/dist/browser/webkit/relay.d.ts.map +1 -1
- package/dist/browser/webkit/relay.js +5 -1
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +2 -0
- package/dist/cli/inspect/test-timeline.d.ts.map +1 -1
- package/dist/cli/inspect/test-timeline.js +38 -4
- package/dist/config/loaded.d.ts +12 -2
- package/dist/config/loaded.d.ts.map +1 -1
- package/dist/config/read-inboxes.d.ts +10 -0
- package/dist/config/read-inboxes.d.ts.map +1 -0
- package/dist/config/read-inboxes.js +78 -0
- package/dist/config/register.d.ts +5 -1
- package/dist/config/register.d.ts.map +1 -1
- package/dist/config/types.d.ts +36 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/validate.d.ts.map +1 -1
- package/dist/config/validate.js +4 -1
- package/dist/diagnostics/artifact.d.ts.map +1 -1
- package/dist/diagnostics/artifact.js +10 -2
- package/dist/diagnostics/attempt.d.ts +5 -0
- package/dist/diagnostics/attempt.d.ts.map +1 -1
- package/dist/diagnostics/attempt.js +10 -0
- package/dist/diagnostics/chromium-collector.d.ts +9 -1
- package/dist/diagnostics/chromium-collector.d.ts.map +1 -1
- package/dist/diagnostics/chromium-collector.js +19 -4
- package/dist/diagnostics/observations.d.ts +8 -0
- package/dist/diagnostics/observations.d.ts.map +1 -1
- package/dist/diagnostics/session-capture.d.ts.map +1 -1
- package/dist/diagnostics/session-capture.js +8 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/media/policy.d.ts +2 -2
- package/dist/media/policy.d.ts.map +1 -1
- package/dist/media/policy.js +1 -1
- package/dist/playwright/not-yet.d.ts.map +1 -1
- package/dist/playwright/not-yet.js +5 -2
- package/dist/protocol/commands.d.ts +122 -13
- package/dist/protocol/commands.d.ts.map +1 -1
- package/dist/protocol/commands.js +85 -6
- package/dist/protocol/diagnostics.d.ts +10 -0
- package/dist/protocol/diagnostics.d.ts.map +1 -1
- package/dist/protocol/diagnostics.js +5 -0
- package/dist/protocol/dialogs.d.ts +104 -0
- package/dist/protocol/dialogs.d.ts.map +1 -0
- package/dist/protocol/dialogs.js +101 -0
- package/dist/protocol/drag.d.ts +45 -0
- package/dist/protocol/drag.d.ts.map +1 -0
- package/dist/protocol/drag.js +31 -0
- package/dist/protocol/events.d.ts +153 -1
- package/dist/protocol/events.d.ts.map +1 -1
- package/dist/protocol/events.js +97 -0
- package/dist/protocol/files.d.ts +83 -0
- package/dist/protocol/files.d.ts.map +1 -0
- package/dist/protocol/files.js +68 -0
- package/dist/protocol/inbox.d.ts +87 -0
- package/dist/protocol/inbox.d.ts.map +1 -0
- package/dist/protocol/inbox.js +51 -0
- package/dist/protocol/index.d.ts +4 -1
- package/dist/protocol/index.d.ts.map +1 -1
- package/dist/protocol/layout-checks.d.ts +83 -0
- package/dist/protocol/layout-checks.d.ts.map +1 -0
- package/dist/protocol/layout-checks.js +214 -0
- package/dist/protocol/layout.d.ts +135 -0
- package/dist/protocol/layout.d.ts.map +1 -0
- package/dist/protocol/layout.js +124 -0
- package/dist/protocol/locator-checks.d.ts +19 -2
- package/dist/protocol/locator-checks.d.ts.map +1 -1
- package/dist/protocol/locator-checks.js +23 -1
- package/dist/protocol/locator.d.ts +55 -7
- package/dist/protocol/locator.d.ts.map +1 -1
- package/dist/protocol/locator.js +99 -18
- package/dist/protocol/messages.d.ts +95 -0
- package/dist/protocol/messages.d.ts.map +1 -1
- package/dist/protocol/messages.js +63 -0
- package/dist/protocol/popups.d.ts +43 -0
- package/dist/protocol/popups.d.ts.map +1 -0
- package/dist/protocol/popups.js +27 -0
- package/dist/protocol/result.d.ts +5 -0
- package/dist/protocol/result.d.ts.map +1 -1
- package/dist/protocol/result.js +1 -0
- package/dist/protocol/routes.d.ts +136 -0
- package/dist/protocol/routes.d.ts.map +1 -0
- package/dist/protocol/routes.js +191 -0
- package/dist/protocol/screenshot.d.ts +219 -0
- package/dist/protocol/screenshot.d.ts.map +1 -0
- package/dist/protocol/screenshot.js +335 -0
- package/dist/reporters/actions.d.ts +6 -4
- package/dist/reporters/actions.d.ts.map +1 -1
- package/dist/reporters/actions.js +24 -6
- package/dist/reporters/agent.d.ts.map +1 -1
- package/dist/reporters/agent.js +4 -2
- package/dist/reporters/dialogs-popups.d.ts +22 -0
- package/dist/reporters/dialogs-popups.d.ts.map +1 -0
- package/dist/reporters/dialogs-popups.js +30 -0
- package/dist/reporters/failure-card.d.ts +10 -1
- package/dist/reporters/failure-card.d.ts.map +1 -1
- package/dist/reporters/failure-card.js +5 -1
- package/dist/reporters/format.d.ts +33 -0
- package/dist/reporters/format.d.ts.map +1 -1
- package/dist/reporters/format.js +42 -0
- package/dist/reporters/html/comparison-view.d.ts +12 -0
- package/dist/reporters/html/comparison-view.d.ts.map +1 -0
- package/dist/reporters/html/comparison-view.js +21 -0
- package/dist/reporters/html/diagnostics-view.js +8 -0
- package/dist/reporters/html/downloads-view.d.ts +10 -0
- package/dist/reporters/html/downloads-view.d.ts.map +1 -0
- package/dist/reporters/html/downloads-view.js +29 -0
- package/dist/reporters/html/style.d.ts.map +1 -1
- package/dist/reporters/html/style.js +3 -0
- package/dist/reporters/html/summary-view.d.ts.map +1 -1
- package/dist/reporters/html/summary-view.js +3 -2
- package/dist/reporters/html/test-view.d.ts.map +1 -1
- package/dist/reporters/html/test-view.js +6 -3
- package/dist/reporters/html/timeline-view.d.ts.map +1 -1
- package/dist/reporters/html/timeline-view.js +26 -2
- package/dist/reporters/human-card.js +10 -0
- package/dist/reporters/human.d.ts.map +1 -1
- package/dist/reporters/human.js +3 -1
- package/dist/reporters/routes.d.ts +9 -0
- package/dist/reporters/routes.d.ts.map +1 -0
- package/dist/reporters/routes.js +17 -0
- package/dist/runner/child-program.js +10 -0
- package/dist/runner/contract.d.ts +5 -0
- package/dist/runner/contract.d.ts.map +1 -1
- package/dist/runner/dialogs.d.ts +71 -0
- package/dist/runner/dialogs.d.ts.map +1 -0
- package/dist/runner/dialogs.js +189 -0
- package/dist/runner/files.d.ts +79 -0
- package/dist/runner/files.d.ts.map +1 -0
- package/dist/runner/files.js +312 -0
- package/dist/runner/inbox-links.d.ts +19 -0
- package/dist/runner/inbox-links.d.ts.map +1 -0
- package/dist/runner/inbox-links.js +63 -0
- package/dist/runner/inbox.d.ts +87 -0
- package/dist/runner/inbox.d.ts.map +1 -0
- package/dist/runner/inbox.js +340 -0
- package/dist/runner/index.d.ts +2 -1
- package/dist/runner/index.d.ts.map +1 -1
- package/dist/runner/native-pool.d.ts.map +1 -1
- package/dist/runner/native-pool.js +2 -0
- package/dist/runner/observations.d.ts +15 -2
- package/dist/runner/observations.d.ts.map +1 -1
- package/dist/runner/observations.js +109 -6
- package/dist/runner/popups.d.ts +82 -0
- package/dist/runner/popups.d.ts.map +1 -0
- package/dist/runner/popups.js +236 -0
- package/dist/runner/redactor.d.ts.map +1 -1
- package/dist/runner/redactor.js +10 -6
- package/dist/runner/routes.d.ts +23 -0
- package/dist/runner/routes.d.ts.map +1 -0
- package/dist/runner/routes.js +128 -0
- package/dist/runner/run-media.d.ts +6 -0
- package/dist/runner/run-media.d.ts.map +1 -1
- package/dist/runner/run-media.js +21 -0
- package/dist/runner/run-session.d.ts.map +1 -1
- package/dist/runner/run-session.js +93 -8
- package/dist/runner/running-test.d.ts +35 -0
- package/dist/runner/running-test.d.ts.map +1 -1
- package/dist/runner/running-test.js +333 -22
- package/dist/runner/screenshot-baselines.d.ts +159 -0
- package/dist/runner/screenshot-baselines.d.ts.map +1 -0
- package/dist/runner/screenshot-baselines.js +357 -0
- package/dist/runner/secrets.d.ts +13 -0
- package/dist/runner/secrets.d.ts.map +1 -1
- package/dist/runner/secrets.js +6 -4
- package/dist/runner/test-pages.d.ts +3 -1
- package/dist/runner/test-pages.d.ts.map +1 -1
- package/dist/runner/test-pages.js +4 -3
- package/dist/schemas/event-v1.schema.json +118873 -6311
- package/dist/schemas/result-v1.schema.json +4 -0
- package/dist/shared/image-diff.d.ts +36 -0
- package/dist/shared/image-diff.d.ts.map +1 -0
- package/dist/shared/image-diff.js +242 -0
- package/dist/shared/metadata-process.d.ts +4 -1
- package/dist/shared/metadata-process.d.ts.map +1 -1
- package/dist/shared/metadata-process.js +51 -9
- package/dist/shared/png.d.ts +35 -0
- package/dist/shared/png.d.ts.map +1 -0
- package/dist/shared/png.js +251 -0
- package/dist/shared/process-ownership.d.ts +15 -4
- package/dist/shared/process-ownership.d.ts.map +1 -1
- package/dist/shared/process-ownership.js +123 -34
- package/dist/store/artifacts.d.ts +23 -2
- package/dist/store/artifacts.d.ts.map +1 -1
- package/dist/store/artifacts.js +40 -1
- package/dist/store/rebuild-result.d.ts.map +1 -1
- package/dist/store/rebuild-result.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { smallestBudget } from "../protocol/deadline.js";
|
|
2
|
+
import { dialogTextLimit, unwaitedDialogMessage, withDialogWords } from "../protocol/dialogs.js";
|
|
3
|
+
import { formatSessionId } from "../protocol/evidence.js";
|
|
4
|
+
import { errorMessage, failure, truncateText, withLocation } from "../protocol/failures.js";
|
|
5
|
+
import { NativePageAdapter } from "./native-pool.js";
|
|
6
|
+
/**
|
|
7
|
+
* The parent's side of a test's JavaScript dialogs. A wait arms the app's page at once, before the action the test runs
|
|
8
|
+
* next arrives, and gives the test the dialog when it opens, its text redacted, under an id of the attempt's own. An
|
|
9
|
+
* answer goes to the page that showed the dialog. A dialog nothing waited for is answered by the page, and recorded
|
|
10
|
+
* here: one that opened while no command ran fails the test, and one that opened during a command fails that command,
|
|
11
|
+
* in the words `withDialogText` gives it. A dialog the test was given and left open is dismissed as the body settles.
|
|
12
|
+
* Every dialog that closed is written down as a `dialog` event.
|
|
13
|
+
*/
|
|
14
|
+
export class TestDialogs {
|
|
15
|
+
#host;
|
|
16
|
+
#waits = new Map();
|
|
17
|
+
#given = new Map();
|
|
18
|
+
#count = 0;
|
|
19
|
+
constructor(host) {
|
|
20
|
+
this.#host = host;
|
|
21
|
+
}
|
|
22
|
+
/** Hears the dialogs an app's page answers itself. Returns a function that stops listening. */
|
|
23
|
+
watch(app, page) {
|
|
24
|
+
return page.onUnwaitedDialog?.((dialog) => this.#unwaited(app, dialog)) ?? (() => undefined);
|
|
25
|
+
}
|
|
26
|
+
receive(message) {
|
|
27
|
+
const { request } = message;
|
|
28
|
+
if (message.testId !== this.#host.testId || message.attemptId !== this.#host.attemptId)
|
|
29
|
+
return this.#host.violation('sent a dialog request for an inactive test attempt');
|
|
30
|
+
if (this.#waits.has(message.id))
|
|
31
|
+
return this.#host.violation(`sent dialog request ${message.id} again while the first was still waiting`);
|
|
32
|
+
switch (request.kind) {
|
|
33
|
+
case 'wait':
|
|
34
|
+
return this.#wait(message);
|
|
35
|
+
case 'stop':
|
|
36
|
+
return this.#stop(message, request.wait);
|
|
37
|
+
case 'answer':
|
|
38
|
+
return void this.#answer(message, request).catch((error) => this.#refuse(message.id, failure('setup_failed', `Retest could not answer the dialog: ${errorMessage(error)}`, message.location)));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Dismisses every dialog the test was given and left open, within `timeoutMs`, so its page answers again and the action
|
|
43
|
+
* that opened it can end. Each is written down as dismissed by Retest. Nothing is recorded against the test: its own
|
|
44
|
+
* process names the dialog it left open.
|
|
45
|
+
*/
|
|
46
|
+
async settle(timeoutMs) {
|
|
47
|
+
const left = [...this.#given.values()];
|
|
48
|
+
this.#given.clear();
|
|
49
|
+
for (const given of left) {
|
|
50
|
+
const answered = await this.#host.page(given.app)?.answerDialog?.(given.pageId, { accept: false }, Math.max(1, timeoutMs));
|
|
51
|
+
if (answered?.ok === true)
|
|
52
|
+
this.#emit({ app: given.app, type: given.type, message: given.message, answer: 'dismissed', by: 'retest', stepId: undefined, location: undefined });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
#wait(message) {
|
|
56
|
+
const { id, app, location } = message;
|
|
57
|
+
const refusal = this.#host.refusal();
|
|
58
|
+
if (refusal !== undefined)
|
|
59
|
+
return this.#reply(id, { kind: 'failed', failure: withLocation(refusal, location) });
|
|
60
|
+
const page = this.#host.page(app);
|
|
61
|
+
if (page === undefined)
|
|
62
|
+
return this.#host.violation(`sent a dialog request for the app ${JSON.stringify(app)}, which this test does not use`);
|
|
63
|
+
if (page.armDialog === undefined)
|
|
64
|
+
return this.#refuse(id, failure('unsupported', noDialogs(app, page), location));
|
|
65
|
+
const stop = new AbortController();
|
|
66
|
+
const remaining = this.#host.remainingMs();
|
|
67
|
+
const arming = page.armDialog(remaining === undefined ? message.timeoutMs : Math.max(1, smallestBudget(message.timeoutMs, remaining)), stop.signal);
|
|
68
|
+
if (!arming.ok)
|
|
69
|
+
return this.#refuse(id, withLocation(arming.failure, location));
|
|
70
|
+
const waiting = { stop, stoppedByTest: false };
|
|
71
|
+
this.#waits.set(id, waiting);
|
|
72
|
+
this.#reply(id, { kind: 'armed' });
|
|
73
|
+
void arming.opened.then((wait) => {
|
|
74
|
+
this.#waits.delete(id);
|
|
75
|
+
if (waiting.stoppedByTest)
|
|
76
|
+
return;
|
|
77
|
+
// A dialog that opens once the test is over has nobody to answer it, and would hold the page from its screenshot.
|
|
78
|
+
if (!this.#host.answerable())
|
|
79
|
+
return void (wait.ok ? this.#dismissLate(app, page, wait.dialog) : undefined);
|
|
80
|
+
if (!wait.ok)
|
|
81
|
+
return this.#refuse(id, withLocation(wait.failure, location));
|
|
82
|
+
const { dialog } = wait;
|
|
83
|
+
this.#count += 1;
|
|
84
|
+
const given = `dialog-${this.#count}`;
|
|
85
|
+
this.#given.set(given, { app, pageId: dialog.id, type: dialog.type, message: dialog.message });
|
|
86
|
+
const record = { id: given, type: dialog.type, message: this.#host.redact(dialog.message), defaultValue: this.#host.redact(dialog.defaultValue) };
|
|
87
|
+
this.#reply(id, { kind: 'opened', dialog: record });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async #dismissLate(app, page, dialog) {
|
|
91
|
+
const answered = await page.answerDialog?.(dialog.id, { accept: false }, this.#host.timeouts.cleanup);
|
|
92
|
+
if (answered?.ok === true)
|
|
93
|
+
this.#emit({ app, type: dialog.type, message: dialog.message, answer: 'dismissed', by: 'retest', stepId: undefined, location: undefined });
|
|
94
|
+
}
|
|
95
|
+
// The action the wait was for failed first, and its own failure fails the test; the wait ends without one.
|
|
96
|
+
#stop(message, wait) {
|
|
97
|
+
const waiting = this.#waits.get(wait);
|
|
98
|
+
if (waiting !== undefined) {
|
|
99
|
+
waiting.stoppedByTest = true;
|
|
100
|
+
waiting.stop.abort(failure('interrupted', 'The action failed before a dialog opened.'));
|
|
101
|
+
}
|
|
102
|
+
this.#reply(message.id, { kind: 'stopped' });
|
|
103
|
+
}
|
|
104
|
+
async #answer(message, request) {
|
|
105
|
+
const { id, location, stepId } = message;
|
|
106
|
+
const refusal = this.#host.refusal();
|
|
107
|
+
if (refusal !== undefined)
|
|
108
|
+
return this.#reply(id, { kind: 'failed', failure: withLocation(refusal, location) });
|
|
109
|
+
const given = this.#given.get(request.dialog);
|
|
110
|
+
if (given === undefined || given.app !== message.app)
|
|
111
|
+
return this.#host.violation(`answered the dialog ${JSON.stringify(request.dialog)}, which Retest did not give it on ${message.app}`);
|
|
112
|
+
this.#given.delete(request.dialog);
|
|
113
|
+
const page = this.#host.page(given.app);
|
|
114
|
+
const timeoutMs = Math.max(1, smallestBudget(message.timeoutMs, this.#host.remainingMs() ?? message.timeoutMs));
|
|
115
|
+
const reply = { accept: request.accept, ...(request.promptText === undefined ? {} : { promptText: request.promptText }) };
|
|
116
|
+
const answered = (await page?.answerDialog?.(given.pageId, reply, timeoutMs)) ?? { ok: false, failure: failure('session_lost', 'The page that showed the dialog is gone.') };
|
|
117
|
+
if (!answered.ok)
|
|
118
|
+
return this.#refuse(id, withLocation(answered.failure, location));
|
|
119
|
+
const typed = request.accept && request.promptText !== undefined ? { typed: request.promptText.length } : {};
|
|
120
|
+
this.#emit({ app: given.app, type: given.type, message: given.message, answer: request.accept ? 'accepted' : 'dismissed', by: 'test', stepId, location, ...typed });
|
|
121
|
+
if (this.#host.answerable())
|
|
122
|
+
this.#reply(id, { kind: 'answered' });
|
|
123
|
+
}
|
|
124
|
+
// A dialog that opened during a command fails that command; one that opened while none ran fails the test here.
|
|
125
|
+
// A dialog answered before the test's first command, as an Electron app's alert at start, is written down and fails
|
|
126
|
+
// nothing: no action of the test opened it, and no change to the test would answer it.
|
|
127
|
+
#unwaited(app, dialog) {
|
|
128
|
+
this.#emit({ app, type: dialog.type, message: dialog.message, answer: dialog.answered, by: 'retest', stepId: this.#host.stepId(), location: undefined });
|
|
129
|
+
if (dialog.during || !this.#host.answerable() || !this.#host.commandsSent())
|
|
130
|
+
return;
|
|
131
|
+
const popup = dialog.popup === true;
|
|
132
|
+
const message = unwaitedDialogMessage({ type: dialog.type, text: this.#host.redact(dialog.message), answered: dialog.answered, popup });
|
|
133
|
+
this.#host.observe({ ...failure('not_actionable', message), details: { dialog: dialog.type, answered: dialog.answered, ...(popup ? { popup: true } : {}) } });
|
|
134
|
+
}
|
|
135
|
+
/** Stops every wait still running, with `reason`, as the test is stopped or ends; none is answered. */
|
|
136
|
+
stop(reason) {
|
|
137
|
+
for (const waiting of this.#waits.values()) {
|
|
138
|
+
waiting.stoppedByTest = true;
|
|
139
|
+
waiting.stop.abort(reason);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
#emit({ app, type, message, answer, by, stepId, location, typed }) {
|
|
143
|
+
const { testId, attemptId } = this.#host;
|
|
144
|
+
const text = truncateText(this.#host.redact(message), dialogTextLimit).text;
|
|
145
|
+
this.#host.emit({
|
|
146
|
+
type: 'dialog',
|
|
147
|
+
testId,
|
|
148
|
+
attemptId,
|
|
149
|
+
...(stepId === undefined ? {} : { stepId }),
|
|
150
|
+
session: app,
|
|
151
|
+
sessionId: formatSessionId(attemptId, app),
|
|
152
|
+
dialog: type,
|
|
153
|
+
message: text,
|
|
154
|
+
answer,
|
|
155
|
+
by,
|
|
156
|
+
...(typed === undefined ? {} : { typed }),
|
|
157
|
+
...(location === undefined ? {} : { location }),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// A failure the parent gave a wait or an answer is its own, as an inbox read's is.
|
|
161
|
+
#refuse(id, problem) {
|
|
162
|
+
if (!this.#host.answerable())
|
|
163
|
+
return;
|
|
164
|
+
this.#host.observe(problem);
|
|
165
|
+
this.#reply(id, { kind: 'failed', failure: problem });
|
|
166
|
+
}
|
|
167
|
+
#reply(id, answer) {
|
|
168
|
+
this.#host.send({ type: 'dialog-result', id, answer });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A command's answer, with a dialog the page answered while the command ran named in words: the page gives the
|
|
173
|
+
* dialog's text apart, as the page showed it, and only the parent can hide the run's values in it before quoting it.
|
|
174
|
+
* Any other answer is given back as it is.
|
|
175
|
+
*
|
|
176
|
+
* @example withDialogText(result, (text) => redactor.redact(text))
|
|
177
|
+
*/
|
|
178
|
+
export function withDialogText(result, redact) {
|
|
179
|
+
if (result.ok)
|
|
180
|
+
return result;
|
|
181
|
+
const failure = withDialogWords(result.failure, redact);
|
|
182
|
+
return failure === result.failure ? result : { ok: false, failure };
|
|
183
|
+
}
|
|
184
|
+
// A native app shows no JavaScript dialog; any other page without dialogs belongs to an engine that refuses them.
|
|
185
|
+
function noDialogs(app, page) {
|
|
186
|
+
if (page instanceof NativePageAdapter)
|
|
187
|
+
return `${app} is a native app, which shows no JavaScript dialog. Answer its alerts with page.alert.`;
|
|
188
|
+
return `The page of ${app} cannot hand its dialogs to a test.`;
|
|
189
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { FilesCapability } from '../browser/contract.ts';
|
|
2
|
+
import type { EventBody } from '../protocol/events.ts';
|
|
3
|
+
import type { Failure, SourceLocation } from '../protocol/failures.ts';
|
|
4
|
+
import type { FilesAnswer, InputFileRecord } from '../protocol/files.ts';
|
|
5
|
+
import type { Redactor } from './redactor.ts';
|
|
6
|
+
/** The folder of a run where Retest writes the files a test gives an input by content, while their test runs. */
|
|
7
|
+
export declare const uploadsFolder = "uploads";
|
|
8
|
+
/**
|
|
9
|
+
* One app's page as the attempt's files see it: its file pickers and downloads, when it has them, where it runs, for a
|
|
10
|
+
* refusal that names it, such as `on Firefox` or `in an Electron app`, its session, and, when it saves downloads, the
|
|
11
|
+
* folder they go to, absolute and as a reference in the run folder.
|
|
12
|
+
*/
|
|
13
|
+
export type FilesPage = {
|
|
14
|
+
readonly files: FilesCapability | undefined;
|
|
15
|
+
readonly where: string;
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
readonly downloads?: {
|
|
18
|
+
readonly folder: string;
|
|
19
|
+
readonly reference: string;
|
|
20
|
+
} | undefined;
|
|
21
|
+
};
|
|
22
|
+
export type AttemptFilesOptions = {
|
|
23
|
+
readonly pages: ReadonlyMap<string, FilesPage>;
|
|
24
|
+
readonly runFolder: string;
|
|
25
|
+
readonly testId: string;
|
|
26
|
+
readonly attemptId: string;
|
|
27
|
+
readonly redactor?: Redactor | undefined;
|
|
28
|
+
readonly emit: (body: EventBody) => void;
|
|
29
|
+
};
|
|
30
|
+
/** How long a request may wait, the signal that stops it, and the step and place of the call that made it. */
|
|
31
|
+
export type FilesBudget = {
|
|
32
|
+
readonly timeoutMs: number;
|
|
33
|
+
readonly signal: AbortSignal;
|
|
34
|
+
readonly stepId?: string | undefined;
|
|
35
|
+
readonly location?: SourceLocation | undefined;
|
|
36
|
+
};
|
|
37
|
+
/** The files of an input command, as the page takes them: absolute paths, and the type the test named for each, or null. */
|
|
38
|
+
export type ResolvedFiles = {
|
|
39
|
+
ok: true;
|
|
40
|
+
paths: string[];
|
|
41
|
+
mimeTypes: (string | null)[];
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
failure: Failure;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* One attempt's files: the files a test gives an input, checked and, when given by content, written to a folder of the
|
|
48
|
+
* run's own; the file pickers a test waits for; and every download an app's browser context makes, saved under the
|
|
49
|
+
* name the page suggested in its session's downloads folder, and written down once it ends, whether or not the test
|
|
50
|
+
* waited for it. A download that holds the value of a secret is removed rather than kept.
|
|
51
|
+
*/
|
|
52
|
+
export declare class AttemptFiles {
|
|
53
|
+
#private;
|
|
54
|
+
constructor(options: AttemptFilesOptions);
|
|
55
|
+
/**
|
|
56
|
+
* The paths a page gives an input for the files a test named: each path read against the folder of the file that
|
|
57
|
+
* made the call, and each file given by content written to a folder of the run's own, named as the test named it.
|
|
58
|
+
* A path with no file at it fails `usage`, naming the path.
|
|
59
|
+
*/
|
|
60
|
+
stage(files: readonly InputFileRecord[], method: string): ResolvedFiles;
|
|
61
|
+
/** Waits for the next file picker the app's page opens, within the budget, and answers with it. */
|
|
62
|
+
chooser(app: string, budget: FilesBudget): Promise<FilesAnswer>;
|
|
63
|
+
/** Waits for the next download the app's page begins, within the budget, and answers with it. */
|
|
64
|
+
download(app: string, budget: FilesBudget): Promise<FilesAnswer>;
|
|
65
|
+
/** Waits for a download the test was given to be saved, within the budget, and answers with its path and size. */
|
|
66
|
+
saved(id: string, budget: FilesBudget): Promise<FilesAnswer>;
|
|
67
|
+
/**
|
|
68
|
+
* Ends the attempt's files: each download still on its way is written down as unfinished, no later one is told of,
|
|
69
|
+
* and the files written for inputs are removed with their folder.
|
|
70
|
+
*/
|
|
71
|
+
close(): void;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A name in `folder` no file holds yet: `name` itself, or with `-2`, `-3` and so on before its extension.
|
|
75
|
+
*
|
|
76
|
+
* @example freeName('/run/downloads', 'tasks.csv') // 'tasks-2.csv' when tasks.csv is there
|
|
77
|
+
*/
|
|
78
|
+
export declare function freeName(folder: string, name: string): string;
|
|
79
|
+
//# sourceMappingURL=files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/runner/files.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,eAAe,EAAgB,MAAM,wBAAwB,CAAA;AACxF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAA;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACxE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAU7C,iHAAiH;AACjH,eAAO,MAAM,aAAa,YAAY,CAAA;AAOtC;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,SAAS,CAAA;IAC3C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAA;CACzF,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;IACxC,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAA;CACzC,CAAA;AAED,8GAA8G;AAC9G,MAAM,MAAM,WAAW,GAAG;IAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,cAAc,GAAG,SAAS,CAAA;CAAE,CAAA;AAE5K,4HAA4H;AAC5H,MAAM,MAAM,aAAa,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAA;AAezH;;;;;GAKG;AACH,qBAAa,YAAY;;gBASX,OAAO,EAAE,mBAAmB;IAQxC;;;;OAIG;IACH,KAAK,CAAC,KAAK,EAAE,SAAS,eAAe,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,aAAa;IA0BvE,mGAAmG;IAC7F,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;IAarE,iGAAiG;IAC3F,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;IAkBtE,kHAAkH;IAC5G,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;IAYlE;;;OAGG;IACH,KAAK,IAAI,IAAI;CAmHd;AASD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAS7D"}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
|
+
import { filesRefused } from "../browser/file-chooser.js";
|
|
4
|
+
import { originAndPath } from "../browser/page-url.js";
|
|
5
|
+
import { errorMessage, failure, failureSchema } from "../protocol/failures.js";
|
|
6
|
+
import { parse } from "../protocol/schema.js";
|
|
7
|
+
import { downloadFileName } from "../store/artifacts.js";
|
|
8
|
+
import { bounded } from "./bounded.js";
|
|
9
|
+
/** The folder of a run where Retest writes the files a test gives an input by content, while their test runs. */
|
|
10
|
+
export const uploadsFolder = 'uploads';
|
|
11
|
+
// A saved download is read for secrets a piece at a time, each piece beginning with the end of the one before, so a
|
|
12
|
+
// value that a piece's edge cuts is still read whole, up to this many characters long.
|
|
13
|
+
const pieceBytes = 8 * 1024 * 1024;
|
|
14
|
+
const pieceOverlap = 64 * 1024;
|
|
15
|
+
/**
|
|
16
|
+
* One attempt's files: the files a test gives an input, checked and, when given by content, written to a folder of the
|
|
17
|
+
* run's own; the file pickers a test waits for; and every download an app's browser context makes, saved under the
|
|
18
|
+
* name the page suggested in its session's downloads folder, and written down once it ends, whether or not the test
|
|
19
|
+
* waited for it. A download that holds the value of a secret is removed rather than kept.
|
|
20
|
+
*/
|
|
21
|
+
export class AttemptFiles {
|
|
22
|
+
#options;
|
|
23
|
+
#kept = new Map();
|
|
24
|
+
// What a `page.download()` on an app waits for: the next download that app begins.
|
|
25
|
+
#waiting = new Map();
|
|
26
|
+
#stopListening = [];
|
|
27
|
+
#staged = 0;
|
|
28
|
+
#closed = false;
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.#options = options;
|
|
31
|
+
for (const [app, page] of options.pages) {
|
|
32
|
+
const stop = page.downloads === undefined ? undefined : page.files?.onDownload?.((download) => this.#began(app, page, download));
|
|
33
|
+
if (stop !== undefined)
|
|
34
|
+
this.#stopListening.push(stop);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The paths a page gives an input for the files a test named: each path read against the folder of the file that
|
|
39
|
+
* made the call, and each file given by content written to a folder of the run's own, named as the test named it.
|
|
40
|
+
* A path with no file at it fails `usage`, naming the path.
|
|
41
|
+
*/
|
|
42
|
+
stage(files, method) {
|
|
43
|
+
const paths = [];
|
|
44
|
+
const mimeTypes = [];
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
if ('path' in file) {
|
|
47
|
+
const path = resolve(file.folder, file.path);
|
|
48
|
+
const problem = fileProblem(path);
|
|
49
|
+
if (problem !== undefined)
|
|
50
|
+
return { ok: false, failure: { ...failure('usage', `${method}() ${problem}`), details: { path, inputSent: false } } };
|
|
51
|
+
paths.push(path);
|
|
52
|
+
mimeTypes.push(null);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
this.#staged += 1;
|
|
56
|
+
const folder = join(this.#options.runFolder, uploadsFolder, this.#options.attemptId, String(this.#staged));
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(folder, { recursive: true });
|
|
59
|
+
writeFileSync(join(folder, file.name), Buffer.from(file.base64, 'base64'));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return { ok: false, failure: failure('setup_failed', `Retest could not write ${file.name} for ${method}(): ${errorMessage(error)}`) };
|
|
63
|
+
}
|
|
64
|
+
paths.push(join(folder, file.name));
|
|
65
|
+
mimeTypes.push(file.mimeType ?? null);
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, paths, mimeTypes };
|
|
68
|
+
}
|
|
69
|
+
/** Waits for the next file picker the app's page opens, within the budget, and answers with it. */
|
|
70
|
+
async chooser(app, budget) {
|
|
71
|
+
const page = this.#options.pages.get(app);
|
|
72
|
+
if (page?.files === undefined)
|
|
73
|
+
return refused(filesRefused('chooser', page?.where ?? 'on this app'));
|
|
74
|
+
const arm = page.files.armFileChooser();
|
|
75
|
+
const opened = await bounded(arm.opened, budget.timeoutMs, aborted(budget.signal));
|
|
76
|
+
if (opened.status === 'done')
|
|
77
|
+
return { ok: true, kind: 'chooser', chooser: opened.value.id, multiple: opened.value.multiple };
|
|
78
|
+
arm.release();
|
|
79
|
+
if (opened.status === 'failed')
|
|
80
|
+
return refused(failureOf(opened.error));
|
|
81
|
+
if (opened.status === 'stopped')
|
|
82
|
+
return refused(stoppedBy(budget.signal, 'Retest stopped waiting for a file picker.'));
|
|
83
|
+
const message = `No file picker opened within ${budget.timeoutMs} ms of page.fileChooser(). Check that its action opens one, as a click on an upload button does.`;
|
|
84
|
+
return refused({ ...failure('timeout', message), details: { timeoutMs: budget.timeoutMs } });
|
|
85
|
+
}
|
|
86
|
+
/** Waits for the next download the app's page begins, within the budget, and answers with it. */
|
|
87
|
+
async download(app, budget) {
|
|
88
|
+
const page = this.#options.pages.get(app);
|
|
89
|
+
if (page?.files?.onDownload === undefined || page.downloads === undefined)
|
|
90
|
+
return refused(filesRefused('download', page?.where ?? 'on this app'));
|
|
91
|
+
if (this.#waiting.has(app))
|
|
92
|
+
return refused(failure('usage', 'page.download() is already waiting for a download on this app. Await it before you wait for the next.'));
|
|
93
|
+
const began = Promise.withResolvers();
|
|
94
|
+
this.#waiting.set(app, began.resolve);
|
|
95
|
+
const waited = await bounded(began.promise, budget.timeoutMs, aborted(budget.signal));
|
|
96
|
+
if (this.#waiting.get(app) === began.resolve)
|
|
97
|
+
this.#waiting.delete(app);
|
|
98
|
+
if (waited.status === 'done') {
|
|
99
|
+
const kept = waited.value;
|
|
100
|
+
kept.given = { stepId: budget.stepId, location: budget.location };
|
|
101
|
+
return { ok: true, kind: 'download', download: kept.id, suggestedFilename: this.#redact(kept.download.suggestedFilename) };
|
|
102
|
+
}
|
|
103
|
+
if (waited.status === 'stopped')
|
|
104
|
+
return refused(stoppedBy(budget.signal, 'Retest stopped waiting for a download.'));
|
|
105
|
+
const message = `No download began within ${budget.timeoutMs} ms of page.download(). Check that its action starts one, as a link to a file does.`;
|
|
106
|
+
return refused({ ...failure('timeout', message), details: { timeoutMs: budget.timeoutMs } });
|
|
107
|
+
}
|
|
108
|
+
/** Waits for a download the test was given to be saved, within the budget, and answers with its path and size. */
|
|
109
|
+
async saved(id, budget) {
|
|
110
|
+
const kept = this.#kept.get(id);
|
|
111
|
+
if (kept?.given === undefined)
|
|
112
|
+
return refused(failure('usage', 'This download is not one page.download() gave this test, so Retest has no file for it.'));
|
|
113
|
+
const saved = await bounded(kept.saved, budget.timeoutMs, aborted(budget.signal));
|
|
114
|
+
if (saved.status === 'done')
|
|
115
|
+
return saved.value.ok ? { ok: true, kind: 'saved', path: saved.value.path, size: saved.value.size } : refused(saved.value.failure);
|
|
116
|
+
if (saved.status === 'failed')
|
|
117
|
+
return refused(failureOf(saved.error));
|
|
118
|
+
if (saved.status === 'stopped')
|
|
119
|
+
return refused(stoppedBy(budget.signal, 'Retest stopped waiting for the download to be saved.'));
|
|
120
|
+
const name = this.#redact(kept.download.suggestedFilename);
|
|
121
|
+
const message = `The download of ${name} was not saved within ${budget.timeoutMs} ms: ${describeProgress(kept.download.progress())} had arrived.`;
|
|
122
|
+
return refused({ ...failure('timeout', message), details: { timeoutMs: budget.timeoutMs } });
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Ends the attempt's files: each download still on its way is written down as unfinished, no later one is told of,
|
|
126
|
+
* and the files written for inputs are removed with their folder.
|
|
127
|
+
*/
|
|
128
|
+
close() {
|
|
129
|
+
if (this.#closed)
|
|
130
|
+
return;
|
|
131
|
+
this.#closed = true;
|
|
132
|
+
for (const stop of this.#stopListening)
|
|
133
|
+
stop();
|
|
134
|
+
for (const kept of this.#kept.values()) {
|
|
135
|
+
if (kept.done)
|
|
136
|
+
continue;
|
|
137
|
+
kept.done = true;
|
|
138
|
+
this.#write(kept, { state: 'unfinished', ...kept.download.progress() });
|
|
139
|
+
}
|
|
140
|
+
rmSync(join(this.#options.runFolder, uploadsFolder, this.#options.attemptId), { recursive: true, force: true });
|
|
141
|
+
removeQuietly(join(this.#options.runFolder, uploadsFolder), rmdirSync);
|
|
142
|
+
}
|
|
143
|
+
// A `page.download()` takes a download the page, a frame inside it or a page it opened began, never one another page of
|
|
144
|
+
// its context began.
|
|
145
|
+
#began(app, page, download) {
|
|
146
|
+
if (this.#closed)
|
|
147
|
+
return;
|
|
148
|
+
const id = `download-${this.#kept.size + 1}`;
|
|
149
|
+
const saved = Promise.withResolvers();
|
|
150
|
+
const kept = { id, app, page, download, saved: saved.promise, done: false };
|
|
151
|
+
this.#kept.set(id, kept);
|
|
152
|
+
void download.ended.then((end) => saved.resolve(this.#ended(kept, end)));
|
|
153
|
+
const waiting = this.#waiting.get(app);
|
|
154
|
+
if (waiting === undefined || download.startedIn === 'other')
|
|
155
|
+
return;
|
|
156
|
+
this.#waiting.delete(app);
|
|
157
|
+
waiting(kept);
|
|
158
|
+
}
|
|
159
|
+
// A completed download takes the name the page suggested, made safe for a file name, with a number before its
|
|
160
|
+
// extension when the folder already holds one by that name. One that holds the value of a secret is removed, even once
|
|
161
|
+
// its test has ended, since a secret never reaches a file of the run. A cancelled one leaves no file of its own behind.
|
|
162
|
+
#ended(kept, end) {
|
|
163
|
+
const folder = kept.page.downloads;
|
|
164
|
+
const name = this.#redact(kept.download.suggestedFilename);
|
|
165
|
+
if (end.state === 'completed' && this.#holdsSecret(end.filePath)) {
|
|
166
|
+
removeQuietly(end.filePath);
|
|
167
|
+
const message = `Retest did not keep the download of ${name}: it holds the value of a secret, and a secret never reaches a file of the run. Retest removed the file.`;
|
|
168
|
+
const withheld = { ...failure('unsupported', message), details: { download: name } };
|
|
169
|
+
if (!kept.done)
|
|
170
|
+
this.#write(kept, { state: 'withheld', size: end.receivedBytes, failure: withheld });
|
|
171
|
+
kept.done = true;
|
|
172
|
+
return { ok: false, failure: withheld };
|
|
173
|
+
}
|
|
174
|
+
if (folder === undefined || kept.done)
|
|
175
|
+
return { ok: false, failure: failure('interrupted', `The test ended before the download of ${name} was saved.`) };
|
|
176
|
+
kept.done = true;
|
|
177
|
+
if (end.state === 'canceled') {
|
|
178
|
+
removeQuietly(join(folder.folder, `${kept.download.guid}.crdownload`));
|
|
179
|
+
const message = `The browser stopped the download of ${name} after ${describeProgress(end)} had arrived, and gives no reason. Retest saved nothing.`;
|
|
180
|
+
const stopped = { ...failure('not_actionable', message), details: { download: name, receivedBytes: end.receivedBytes, totalBytes: end.totalBytes } };
|
|
181
|
+
this.#write(kept, { state: 'canceled', receivedBytes: end.receivedBytes, totalBytes: end.totalBytes, failure: stopped });
|
|
182
|
+
return { ok: false, failure: stopped };
|
|
183
|
+
}
|
|
184
|
+
if (resolve(dirname(end.filePath)) !== resolve(folder.folder)) {
|
|
185
|
+
const outside = failure('setup_failed', `The browser saved the download of ${name} outside its folder, so Retest did not keep it.`);
|
|
186
|
+
this.#write(kept, { state: 'canceled', receivedBytes: end.receivedBytes, totalBytes: end.totalBytes, failure: outside });
|
|
187
|
+
return { ok: false, failure: outside };
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const file = freeName(folder.folder, downloadFileName(name));
|
|
191
|
+
renameSync(end.filePath, join(folder.folder, file));
|
|
192
|
+
const { size } = statSync(join(folder.folder, file));
|
|
193
|
+
this.#write(kept, { state: 'completed', path: `${folder.reference}/${file}`, size });
|
|
194
|
+
return { ok: true, path: join(folder.folder, file), size };
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
const lost = failure('setup_failed', `Retest could not keep the download of ${name}: ${errorMessage(error)}`);
|
|
198
|
+
this.#write(kept, { state: 'canceled', receivedBytes: end.receivedBytes, totalBytes: end.totalBytes, failure: lost });
|
|
199
|
+
return { ok: false, failure: lost };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
#write(kept, ending) {
|
|
203
|
+
const { testId, attemptId } = this.#options;
|
|
204
|
+
const given = kept.given === undefined ? {} : { awaited: true, ...(kept.given.stepId === undefined ? {} : { stepId: kept.given.stepId }), ...(kept.given.location === undefined ? {} : { location: kept.given.location }) };
|
|
205
|
+
this.#options.emit({
|
|
206
|
+
type: 'download',
|
|
207
|
+
testId,
|
|
208
|
+
attemptId,
|
|
209
|
+
session: kept.app,
|
|
210
|
+
sessionId: kept.page.sessionId,
|
|
211
|
+
suggestedFilename: this.#redact(kept.download.suggestedFilename),
|
|
212
|
+
url: this.#redact(downloadAddress(kept.download.url)),
|
|
213
|
+
...(kept.download.startedIn === undefined ? {} : { startedIn: kept.download.startedIn }),
|
|
214
|
+
...ending,
|
|
215
|
+
...given,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
#redact(text) {
|
|
219
|
+
return this.#options.redactor?.redact(text) ?? text;
|
|
220
|
+
}
|
|
221
|
+
// Whether the file holds a value the run hides, read as text in every form the redactor knows it. A run that hides no
|
|
222
|
+
// value reads nothing.
|
|
223
|
+
#holdsSecret(path) {
|
|
224
|
+
const { redactor } = this.#options;
|
|
225
|
+
if (redactor === undefined || !redactor.active)
|
|
226
|
+
return false;
|
|
227
|
+
let file;
|
|
228
|
+
try {
|
|
229
|
+
file = openSync(path, 'r');
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const piece = Buffer.alloc(pieceBytes);
|
|
236
|
+
let carried = '';
|
|
237
|
+
for (;;) {
|
|
238
|
+
const read = readSync(file, piece, 0, pieceBytes, null);
|
|
239
|
+
if (read === 0)
|
|
240
|
+
return false;
|
|
241
|
+
const text = carried + piece.toString('utf8', 0, read);
|
|
242
|
+
if (redactor.redact(text) !== text)
|
|
243
|
+
return true;
|
|
244
|
+
carried = text.slice(-pieceOverlap);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
closeSync(file);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* A name in `folder` no file holds yet: `name` itself, or with `-2`, `-3` and so on before its extension.
|
|
254
|
+
*
|
|
255
|
+
* @example freeName('/run/downloads', 'tasks.csv') // 'tasks-2.csv' when tasks.csv is there
|
|
256
|
+
*/
|
|
257
|
+
export function freeName(folder, name) {
|
|
258
|
+
if (!existsSync(join(folder, name)))
|
|
259
|
+
return name;
|
|
260
|
+
const extension = extname(name);
|
|
261
|
+
const stem = extension === '' || extension === name ? name : name.slice(0, -extension.length);
|
|
262
|
+
const suffix = extension === name ? '' : extension;
|
|
263
|
+
for (let number = 2;; number += 1) {
|
|
264
|
+
const candidate = `${stem}-${number}${suffix}`;
|
|
265
|
+
if (!existsSync(join(folder, candidate)))
|
|
266
|
+
return candidate;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Where a download came from, as an address is recorded: its origin and path, never its query, which can carry what the
|
|
270
|
+
// page was given. An address with no host, such as a blob's or a data address, keeps its scheme alone.
|
|
271
|
+
function downloadAddress(url) {
|
|
272
|
+
const parsed = URL.parse(url);
|
|
273
|
+
return parsed === null ? 'an address Retest cannot read' : originAndPath(parsed);
|
|
274
|
+
}
|
|
275
|
+
function describeProgress({ receivedBytes, totalBytes }) {
|
|
276
|
+
return totalBytes > 0 ? `${receivedBytes} of ${totalBytes} bytes` : `${receivedBytes} bytes`;
|
|
277
|
+
}
|
|
278
|
+
function fileProblem(path) {
|
|
279
|
+
try {
|
|
280
|
+
const stats = statSync(path);
|
|
281
|
+
return stats.isFile() ? undefined : `received ${path}, which is not a file.`;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return `found no file at ${path}.`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// What is gone already, or a folder another attempt still writes in, is left as it is.
|
|
288
|
+
function removeQuietly(path, remove = unlinkSync) {
|
|
289
|
+
try {
|
|
290
|
+
remove(path);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// The browser removes what it left of some downloads itself, and an attempt that still runs keeps its files.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function refused(problem) {
|
|
297
|
+
return { ok: false, failure: problem };
|
|
298
|
+
}
|
|
299
|
+
// A browser's refusal of an arm carries its failure; anything else thrown is the browser call failing.
|
|
300
|
+
function failureOf(error) {
|
|
301
|
+
const carried = typeof error === 'object' && error !== null && 'failure' in error ? parse(failureSchema, error.failure) : undefined;
|
|
302
|
+
return carried?.ok === true ? carried.value : failure('session_lost', `The browser call failed: ${errorMessage(error)}`);
|
|
303
|
+
}
|
|
304
|
+
function stoppedBy(signal, fallback) {
|
|
305
|
+
const parsed = parse(failureSchema, signal.reason);
|
|
306
|
+
return parsed.ok ? parsed.value : failure('interrupted', fallback);
|
|
307
|
+
}
|
|
308
|
+
function aborted(signal) {
|
|
309
|
+
if (signal.aborted)
|
|
310
|
+
return Promise.resolve();
|
|
311
|
+
return new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }));
|
|
312
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A message's link as Retest reads it: the address it parsed, and the parts of it that read as tokens, each as the
|
|
3
|
+
* address writes it and decoded, with no repeats. A link with any such part carries a token.
|
|
4
|
+
*/
|
|
5
|
+
export type ReadLink = {
|
|
6
|
+
readonly url: URL;
|
|
7
|
+
readonly tokens: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Reads a link from a message, or undefined when its address is not an http or https URL. A password in the address is
|
|
11
|
+
* a token. A part reads as a token when it is one of the message's codes; when it is a query parameter, or a parameter of a fragment written as a query,
|
|
12
|
+
* whose name speaks of a credential; or when it reads as one as diagnostics read a path segment: 16 letters and digits
|
|
13
|
+
* or more, 12 digits or more, or a JSON Web Token's shape, and not a file name, a version or a date. That rule applies
|
|
14
|
+
* to each path segment, each query value and the fragment.
|
|
15
|
+
*
|
|
16
|
+
* @example readLink('https://app.test/verify/9f8e7d6c5b4a39281706f5e4?utm=mail', [])?.tokens // ['9f8e7d6c5b4a39281706f5e4']
|
|
17
|
+
*/
|
|
18
|
+
export declare function readLink(address: string, codes: readonly string[]): ReadLink | undefined;
|
|
19
|
+
//# sourceMappingURL=inbox-links.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox-links.d.ts","sourceRoot":"","sources":["../../src/runner/inbox-links.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG;IAAE,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAA;AAMhF;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,QAAQ,GAAG,SAAS,CAexF"}
|