@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,271 @@
|
|
|
1
|
+
import { Deadline } from "../protocol/deadline.js";
|
|
2
|
+
import { isDialogType, unwaitedDialogMessage, unwaitedDialogSentence } from "../protocol/dialogs.js";
|
|
3
|
+
import { errorMessage, failureSchema } from "../protocol/failures.js";
|
|
4
|
+
import { parse, s } from "../protocol/schema.js";
|
|
5
|
+
import { readProtocol, sendOptions } from "./cdp-results.js";
|
|
6
|
+
import { CdpClosedError, CdpDisconnectedError } from "./cdp/errors.js";
|
|
7
|
+
import { Listeners } from "./listeners.js";
|
|
8
|
+
// Every part but the kind is read leniently: a dialog whose event Retest could not read would hold the page for good.
|
|
9
|
+
const openingSchema = s.object({ type: s.string(), message: s.optional(s.string()), defaultPrompt: s.optional(s.string()) });
|
|
10
|
+
const closedSchema = s.object({ result: s.boolean() });
|
|
11
|
+
/**
|
|
12
|
+
* The JavaScript dialogs of one Chromium page. A dialog holds the page until it is answered: the command whose input
|
|
13
|
+
* opened it waits, as Chrome does not answer that input before then. A page armed with `arm` hands its next dialog to
|
|
14
|
+
* the test, which answers it with `answer`. Any other dialog Retest answers at once, dismissing it, or accepting a
|
|
15
|
+
* `beforeunload` so the navigation the test asked for happens, and tells its listeners; a command running meanwhile
|
|
16
|
+
* fails with it, so no dialog is ever answered silently as a pass.
|
|
17
|
+
*/
|
|
18
|
+
export class PageDialogs {
|
|
19
|
+
#session;
|
|
20
|
+
#unwaited;
|
|
21
|
+
#closed;
|
|
22
|
+
// Every dialog Retest answered itself, in order, so a command can tell which arrived while it ran.
|
|
23
|
+
#answeredByRetest = [];
|
|
24
|
+
// How many `beforeunload` dialogs were dismissed, each keeping the page where it was.
|
|
25
|
+
#pagesKept = 0;
|
|
26
|
+
#waiter;
|
|
27
|
+
#open;
|
|
28
|
+
#opened = 0;
|
|
29
|
+
#running = 0;
|
|
30
|
+
#lostReason;
|
|
31
|
+
constructor(session, onListenerError) {
|
|
32
|
+
this.#session = session;
|
|
33
|
+
this.#unwaited = new Listeners(onListenerError);
|
|
34
|
+
this.#closed = new Listeners(onListenerError);
|
|
35
|
+
}
|
|
36
|
+
/** The kind of the dialog open on the page, while one is. */
|
|
37
|
+
get open() {
|
|
38
|
+
return this.#open?.type;
|
|
39
|
+
}
|
|
40
|
+
/** The dialog the test was given and has not answered, which holds the page until it does. */
|
|
41
|
+
get held() {
|
|
42
|
+
const open = this.#open;
|
|
43
|
+
return open?.by === 'test' && open.answering !== true ? open : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Arms the page for its next dialog, at once, so a command sent after this call cannot open a dialog Retest would
|
|
47
|
+
* answer itself. The wait settles with that dialog, or fails when `timeoutMs` runs out, when `signal` aborts, with its
|
|
48
|
+
* reason when that is a `Failure`, or when the page goes. One wait at a time.
|
|
49
|
+
*/
|
|
50
|
+
arm(timeoutMs, signal) {
|
|
51
|
+
if (this.#lostReason !== undefined)
|
|
52
|
+
return { ok: false, failure: lost(this.#lostReason) };
|
|
53
|
+
if (this.#waiter !== undefined)
|
|
54
|
+
return { ok: false, failure: { class: 'usage', message: 'page.dialog() is already waiting for a dialog on this page. Await it before the next one.' } };
|
|
55
|
+
const deadline = new Deadline(timeoutMs, { signal });
|
|
56
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
57
|
+
const timer = setTimeout(() => settle({ ok: false, failure: { class: 'timeout', message: `No dialog opened within ${deadline.budgetMs} ms.` } }), deadline.waitToEndMs);
|
|
58
|
+
const stop = () => settle({ ok: false, failure: stopped(signal?.reason) });
|
|
59
|
+
const waiter = { settle: (wait) => settle(wait) };
|
|
60
|
+
const settle = (wait) => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
signal?.removeEventListener('abort', stop);
|
|
63
|
+
if (this.#waiter === waiter)
|
|
64
|
+
this.#waiter = undefined;
|
|
65
|
+
resolve(wait);
|
|
66
|
+
};
|
|
67
|
+
this.#waiter = waiter;
|
|
68
|
+
if (signal?.aborted === true)
|
|
69
|
+
stop();
|
|
70
|
+
else
|
|
71
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
72
|
+
return { ok: true, opened: promise };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Answers the dialog the test was given, accepting it with a prompt's text or dismissing it, within `timeoutMs`.
|
|
76
|
+
* A dialog that is no longer open, or one the test was not given, fails and nothing is sent.
|
|
77
|
+
*/
|
|
78
|
+
async answer(id, reply, timeoutMs) {
|
|
79
|
+
const open = this.#open;
|
|
80
|
+
if (open === undefined || open.id !== id || open.by !== 'test' || open.answering === true) {
|
|
81
|
+
const message = this.#lostReason === undefined ? 'The dialog is no longer open, so Retest answered nothing.' : `The page is gone, so Retest answered nothing: ${this.#lostReason}.`;
|
|
82
|
+
return { ok: false, failure: { class: 'not_actionable', message } };
|
|
83
|
+
}
|
|
84
|
+
if (reply.promptText !== undefined && open.type !== 'prompt') {
|
|
85
|
+
return { ok: false, failure: { class: 'usage', message: `accept() takes text only for a prompt dialog, and this is ${article(open.type)} ${open.type} dialog.` } };
|
|
86
|
+
}
|
|
87
|
+
// Chrome answers a prompt accepted with no text with an empty one; a person who presses OK submits the default.
|
|
88
|
+
const promptText = reply.promptText ?? (reply.accept && open.type === 'prompt' ? open.defaultValue : undefined);
|
|
89
|
+
const params = { accept: reply.accept, ...(promptText === undefined ? {} : { promptText }) };
|
|
90
|
+
open.answering = true;
|
|
91
|
+
// Chrome answers the navigation a dismissed beforeunload stopped before it says the dialog closed, so it is counted now.
|
|
92
|
+
if (open.type === 'beforeunload' && !reply.accept)
|
|
93
|
+
this.#pagesKept += 1;
|
|
94
|
+
try {
|
|
95
|
+
await this.#session.send('Page.handleJavaScriptDialog', params, sendOptions(new Deadline(timeoutMs)));
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof CdpDisconnectedError || error instanceof CdpClosedError)
|
|
100
|
+
return { ok: false, failure: lost(error.reason) };
|
|
101
|
+
return { ok: false, failure: { class: 'outcome_unknown', message: `Retest asked the browser to answer the ${open.type} dialog, and could not tell whether it did: ${errorMessage(error)}` } };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Takes on the dialogs Retest answered on the page before the page was set up, as an Electron app's first window can
|
|
106
|
+
* show one as it loads. Listeners hear them first, as dialogs nothing waited for that opened while no command ran.
|
|
107
|
+
*/
|
|
108
|
+
answeredBefore(dialogs) {
|
|
109
|
+
this.#answeredByRetest.push(...dialogs);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Hears each dialog Retest answered itself, those before the call first, since a popup runs before whoever waits for
|
|
113
|
+
* it can listen. Returns a function that stops listening.
|
|
114
|
+
*/
|
|
115
|
+
onUnwaited(listener) {
|
|
116
|
+
for (const dialog of this.#answeredByRetest)
|
|
117
|
+
listener(dialog);
|
|
118
|
+
return this.#unwaited.add(listener);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Tells of a dialog Retest answered in a page this page opened while nothing waited for it, as this page's own: a
|
|
122
|
+
* command running here fails with it, and the listeners hear it.
|
|
123
|
+
*/
|
|
124
|
+
forward(dialog) {
|
|
125
|
+
const told = { ...dialog, during: this.#running > 0, popup: true };
|
|
126
|
+
this.#answeredByRetest.push(told);
|
|
127
|
+
this.#unwaited.emit(told);
|
|
128
|
+
}
|
|
129
|
+
/** Hears each dialog that closed, however it was answered. Returns a function that stops listening. */
|
|
130
|
+
onClosed(listener) {
|
|
131
|
+
return this.#closed.add(listener);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Marks a command running on the page. The function it returns marks it over, and says what dialogs did meanwhile:
|
|
135
|
+
* the first Retest answered itself, which the command then fails with, and whether a `beforeunload` dialog was
|
|
136
|
+
* dismissed, which kept the page where it was.
|
|
137
|
+
*/
|
|
138
|
+
commandStarted() {
|
|
139
|
+
const from = this.#answeredByRetest.length;
|
|
140
|
+
const kept = this.#pagesKept;
|
|
141
|
+
this.#running += 1;
|
|
142
|
+
return () => {
|
|
143
|
+
this.#running -= 1;
|
|
144
|
+
return { unwaited: this.#answeredByRetest[from], kept: this.#pagesKept > kept };
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Reads `Page.javascriptDialogOpening`: hands the dialog to the test that waits for it, or answers it at once. */
|
|
148
|
+
opened(params) {
|
|
149
|
+
const { type, message = '', defaultPrompt } = readProtocol(openingSchema, params, { method: 'Page.javascriptDialogOpening', sessionId: this.#session.id });
|
|
150
|
+
// A kind this release does not know is handled as an alert is, so the page is never left held.
|
|
151
|
+
const kind = isDialogType(type) ? type : 'alert';
|
|
152
|
+
this.#opened += 1;
|
|
153
|
+
const dialog = { id: `dialog-${this.#opened}`, type: kind, message, defaultValue: kind === 'prompt' ? (defaultPrompt ?? '') : '' };
|
|
154
|
+
const waiter = this.#waiter;
|
|
155
|
+
if (waiter !== undefined) {
|
|
156
|
+
this.#open = { ...dialog, by: 'test' };
|
|
157
|
+
waiter.settle({ ok: true, dialog });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const accept = kind === 'beforeunload';
|
|
161
|
+
this.#open = { ...dialog, by: 'retest', answering: true };
|
|
162
|
+
const unwaited = { type: kind, message, answered: accept ? 'accepted' : 'dismissed', during: this.#running > 0 };
|
|
163
|
+
this.#answeredByRetest.push(unwaited);
|
|
164
|
+
this.#session.send('Page.handleJavaScriptDialog', { accept }).catch(() => {
|
|
165
|
+
// A page that went away took its dialog with it; a command waiting on it hears of the loss itself.
|
|
166
|
+
});
|
|
167
|
+
this.#unwaited.emit(unwaited);
|
|
168
|
+
}
|
|
169
|
+
/** Reads `Page.javascriptDialogClosed`. */
|
|
170
|
+
closed(params) {
|
|
171
|
+
const open = this.#open;
|
|
172
|
+
this.#open = undefined;
|
|
173
|
+
if (open === undefined)
|
|
174
|
+
return;
|
|
175
|
+
const { result } = readProtocol(closedSchema, params, { method: 'Page.javascriptDialogClosed', sessionId: this.#session.id });
|
|
176
|
+
this.#closed.emit({ type: open.type, accepted: result });
|
|
177
|
+
}
|
|
178
|
+
/** The page went away: a wait fails with the reason, and no dialog is open any more. */
|
|
179
|
+
lost(reason) {
|
|
180
|
+
this.#lostReason ??= reason;
|
|
181
|
+
this.#open = undefined;
|
|
182
|
+
this.#waiter?.settle({ ok: false, failure: lost(reason) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The failure of a command during which the page opened a dialog nothing waited for, which Retest answered. `command`
|
|
187
|
+
* is the command as a failure names it. The dialog's text is page text: it goes in `details.dialogMessage`, as the page
|
|
188
|
+
* showed it, for the parent to redact and quote, and the message leaves it out.
|
|
189
|
+
*
|
|
190
|
+
* @example unwaitedDialogFailure("click getByTestId('delete')", dialog, true).class // 'not_actionable'
|
|
191
|
+
*/
|
|
192
|
+
export function unwaitedDialogFailure(command, dialog, inputSent) {
|
|
193
|
+
const popup = dialog.popup === true ? { popup: true } : {};
|
|
194
|
+
return {
|
|
195
|
+
class: 'not_actionable',
|
|
196
|
+
message: unwaitedDialogMessage({ type: dialog.type, answered: dialog.answered, command, popup: dialog.popup === true }),
|
|
197
|
+
details: { dialog: dialog.type, dialogMessage: dialog.message, answered: dialog.answered, command, ...popup, inputSent },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A command's own failure, with the dialog Retest answered while it ran named after it: the command's class, message
|
|
202
|
+
* and details stay first, since its outcome is what happened, and the dialog follows in a sentence of its own and in
|
|
203
|
+
* `details`. The dialog's text goes in `details.dialogMessage` for the parent to redact and quote.
|
|
204
|
+
*
|
|
205
|
+
* @example withUnwaitedDialog(timedOut, dialog).class // 'timeout'
|
|
206
|
+
*/
|
|
207
|
+
export function withUnwaitedDialog(failure, dialog) {
|
|
208
|
+
const popup = dialog.popup === true ? { popup: true } : {};
|
|
209
|
+
return {
|
|
210
|
+
...failure,
|
|
211
|
+
message: `${failure.message} ${unwaitedDialogSentence({ type: dialog.type, answered: dialog.answered, popup: dialog.popup === true })}`,
|
|
212
|
+
details: { ...failure.details, dialog: dialog.type, dialogMessage: dialog.message, answered: dialog.answered, ...popup },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The failure of a command the page did not follow because a `beforeunload` dialog was dismissed, which keeps the page
|
|
217
|
+
* where it was.
|
|
218
|
+
*
|
|
219
|
+
* @example keptByBeforeunload('open http://127.0.0.1:4173/next', true).class // 'not_actionable'
|
|
220
|
+
*/
|
|
221
|
+
export function keptByBeforeunload(command, inputSent) {
|
|
222
|
+
return { class: 'not_actionable', message: `Retest could not ${command}: its beforeunload dialog was dismissed, so the page stayed where it was.`, details: { dialog: 'beforeunload', inputSent } };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A command's own failure of another kind, as a timeout, while a `beforeunload` dialog the test dismissed kept the page:
|
|
226
|
+
* its class and message stay first, and the kept page follows.
|
|
227
|
+
*
|
|
228
|
+
* @example withKeptPage(timedOut).message.endsWith('so the page stayed where it was.') // true
|
|
229
|
+
*/
|
|
230
|
+
export function withKeptPage(failure) {
|
|
231
|
+
return { ...failure, message: `${failure.message} Its beforeunload dialog was dismissed, so the page stayed where it was.`, details: { ...failure.details, dialog: 'beforeunload' } };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The failure of a command that met a dialog the test was given and has not answered, which holds the page: one sent
|
|
235
|
+
* while it holds the page, which sends nothing, or the command whose input opened it and whose time ran out waiting.
|
|
236
|
+
*
|
|
237
|
+
* @example heldByDialog("click getByTestId('save')", 'confirm', false).class // 'not_actionable'
|
|
238
|
+
*/
|
|
239
|
+
export function heldByDialog(command, type, inputSent) {
|
|
240
|
+
if (inputSent) {
|
|
241
|
+
return {
|
|
242
|
+
class: 'timeout',
|
|
243
|
+
message: `Retest began to ${command}, and the ${type} dialog the test was given held the page until the time ran out. Answer it with dialog.accept() or dialog.dismiss().`,
|
|
244
|
+
details: { dialog: type, inputSent: true },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
class: 'not_actionable',
|
|
249
|
+
message: `Retest could not ${command}: the ${type} dialog the test was given still holds the page. Answer it first with dialog.accept() or dialog.dismiss().`,
|
|
250
|
+
details: { dialog: type, inputSent: false },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The refusal of `page.dialog()` on a page of an engine that cannot hand its dialogs to a test yet.
|
|
255
|
+
*
|
|
256
|
+
* @example dialogsUnsupported('Firefox').class // 'unsupported'
|
|
257
|
+
*/
|
|
258
|
+
export function dialogsUnsupported(engine) {
|
|
259
|
+
return { class: 'unsupported', message: `Retest hands dialogs to a test on Chrome and Chromium only, and not on ${engine} yet.` };
|
|
260
|
+
}
|
|
261
|
+
function lost(reason) {
|
|
262
|
+
return { class: 'session_lost', message: `The page went away before a dialog was answered: ${reason}.` };
|
|
263
|
+
}
|
|
264
|
+
// A wait stopped by its test or its caller says why, with the class the reason gives.
|
|
265
|
+
function stopped(reason) {
|
|
266
|
+
const parsed = parse(failureSchema, reason);
|
|
267
|
+
return parsed.ok ? parsed.value : { class: 'interrupted', message: 'Retest stopped waiting for a dialog.' };
|
|
268
|
+
}
|
|
269
|
+
function article(word) {
|
|
270
|
+
return /^[aeiou]/.test(word) ? 'an' : 'a';
|
|
271
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CdpConnection } from './cdp/connection.ts';
|
|
2
|
+
import type { CdpSession } from './cdp/session.ts';
|
|
3
|
+
import type { DownloadStart, PageDownload } from './contract.ts';
|
|
4
|
+
import type { Deadline } from '../protocol/deadline.ts';
|
|
5
|
+
/** A download as the browser began it, before its page is known. */
|
|
6
|
+
type Began = {
|
|
7
|
+
guid: string;
|
|
8
|
+
url: string;
|
|
9
|
+
suggestedFilename: string;
|
|
10
|
+
};
|
|
11
|
+
type Progress = {
|
|
12
|
+
guid: string;
|
|
13
|
+
totalBytes: number;
|
|
14
|
+
receivedBytes: number;
|
|
15
|
+
state: 'inProgress' | 'completed' | 'canceled';
|
|
16
|
+
filePath?: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
/** What a page's downloads are told of it: its session, its main frame as it stands, its context, and whether it is a popup. */
|
|
19
|
+
export type PageDownloadsOptions = {
|
|
20
|
+
session: CdpSession;
|
|
21
|
+
mainFrame: () => string;
|
|
22
|
+
browserContextId: string;
|
|
23
|
+
popup: boolean;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The browser's downloads, each routed to the test's page of the context it began in. A download begun by that page or
|
|
27
|
+
* a frame inside it is the page's own. One begun by a page it opened, or by a frame of such a page, is told as begun in
|
|
28
|
+
* a popup; any other download of the context, as begun in another page. A download whose frame Retest was told of in no
|
|
29
|
+
* way is placed once it is saved, by the folder it was saved in, as begun in another page.
|
|
30
|
+
*/
|
|
31
|
+
export declare class BrowserDownloads {
|
|
32
|
+
#private;
|
|
33
|
+
constructor(connection: CdpConnection, onListenerError: (error: unknown) => void);
|
|
34
|
+
/**
|
|
35
|
+
* Tells the browser what to do with a context's downloads: save each in `folder`, named by its id until Retest names
|
|
36
|
+
* it, and say how each goes; or, without a folder, refuse them, so none lands in the person's own Downloads folder.
|
|
37
|
+
* The first context that saves them has the browser tell of every target it makes from then on.
|
|
38
|
+
*
|
|
39
|
+
* @example await downloads.configure(browserContextId, '/run/artifacts/k3v9q0x2mb/web-1a2b/downloads', deadline)
|
|
40
|
+
*/
|
|
41
|
+
configure(browserContextId: string, folder: string | undefined, deadline: Deadline): Promise<void>;
|
|
42
|
+
/** The downloads of one page, until `close` is called on them. */
|
|
43
|
+
page(options: PageDownloadsOptions): PageDownloads;
|
|
44
|
+
}
|
|
45
|
+
type PageDownloadsParts = PageDownloadsOptions & {
|
|
46
|
+
onListenerError: (error: unknown) => void;
|
|
47
|
+
closed: () => void;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The downloads one page is told of: those begun in its main frame and in the frames inside it, which the page tells of
|
|
51
|
+
* as they attach and detach, and, for the test's own page of a context, those begun anywhere else in its context.
|
|
52
|
+
*/
|
|
53
|
+
export declare class PageDownloads {
|
|
54
|
+
#private;
|
|
55
|
+
readonly mainFrame: () => string;
|
|
56
|
+
readonly context: string;
|
|
57
|
+
readonly popup: boolean;
|
|
58
|
+
constructor({ session, mainFrame, browserContextId, popup, onListenerError, closed }: PageDownloadsParts);
|
|
59
|
+
/** Whether the frame is this page's: its main frame, or a frame inside it the page told of. */
|
|
60
|
+
holds(frameId: string): boolean;
|
|
61
|
+
/** Tells `listener` of every download the page is told of from now on. Returns a function that removes it. */
|
|
62
|
+
onDownload(listener: (download: PageDownload) => void): () => void;
|
|
63
|
+
began({ guid, url, suggestedFilename, startedIn }: Began & {
|
|
64
|
+
startedIn?: DownloadStart;
|
|
65
|
+
}): void;
|
|
66
|
+
progressed({ guid, receivedBytes, totalBytes, state, filePath }: Progress): void;
|
|
67
|
+
/** Stops routing downloads to this page and tells no listener of any more. A download on its way is left as it is. */
|
|
68
|
+
close(): void;
|
|
69
|
+
}
|
|
70
|
+
export {};
|
|
71
|
+
//# sourceMappingURL=downloads.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"downloads.d.ts","sourceRoot":"","sources":["../../src/browser/downloads.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAClD,OAAO,KAAK,EAAe,aAAa,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAC7E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AA6BvD,oEAAoE;AACpE,KAAK,KAAK,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAA;AAErE,KAAK,QAAQ,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,YAAY,GAAG,WAAW,GAAG,UAAU,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAA;AAE1J,gIAAgI;AAChI,MAAM,MAAM,oBAAoB,GAAG;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,SAAS,EAAE,MAAM,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAA;AAK7H;;;;;GAKG;AACH,qBAAa,gBAAgB;;gBAaf,UAAU,EAAE,aAAa,EAAE,eAAe,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI;IAShF;;;;;;OAMG;IACG,SAAS,CAAC,gBAAgB,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IASxG,kEAAkE;IAClE,IAAI,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa;CAuGnD;AAED,KAAK,kBAAkB,GAAG,oBAAoB,GAAG;IAAE,eAAe,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,IAAI,CAAA;CAAE,CAAA;AAKlH;;;GAGG;AACH,qBAAa,aAAa;;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,MAAM,CAAA;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;gBAOX,EAAE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,EAAE,kBAAkB;IAiBxG,+FAA+F;IAC/F,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAI/B,8GAA8G;IAC9G,UAAU,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,GAAG,MAAM,IAAI;IAIlE,KAAK,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,iBAAiB,EAAE,SAAS,EAAE,EAAE,KAAK,GAAG;QAAE,SAAS,CAAC,EAAE,aAAa,CAAA;KAAE,GAAG,IAAI;IAmB/F,UAAU,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,QAAQ,GAAG,IAAI;IAYhF,sHAAsH;IACtH,KAAK,IAAI,IAAI;CAMd"}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { s } from "../protocol/schema.js";
|
|
3
|
+
import { readProtocol, request, sendOptions } from "./cdp-results.js";
|
|
4
|
+
import { Listeners } from "./listeners.js";
|
|
5
|
+
// Chrome tells of a download on the browser's own session, by the frame that began it, whatever context the frame is
|
|
6
|
+
// in, so the browser routes each one to the test's page of the context the frame is in. `Page.downloadWillBegin` would
|
|
7
|
+
// come on the page's session, but Chrome marks it deprecated in favour of these. A page a page opens, as a link with
|
|
8
|
+
// `target="_blank"` does, is a target of its own, often gone a moment after its download begins, so the browser keeps
|
|
9
|
+
// what it was told of every page and frame target in a context that saves downloads, from the moment it is made.
|
|
10
|
+
const willBeginSchema = s.object({ frameId: s.string(), guid: s.string(), url: s.string(), suggestedFilename: s.string() });
|
|
11
|
+
const progressSchema = s.object({
|
|
12
|
+
guid: s.string(),
|
|
13
|
+
totalBytes: s.number({ min: 0 }),
|
|
14
|
+
receivedBytes: s.number({ min: 0 }),
|
|
15
|
+
state: s.enum(['inProgress', 'completed', 'canceled']),
|
|
16
|
+
filePath: s.optional(s.string()),
|
|
17
|
+
});
|
|
18
|
+
const targetSchema = s.object({
|
|
19
|
+
targetInfo: s.object({ targetId: s.string(), type: s.string(), browserContextId: s.optional(s.string()), openerId: s.optional(s.string()), parentId: s.optional(s.string()) }),
|
|
20
|
+
});
|
|
21
|
+
const frameSchema = s.object({ frameId: s.string() });
|
|
22
|
+
const navigatedSchema = s.object({ frame: s.object({ id: s.string(), parentId: s.optional(s.string()) }) });
|
|
23
|
+
// How far a frame or a popup is followed back to the page that holds it: a popup's popup's frame is three steps.
|
|
24
|
+
const maxSteps = 8;
|
|
25
|
+
/**
|
|
26
|
+
* The browser's downloads, each routed to the test's page of the context it began in. A download begun by that page or
|
|
27
|
+
* a frame inside it is the page's own. One begun by a page it opened, or by a frame of such a page, is told as begun in
|
|
28
|
+
* a popup; any other download of the context, as begun in another page. A download whose frame Retest was told of in no
|
|
29
|
+
* way is placed once it is saved, by the folder it was saved in, as begun in another page.
|
|
30
|
+
*/
|
|
31
|
+
export class BrowserDownloads {
|
|
32
|
+
#connection;
|
|
33
|
+
#onListenerError;
|
|
34
|
+
#pages = new Set();
|
|
35
|
+
// The folder each context that saves downloads saves them in, by context.
|
|
36
|
+
#folders = new Map();
|
|
37
|
+
#targets = new Map();
|
|
38
|
+
// The page each download on its way was told to, by the download's id, until it ends or that page closes.
|
|
39
|
+
#owners = new Map();
|
|
40
|
+
// Downloads no page could be found for as they began, by id, until they end.
|
|
41
|
+
#unplaced = new Map();
|
|
42
|
+
#discovering;
|
|
43
|
+
constructor(connection, onListenerError) {
|
|
44
|
+
this.#connection = connection;
|
|
45
|
+
this.#onListenerError = onListenerError;
|
|
46
|
+
connection.on('Browser.downloadWillBegin', (params) => this.#guarded(() => this.#began(params)));
|
|
47
|
+
connection.on('Browser.downloadProgress', (params) => this.#guarded(() => this.#progressed(params)));
|
|
48
|
+
connection.on('Target.targetCreated', (params) => this.#guarded(() => this.#targetTold(params)));
|
|
49
|
+
connection.on('Target.targetInfoChanged', (params) => this.#guarded(() => this.#targetTold(params)));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Tells the browser what to do with a context's downloads: save each in `folder`, named by its id until Retest names
|
|
53
|
+
* it, and say how each goes; or, without a folder, refuse them, so none lands in the person's own Downloads folder.
|
|
54
|
+
* The first context that saves them has the browser tell of every target it makes from then on.
|
|
55
|
+
*
|
|
56
|
+
* @example await downloads.configure(browserContextId, '/run/artifacts/k3v9q0x2mb/web-1a2b/downloads', deadline)
|
|
57
|
+
*/
|
|
58
|
+
async configure(browserContextId, folder, deadline) {
|
|
59
|
+
if (folder !== undefined) {
|
|
60
|
+
this.#folders.set(browserContextId, resolve(folder));
|
|
61
|
+
await this.#discover(deadline);
|
|
62
|
+
}
|
|
63
|
+
const behavior = folder === undefined ? { behavior: 'deny', browserContextId } : { behavior: 'allowAndName', browserContextId, downloadPath: folder, eventsEnabled: true };
|
|
64
|
+
await request(this.#connection, 'Browser.setDownloadBehavior', behavior, s.object({}), sendOptions(deadline));
|
|
65
|
+
}
|
|
66
|
+
/** The downloads of one page, until `close` is called on them. */
|
|
67
|
+
page(options) {
|
|
68
|
+
const downloads = new PageDownloads({ ...options, onListenerError: this.#onListenerError, closed: () => this.#closed(downloads) });
|
|
69
|
+
this.#pages.add(downloads);
|
|
70
|
+
return downloads;
|
|
71
|
+
}
|
|
72
|
+
// A start that failed is tried again by the next page that saves downloads.
|
|
73
|
+
#discover(deadline) {
|
|
74
|
+
const discovering = (this.#discovering ??= request(this.#connection, 'Target.setDiscoverTargets', { discover: true }, s.object({}), sendOptions(deadline)).then(() => undefined));
|
|
75
|
+
discovering.catch(() => {
|
|
76
|
+
if (this.#discovering === discovering)
|
|
77
|
+
this.#discovering = undefined;
|
|
78
|
+
});
|
|
79
|
+
return discovering;
|
|
80
|
+
}
|
|
81
|
+
#targetTold(params) {
|
|
82
|
+
const { targetId, type, browserContextId, openerId, parentId } = readProtocol(targetSchema, params, { method: 'Target.targetCreated' }).targetInfo;
|
|
83
|
+
if ((type !== 'page' && type !== 'iframe') || browserContextId === undefined || !this.#folders.has(browserContextId))
|
|
84
|
+
return;
|
|
85
|
+
const known = this.#targets.get(targetId);
|
|
86
|
+
this.#targets.set(targetId, { context: browserContextId, opener: openerId ?? known?.opener, parent: parentId ?? known?.parent });
|
|
87
|
+
}
|
|
88
|
+
#began(params) {
|
|
89
|
+
const { frameId, guid, url, suggestedFilename } = readProtocol(willBeginSchema, params, { method: 'Browser.downloadWillBegin' });
|
|
90
|
+
const holder = [...this.#pages].find((page) => page.holds(frameId));
|
|
91
|
+
const target = this.#targets.get(frameId);
|
|
92
|
+
const context = holder?.context ?? target?.context;
|
|
93
|
+
const home = context === undefined ? undefined : this.#home(context);
|
|
94
|
+
if (home === undefined) {
|
|
95
|
+
this.#unplaced.set(guid, { guid, url, suggestedFilename });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const startedIn = holder === home ? undefined : holder !== undefined && holder.popup ? this.#openedBy(holder.mainFrame(), home) : this.#startOf(frameId, home);
|
|
99
|
+
this.#owners.set(guid, home);
|
|
100
|
+
home.began({ guid, url, suggestedFilename, ...(startedIn === undefined ? {} : { startedIn }) });
|
|
101
|
+
}
|
|
102
|
+
#progressed(params) {
|
|
103
|
+
const progress = readProtocol(progressSchema, params, { method: 'Browser.downloadProgress' });
|
|
104
|
+
const final = progress.state !== 'inProgress';
|
|
105
|
+
const owner = this.#owners.get(progress.guid);
|
|
106
|
+
if (owner !== undefined) {
|
|
107
|
+
if (final)
|
|
108
|
+
this.#owners.delete(progress.guid);
|
|
109
|
+
owner.progressed(progress);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const began = this.#unplaced.get(progress.guid);
|
|
113
|
+
if (began === undefined || !final)
|
|
114
|
+
return;
|
|
115
|
+
this.#unplaced.delete(progress.guid);
|
|
116
|
+
const home = progress.filePath === undefined ? undefined : this.#homeByFolder(dirname(progress.filePath));
|
|
117
|
+
if (home === undefined)
|
|
118
|
+
return;
|
|
119
|
+
home.began({ ...began, startedIn: 'other' });
|
|
120
|
+
home.progressed(progress);
|
|
121
|
+
}
|
|
122
|
+
// The test's page of a context: the one page of it that is no popup.
|
|
123
|
+
#home(context) {
|
|
124
|
+
return [...this.#pages].find((page) => page.context === context && !page.popup);
|
|
125
|
+
}
|
|
126
|
+
#homeByFolder(folder) {
|
|
127
|
+
const context = [...this.#folders].find(([, saved]) => saved === resolve(folder))?.[0];
|
|
128
|
+
return context === undefined ? undefined : this.#home(context);
|
|
129
|
+
}
|
|
130
|
+
// Where a frame target stands to the page: a frame inside it, a page it opened or a frame of one, or another page.
|
|
131
|
+
#startOf(targetId, home) {
|
|
132
|
+
let current = targetId;
|
|
133
|
+
let opened = false;
|
|
134
|
+
for (let step = 0; step < maxSteps && current !== undefined; step += 1) {
|
|
135
|
+
if (current === home.mainFrame())
|
|
136
|
+
return opened ? 'popup' : undefined;
|
|
137
|
+
const facts = this.#targets.get(current);
|
|
138
|
+
if (facts?.parent !== undefined)
|
|
139
|
+
current = facts.parent;
|
|
140
|
+
else {
|
|
141
|
+
opened = true;
|
|
142
|
+
current = facts?.opener;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return 'other';
|
|
146
|
+
}
|
|
147
|
+
// A popup page Retest opened is the test's when the page or a page it opened opened it.
|
|
148
|
+
#openedBy(popupFrame, home) {
|
|
149
|
+
return this.#startOf(popupFrame, home) === 'popup' ? 'popup' : 'other';
|
|
150
|
+
}
|
|
151
|
+
// A page that closes takes the downloads still on their way to it, and the facts of its context, with it.
|
|
152
|
+
#closed(page) {
|
|
153
|
+
this.#pages.delete(page);
|
|
154
|
+
for (const [guid, owner] of this.#owners)
|
|
155
|
+
if (owner === page)
|
|
156
|
+
this.#owners.delete(guid);
|
|
157
|
+
if (page.popup)
|
|
158
|
+
return;
|
|
159
|
+
this.#folders.delete(page.context);
|
|
160
|
+
for (const [targetId, facts] of this.#targets)
|
|
161
|
+
if (facts.context === page.context)
|
|
162
|
+
this.#targets.delete(targetId);
|
|
163
|
+
}
|
|
164
|
+
// An event Retest cannot read must not reach the connection, which would drop the browser's other events with it.
|
|
165
|
+
#guarded(work) {
|
|
166
|
+
try {
|
|
167
|
+
work();
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
this.#onListenerError(error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The downloads one page is told of: those begun in its main frame and in the frames inside it, which the page tells of
|
|
176
|
+
* as they attach and detach, and, for the test's own page of a context, those begun anywhere else in its context.
|
|
177
|
+
*/
|
|
178
|
+
export class PageDownloads {
|
|
179
|
+
mainFrame;
|
|
180
|
+
context;
|
|
181
|
+
popup;
|
|
182
|
+
#frames = new Set();
|
|
183
|
+
#listeners;
|
|
184
|
+
#tracked = new Map();
|
|
185
|
+
#stopListening;
|
|
186
|
+
#closed;
|
|
187
|
+
constructor({ session, mainFrame, browserContextId, popup, onListenerError, closed }) {
|
|
188
|
+
this.mainFrame = mainFrame;
|
|
189
|
+
this.context = browserContextId;
|
|
190
|
+
this.popup = popup;
|
|
191
|
+
this.#closed = closed;
|
|
192
|
+
this.#listeners = new Listeners(onListenerError);
|
|
193
|
+
const source = (method) => ({ method, sessionId: session.id });
|
|
194
|
+
this.#stopListening = [
|
|
195
|
+
session.on('Page.frameAttached', (params) => this.#frames.add(readProtocol(frameSchema, params, source('Page.frameAttached')).frameId)),
|
|
196
|
+
session.on('Page.frameDetached', (params) => this.#frames.delete(readProtocol(frameSchema, params, source('Page.frameDetached')).frameId)),
|
|
197
|
+
session.on('Page.frameNavigated', (params) => {
|
|
198
|
+
const { frame } = readProtocol(navigatedSchema, params, source('Page.frameNavigated'));
|
|
199
|
+
if (frame.parentId !== undefined)
|
|
200
|
+
this.#frames.add(frame.id);
|
|
201
|
+
}),
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
/** Whether the frame is this page's: its main frame, or a frame inside it the page told of. */
|
|
205
|
+
holds(frameId) {
|
|
206
|
+
return frameId === this.mainFrame() || this.#frames.has(frameId);
|
|
207
|
+
}
|
|
208
|
+
/** Tells `listener` of every download the page is told of from now on. Returns a function that removes it. */
|
|
209
|
+
onDownload(listener) {
|
|
210
|
+
return this.#listeners.add(listener);
|
|
211
|
+
}
|
|
212
|
+
began({ guid, url, suggestedFilename, startedIn }) {
|
|
213
|
+
const ended = Promise.withResolvers();
|
|
214
|
+
const tracked = {
|
|
215
|
+
received: 0,
|
|
216
|
+
total: 0,
|
|
217
|
+
end: ended.resolve,
|
|
218
|
+
download: {
|
|
219
|
+
guid,
|
|
220
|
+
url,
|
|
221
|
+
suggestedFilename,
|
|
222
|
+
...(startedIn === undefined ? {} : { startedIn }),
|
|
223
|
+
ended: ended.promise,
|
|
224
|
+
progress: () => ({ receivedBytes: tracked.received, totalBytes: tracked.total }),
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
this.#tracked.set(guid, tracked);
|
|
228
|
+
this.#listeners.emit(tracked.download);
|
|
229
|
+
}
|
|
230
|
+
progressed({ guid, receivedBytes, totalBytes, state, filePath }) {
|
|
231
|
+
const tracked = this.#tracked.get(guid);
|
|
232
|
+
if (tracked === undefined)
|
|
233
|
+
return;
|
|
234
|
+
tracked.received = receivedBytes;
|
|
235
|
+
tracked.total = totalBytes;
|
|
236
|
+
if (state === 'inProgress')
|
|
237
|
+
return;
|
|
238
|
+
this.#tracked.delete(guid);
|
|
239
|
+
// A download the browser says it completed, but whose file it does not name, is not one Retest can keep.
|
|
240
|
+
if (state === 'completed' && filePath !== undefined && isAbsolute(filePath))
|
|
241
|
+
tracked.end({ state: 'completed', filePath, receivedBytes, totalBytes });
|
|
242
|
+
else
|
|
243
|
+
tracked.end({ state: 'canceled', receivedBytes, totalBytes });
|
|
244
|
+
}
|
|
245
|
+
/** Stops routing downloads to this page and tells no listener of any more. A download on its way is left as it is. */
|
|
246
|
+
close() {
|
|
247
|
+
for (const stop of this.#stopListening)
|
|
248
|
+
stop();
|
|
249
|
+
this.#listeners.clear();
|
|
250
|
+
this.#tracked.clear();
|
|
251
|
+
this.#closed();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PendingNavigation } from './actionability.ts';
|
|
2
|
+
import type { CdpSession } from './cdp/session.ts';
|
|
3
|
+
import type { BrowserCommand } from './contract.ts';
|
|
4
|
+
import type { Dispatch } from './dispatch.ts';
|
|
5
|
+
import type { Point } from './input.ts';
|
|
6
|
+
import type { IsolatedWorld } from './isolated-world.ts';
|
|
7
|
+
import type { CommandResult } from '../protocol/commands.ts';
|
|
8
|
+
import type { Failure } from '../protocol/failures.ts';
|
|
9
|
+
import { Deadline } from '../protocol/deadline.ts';
|
|
10
|
+
/** A page as a drag needs it: its session, Retest's world in its main frame, its navigation and its input's cause. */
|
|
11
|
+
export type DragPage = {
|
|
12
|
+
readonly session: CdpSession;
|
|
13
|
+
readonly world: IsolatedWorld;
|
|
14
|
+
readonly touch: boolean;
|
|
15
|
+
readonly pendingNavigation: () => PendingNavigation | undefined;
|
|
16
|
+
/** Runs the work that delivers the drag's input, so a navigation it starts is the drag's. */
|
|
17
|
+
readonly delivering: <T>(work: () => Promise<T>) => Promise<T>;
|
|
18
|
+
};
|
|
19
|
+
type DragCommand = Extract<BrowserCommand, {
|
|
20
|
+
kind: 'dragTo';
|
|
21
|
+
}>;
|
|
22
|
+
/** Installs Retest's drag listener in each new document, before the page's own scripts run. */
|
|
23
|
+
export declare const dragListenerScript: string;
|
|
24
|
+
/**
|
|
25
|
+
* Drags the element `command.locator` finds onto the element `command.target` finds. Nothing is sent until both are
|
|
26
|
+
* ready: the source as for a click, visible, stable, enabled and not covered, and the target visible, stable, in view
|
|
27
|
+
* and not covered at its centre, both in view together. The press is guarded as a click's is; then the mouse moves in
|
|
28
|
+
* `steps` from the source's centre to the target's, and is released there. A press and a move that begin an HTML drag
|
|
29
|
+
* carry it on through drag events, and the drop ends it, with no release, as a person's drop ends one. A target gone
|
|
30
|
+
* by the time of the drop fails `not_actionable`, after the drag is cancelled, or the mouse released where the moves
|
|
31
|
+
* ended, and says so; so does an HTML drop another element covers or takes. Every other failure before the press sends
|
|
32
|
+
* nothing, and one after it leaves no drag held and no button down.
|
|
33
|
+
*
|
|
34
|
+
* @example const result = await dragTo(page, { kind: 'dragTo', locator: card, target: column }, deadline, dispatch)
|
|
35
|
+
*/
|
|
36
|
+
export declare function dragTo(page: DragPage, command: DragCommand, deadline: Deadline, dispatch: Dispatch): Promise<CommandResult>;
|
|
37
|
+
/**
|
|
38
|
+
* The position `fraction` of the way from `from` to `to`, on the straight line between them.
|
|
39
|
+
*
|
|
40
|
+
* @example between({ x: 0, y: 0 }, { x: 100, y: 50 }, 0.2) // { x: 20, y: 10 }
|
|
41
|
+
*/
|
|
42
|
+
export declare function between(from: Point, to: Point, fraction: number): Point;
|
|
43
|
+
/**
|
|
44
|
+
* The failure an engine without drag and drop gives `dragTo()`, naming the engine. Nothing is sent.
|
|
45
|
+
*
|
|
46
|
+
* @example dragRefused('Firefox').message // 'dragTo() is not available on Firefox yet. Retest drags on Chrome, Chromium, Edge and Electron.'
|
|
47
|
+
*/
|
|
48
|
+
export declare function dragRefused(engine: string): Failure;
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=drag.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drag.d.ts","sourceRoot":"","sources":["../../src/browser/drag.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACnD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAE7C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAEvC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACxD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAE5D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AAItD,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAuBlD,sHAAsH;AACtH,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;IAC5B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;IAC7B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,iBAAiB,GAAG,SAAS,CAAA;IAC/D,6FAA6F;IAC7F,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;CAC/D,CAAA;AAED,KAAK,WAAW,GAAG,OAAO,CAAC,cAAc,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,CAAA;AAsF9D,+FAA+F;AAC/F,eAAO,MAAM,kBAAkB,EAAE,MAE5B,CAAA;AAoEL;;;;;;;;;;;GAWG;AACH,wBAAsB,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC,CAcjI;AAsVD;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,GAAG,KAAK,CAEvE;AAqGD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD"}
|