@itmetsam/samurai-framework 0.17.0 → 0.19.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/dist/assert/expect.d.ts +5 -0
- package/dist/assert/expect.js +8 -3
- package/dist/assert/locator-assertions.d.ts +16 -1
- package/dist/assert/locator-assertions.js +34 -7
- package/dist/assert/page-assertions.d.ts +25 -0
- package/dist/assert/page-assertions.js +67 -0
- package/dist/assert/text-match.d.ts +2 -0
- package/dist/assert/text-match.js +7 -0
- package/dist/browser/page-navigation.d.ts +4 -0
- package/dist/browser/page-navigation.js +15 -5
- package/dist/browser/page.d.ts +2 -0
- package/dist/browser/page.js +4 -0
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.js +6 -0
- package/dist/flows/run/handlers/support.js +1 -1
- package/dist/flows/run/handlers/types.d.ts +1 -1
- package/dist/flows/run/node-pool.d.ts +1 -1
- package/dist/flows/run/node-pool.js +7 -2
- package/dist/flows/run/project-options.d.ts +5 -1
- package/dist/flows/run/project.js +8 -8
- package/dist/flows/run/run.d.ts +2 -0
- package/dist/flows/run/run.js +2 -1
- package/dist/locator/element-reads.d.ts +4 -0
- package/dist/locator/element-reads.js +11 -0
- package/dist/locator/locator.d.ts +8 -0
- package/dist/locator/locator.js +25 -0
- package/dist/recorder/expectation.d.ts +5 -0
- package/dist/recorder/expectation.js +18 -0
- package/dist/recorder/normalise.d.ts +7 -1
- package/dist/recorder/normalise.js +6 -0
- package/dist/recorder/options.d.ts +1 -1
- package/dist/recorder/recorder.js +3 -0
- package/dist/recorder/toolbar/action-guard.d.ts +2 -2
- package/dist/recorder/toolbar/action-guard.js +2 -1
- package/dist/recorder/toolbar/actions/assert-page.d.ts +6 -0
- package/dist/recorder/toolbar/actions/assert-page.js +33 -0
- package/dist/recorder/toolbar/actions/assert.js +17 -1
- package/dist/recorder/toolbar/actions/pick.d.ts +3 -1
- package/dist/recorder/toolbar/actions/pick.js +11 -3
- package/dist/recorder/toolbar/controller-fixture.d.ts +1 -0
- package/dist/recorder/toolbar/controller-fixture.js +6 -1
- package/dist/recorder/toolbar/controller.d.ts +4 -2
- package/dist/recorder/toolbar/controller.js +23 -2
- package/dist/recorder/toolbar/inspect.d.ts +2 -1
- package/dist/recorder/toolbar/inspect.js +21 -4
- package/dist/recorder/toolbar/protocol.d.ts +37 -3
- package/dist/recorder/toolbar/recorder-toolbar-fixture.d.ts +21 -0
- package/dist/recorder/toolbar/recorder-toolbar-fixture.js +40 -0
- package/dist/recorder/toolbar/source/fields.js +9 -0
- package/dist/recorder/toolbar/source/handlers.js +25 -1
- package/dist/recorder/toolbar/source/picking.js +8 -1
- package/dist/recorder/toolbar/source/recorder-side.js +10 -0
- package/dist/recorder/toolbar/source/setup.js +6 -1
- package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
- package/dist/recorder/toolbar/source/views-panels.js +39 -4
- package/dist/recorder/toolbar/source/views-rail.js +1 -0
- package/dist/recorder/toolbar/source.d.ts +6 -5
- package/dist/recorder/toolbar/source.js +6 -5
- package/dist/recorder/toolbar/types.d.ts +9 -2
- package/dist/recorder/toolbar-bridge.d.ts +2 -0
- package/dist/recorder/toolbar-bridge.js +6 -1
- package/dist/runner/worker-protocol.d.ts +2 -0
- package/dist/runner/worker-session.js +3 -3
- package/dist/runner/worker.d.ts +3 -1
- package/dist/runner/worker.js +1 -1
- package/dist/steps/emit.js +8 -1
- package/dist/steps/index.d.ts +1 -1
- package/dist/steps/model.d.ts +19 -1
- package/dist/steps/parse/expectation.d.ts +2 -1
- package/dist/steps/parse/expectation.js +25 -1
- package/dist/steps/parse/step.js +9 -2
- package/package.json +1 -1
package/dist/assert/expect.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import Page from "../browser/page.js";
|
|
1
2
|
import Locator from "../locator/locator.js";
|
|
2
3
|
import { LocatorAssertions } from "./locator-assertions.js";
|
|
4
|
+
import { PageAssertions } from "./page-assertions.js";
|
|
3
5
|
import { ValueAssertions } from "./value-assertions.js";
|
|
4
6
|
export { takePendingAssertions } from "./pending-assertions.js";
|
|
5
7
|
export { LocatorAssertions } from "./locator-assertions.js";
|
|
8
|
+
export { PageAssertions } from "./page-assertions.js";
|
|
6
9
|
export { ValueAssertions } from "./value-assertions.js";
|
|
7
10
|
/** Auto-retrying assertions on a page element */
|
|
8
11
|
export declare function expect(locator: Locator): LocatorAssertions;
|
|
12
|
+
/** Auto-retrying assertions on the page URL and title */
|
|
13
|
+
export declare function expect(page: Page): PageAssertions;
|
|
9
14
|
/** Synchronous assertions on a plain value */
|
|
10
15
|
export declare function expect<T>(value: T): ValueAssertions<T>;
|
package/dist/assert/expect.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import Page from "../browser/page.js";
|
|
1
2
|
import Locator from "../locator/locator.js";
|
|
2
3
|
import { LocatorAssertions } from "./locator-assertions.js";
|
|
4
|
+
import { PageAssertions } from "./page-assertions.js";
|
|
3
5
|
import { ValueAssertions } from "./value-assertions.js";
|
|
4
6
|
export { takePendingAssertions } from "./pending-assertions.js";
|
|
5
7
|
export { LocatorAssertions } from "./locator-assertions.js";
|
|
8
|
+
export { PageAssertions } from "./page-assertions.js";
|
|
6
9
|
export { ValueAssertions } from "./value-assertions.js";
|
|
7
10
|
export function expect(value) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
if (value instanceof Locator)
|
|
12
|
+
return new LocatorAssertions(value);
|
|
13
|
+
if (value instanceof Page)
|
|
14
|
+
return new PageAssertions(value);
|
|
15
|
+
return new ValueAssertions(value);
|
|
11
16
|
}
|
|
@@ -9,8 +9,13 @@ export declare class LocatorAssertions {
|
|
|
9
9
|
constructor(locator: Locator, negated?: boolean);
|
|
10
10
|
/** Inverts the next matcher: it retries until the condition is false */
|
|
11
11
|
get not(): LocatorAssertions;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Re-reads the element until `check` passes or the timeout expires. With `mustExist`, a read of `null`
|
|
14
|
+
* (no element) never passes, with or without `.not`, and fails as "could not read"
|
|
15
|
+
*/
|
|
13
16
|
private assertEventually;
|
|
17
|
+
/** For matchers that need an element: `read` answers null when there is none, which never passes */
|
|
18
|
+
private assertExisting;
|
|
14
19
|
toBeVisible(options?: LocatorAssertionOptions): Promise<void>;
|
|
15
20
|
/** Trimmed text content equals `expected` (string) or matches it (RegExp) */
|
|
16
21
|
toHaveText(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
|
|
@@ -19,4 +24,14 @@ export declare class LocatorAssertions {
|
|
|
19
24
|
toHaveValue(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
|
|
20
25
|
toHaveAttribute(name: string, expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
|
|
21
26
|
toHaveCount(expected: number, options?: LocatorAssertionOptions): Promise<void>;
|
|
27
|
+
/** The element is missing, or has no box or is hidden by CSS (the opposite of `toBeVisible`) */
|
|
28
|
+
toBeHidden(options?: LocatorAssertionOptions): Promise<void>;
|
|
29
|
+
/** The element exists and is neither `:disabled` nor `aria-disabled="true"`; a missing element never passes, not even with `.not` */
|
|
30
|
+
toBeEnabled(options?: LocatorAssertionOptions): Promise<void>;
|
|
31
|
+
/** A checked checkbox or radio input, or an element with `aria-checked="true"`; a missing element never passes, not even with `.not` */
|
|
32
|
+
toBeChecked(options?: LocatorAssertionOptions): Promise<void>;
|
|
33
|
+
/** The class attribute equals `expected` (string, whitespace collapsed and trimmed on both sides) or matches it (RegExp, tested on the attribute as written) */
|
|
34
|
+
toHaveClass(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
|
|
35
|
+
/** The computed value of a CSS property equals `expected` (string) or matches it (RegExp); `name` may be camelCase (`backgroundColor`) or kebab-case, `--custom` is kept */
|
|
36
|
+
toHaveCSS(name: string, expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
|
|
22
37
|
}
|
|
@@ -2,7 +2,7 @@ import { resolveTimeout } from "../config/timeouts.js";
|
|
|
2
2
|
import { waitUntil, WaitTimeoutError } from "../wait/wait-until.js";
|
|
3
3
|
import { AssertionError } from "./assertion-error.js";
|
|
4
4
|
import { track } from "./pending-assertions.js";
|
|
5
|
-
import { textCheck } from "./text-match.js";
|
|
5
|
+
import { classCheck, matchesText, textCheck } from "./text-match.js";
|
|
6
6
|
export class LocatorAssertions {
|
|
7
7
|
constructor(locator, negated = false) {
|
|
8
8
|
this.locator = locator;
|
|
@@ -12,21 +12,24 @@ export class LocatorAssertions {
|
|
|
12
12
|
get not() {
|
|
13
13
|
return new LocatorAssertions(this.locator, !this.negated);
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Re-reads the element until `check` passes or the timeout expires. With `mustExist`, a read of `null`
|
|
17
|
+
* (no element) never passes, with or without `.not`, and fails as "could not read"
|
|
18
|
+
*/
|
|
19
|
+
assertEventually(matcher, expected, read, check, options, mustExist = false) {
|
|
17
20
|
const name = this.negated ? `not.${matcher}` : matcher;
|
|
18
21
|
return track(name, (async () => {
|
|
19
22
|
const timeout = await resolveTimeout(options?.timeout);
|
|
20
23
|
try {
|
|
21
|
-
await waitUntil(read, (actual) =>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
await waitUntil(read, (actual) => mustExist && actual === null
|
|
25
|
+
? false
|
|
26
|
+
: check(actual) !== this.negated, { timeout });
|
|
24
27
|
}
|
|
25
28
|
catch (err) {
|
|
26
29
|
if (!(err instanceof WaitTimeoutError))
|
|
27
30
|
throw err;
|
|
28
31
|
// Reads never resolve to undefined, so an undefined `last` means no read completed
|
|
29
|
-
if (err.last === undefined) {
|
|
32
|
+
if (err.last === undefined || (mustExist && err.last === null)) {
|
|
30
33
|
throw new Error(`expect(locator).${name}: could not read ${this.locator.selector} within ${timeout}ms`);
|
|
31
34
|
}
|
|
32
35
|
throw new AssertionError({
|
|
@@ -38,6 +41,10 @@ export class LocatorAssertions {
|
|
|
38
41
|
}
|
|
39
42
|
})());
|
|
40
43
|
}
|
|
44
|
+
/** For matchers that need an element: `read` answers null when there is none, which never passes */
|
|
45
|
+
assertExisting(matcher, expected, read, check, options) {
|
|
46
|
+
return this.assertEventually(matcher, expected, read, (actual) => actual !== null && check(actual), options, true);
|
|
47
|
+
}
|
|
41
48
|
toBeVisible(options) {
|
|
42
49
|
return this.assertEventually("toBeVisible", true, () => this.locator.isVisible(), (visible) => visible, options);
|
|
43
50
|
}
|
|
@@ -58,4 +65,24 @@ export class LocatorAssertions {
|
|
|
58
65
|
toHaveCount(expected, options) {
|
|
59
66
|
return this.assertEventually("toHaveCount", expected, () => this.locator.count(), (count) => count === expected, options);
|
|
60
67
|
}
|
|
68
|
+
/** The element is missing, or has no box or is hidden by CSS (the opposite of `toBeVisible`) */
|
|
69
|
+
toBeHidden(options) {
|
|
70
|
+
return this.assertEventually("toBeHidden", true, () => this.locator.isVisible(), (visible) => !visible, options);
|
|
71
|
+
}
|
|
72
|
+
/** The element exists and is neither `:disabled` nor `aria-disabled="true"`; a missing element never passes, not even with `.not` */
|
|
73
|
+
toBeEnabled(options) {
|
|
74
|
+
return this.assertExisting("toBeEnabled", true, () => this.locator.enabledState(), (enabled) => enabled, options);
|
|
75
|
+
}
|
|
76
|
+
/** A checked checkbox or radio input, or an element with `aria-checked="true"`; a missing element never passes, not even with `.not` */
|
|
77
|
+
toBeChecked(options) {
|
|
78
|
+
return this.assertExisting("toBeChecked", true, () => this.locator.isChecked(), (checked) => checked, options);
|
|
79
|
+
}
|
|
80
|
+
/** The class attribute equals `expected` (string, whitespace collapsed and trimmed on both sides) or matches it (RegExp, tested on the attribute as written) */
|
|
81
|
+
toHaveClass(expected, options) {
|
|
82
|
+
return this.assertExisting("toHaveClass", expected, () => this.locator.classAttribute(), classCheck(expected), options);
|
|
83
|
+
}
|
|
84
|
+
/** The computed value of a CSS property equals `expected` (string) or matches it (RegExp); `name` may be camelCase (`backgroundColor`) or kebab-case, `--custom` is kept */
|
|
85
|
+
toHaveCSS(name, expected, options) {
|
|
86
|
+
return this.assertExisting("toHaveCSS", expected, () => this.locator.cssValue(name), (actual) => matchesText(actual, expected, "exact"), options);
|
|
87
|
+
}
|
|
61
88
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type Page from "../browser/page.js";
|
|
2
|
+
export interface PageAssertionOptions {
|
|
3
|
+
/** Time (ms) to keep retrying. Defaults to config `expect.timeout`, then 5000 */
|
|
4
|
+
timeout?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class PageAssertions {
|
|
7
|
+
private readonly page;
|
|
8
|
+
private readonly negated;
|
|
9
|
+
constructor(page: Page, negated?: boolean);
|
|
10
|
+
/** Inverts the next matcher: it retries until the condition is false */
|
|
11
|
+
get not(): PageAssertions;
|
|
12
|
+
/**
|
|
13
|
+
* Re-reads the page until `check` passes or the timeout expires. A read that throws on an open page
|
|
14
|
+
* (a navigation in progress) counts as "no value yet" and never passes, with or without `.not`;
|
|
15
|
+
* a closed page fails at once with the page's error
|
|
16
|
+
*/
|
|
17
|
+
private assertEventually;
|
|
18
|
+
/**
|
|
19
|
+
* The page URL equals `expected` (string) or matches it (RegExp, tested on the full URL). A string starting
|
|
20
|
+
* with "/", "./" or "../" resolves against the base URL like `page.goto`; any other string must equal the URL
|
|
21
|
+
*/
|
|
22
|
+
toHaveURL(expected: string | RegExp, options?: PageAssertionOptions): Promise<void>;
|
|
23
|
+
/** The trimmed page title equals `expected` (string, trimmed too) or matches it (RegExp) */
|
|
24
|
+
toHaveTitle(expected: string | RegExp, options?: PageAssertionOptions): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolveTimeout } from "../config/timeouts.js";
|
|
2
|
+
import { waitUntil, WaitTimeoutError } from "../wait/wait-until.js";
|
|
3
|
+
import { AssertionError } from "./assertion-error.js";
|
|
4
|
+
import { track } from "./pending-assertions.js";
|
|
5
|
+
import { matchesText } from "./text-match.js";
|
|
6
|
+
export class PageAssertions {
|
|
7
|
+
constructor(page, negated = false) {
|
|
8
|
+
this.page = page;
|
|
9
|
+
this.negated = negated;
|
|
10
|
+
}
|
|
11
|
+
/** Inverts the next matcher: it retries until the condition is false */
|
|
12
|
+
get not() {
|
|
13
|
+
return new PageAssertions(this.page, !this.negated);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Re-reads the page until `check` passes or the timeout expires. A read that throws on an open page
|
|
17
|
+
* (a navigation in progress) counts as "no value yet" and never passes, with or without `.not`;
|
|
18
|
+
* a closed page fails at once with the page's error
|
|
19
|
+
*/
|
|
20
|
+
assertEventually(matcher, what, expectedOf, read, check, options) {
|
|
21
|
+
const name = this.negated ? `not.${matcher}` : matcher;
|
|
22
|
+
return track(name, (async () => {
|
|
23
|
+
// Resolved inside the tracked body: a throw (no base URL) rejects the tracked promise
|
|
24
|
+
const expected = expectedOf();
|
|
25
|
+
const timeout = await resolveTimeout(options?.timeout);
|
|
26
|
+
const safeRead = async () => {
|
|
27
|
+
try {
|
|
28
|
+
return await read();
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (this.page.closed)
|
|
32
|
+
throw err;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
await waitUntil(safeRead, (actual) => actual !== null && check(actual, expected) !== this.negated, { timeout });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (!(err instanceof WaitTimeoutError))
|
|
41
|
+
throw err;
|
|
42
|
+
if (err.last === undefined || err.last === null) {
|
|
43
|
+
throw new Error(`expect(page).${name}: could not read the page ${what} within ${timeout}ms`);
|
|
44
|
+
}
|
|
45
|
+
throw new AssertionError({
|
|
46
|
+
matcher: name,
|
|
47
|
+
expected,
|
|
48
|
+
actual: err.last,
|
|
49
|
+
locator: "page",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
})());
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The page URL equals `expected` (string) or matches it (RegExp, tested on the full URL). A string starting
|
|
56
|
+
* with "/", "./" or "../" resolves against the base URL like `page.goto`; any other string must equal the URL
|
|
57
|
+
*/
|
|
58
|
+
toHaveURL(expected, options) {
|
|
59
|
+
return this.assertEventually("toHaveURL", "URL", () => typeof expected === "string"
|
|
60
|
+
? this.page.resolveURL(expected, "toHaveURL")
|
|
61
|
+
: expected, () => this.page.url(), (url, target) => matchesText(url, target, "exact"), options);
|
|
62
|
+
}
|
|
63
|
+
/** The trimmed page title equals `expected` (string, trimmed too) or matches it (RegExp) */
|
|
64
|
+
toHaveTitle(expected, options) {
|
|
65
|
+
return this.assertEventually("toHaveTitle", "title", () => (typeof expected === "string" ? expected.trim() : expected), async () => (await this.page.title()).trim(), (title, target) => matchesText(title, target, "exact"), options);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -2,3 +2,5 @@
|
|
|
2
2
|
export declare function matchesText(actual: string, expected: string | RegExp, mode: "exact" | "contains"): boolean;
|
|
3
3
|
/** A check for a read that may find nothing (`null`): true when the text is there and matches */
|
|
4
4
|
export declare function textCheck(expected: string | RegExp, mode: "exact" | "contains"): (actual: string | null) => boolean;
|
|
5
|
+
/** A check for a class attribute: a string equals it after collapsing whitespace and trimming both sides, a RegExp tests it as written */
|
|
6
|
+
export declare function classCheck(expected: string | RegExp): (actual: string) => boolean;
|
|
@@ -9,3 +9,10 @@ export function matchesText(actual, expected, mode) {
|
|
|
9
9
|
export function textCheck(expected, mode) {
|
|
10
10
|
return (actual) => actual !== null && matchesText(actual, expected, mode);
|
|
11
11
|
}
|
|
12
|
+
/** A check for a class attribute: a string equals it after collapsing whitespace and trimming both sides, a RegExp tests it as written */
|
|
13
|
+
export function classCheck(expected) {
|
|
14
|
+
if (expected instanceof RegExp)
|
|
15
|
+
return (actual) => matchesText(actual, expected, "exact");
|
|
16
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
17
|
+
return (actual) => normalize(actual) === normalize(expected);
|
|
18
|
+
}
|
|
@@ -28,6 +28,8 @@ export interface NavigationDeps {
|
|
|
28
28
|
timeout: number;
|
|
29
29
|
}): Promise<void>;
|
|
30
30
|
}
|
|
31
|
+
/** Resolves a slash-led `url` against `base`; any other URL is returned as given */
|
|
32
|
+
export declare function resolveRelativeURL(url: string, base: string): string;
|
|
31
33
|
/** Navigating one page: `goto`, `navigateTo`, `reload` and the wait for a load state */
|
|
32
34
|
export declare class PageNavigation {
|
|
33
35
|
private connector;
|
|
@@ -40,6 +42,8 @@ export declare class PageNavigation {
|
|
|
40
42
|
constructor(connector: BiDiConnector, contextId: string, deps: NavigationDeps);
|
|
41
43
|
/** Sets the base that relative `goto` URLs resolve against; `undefined` falls back to the run's baseURL */
|
|
42
44
|
setBaseURL(url: string | undefined): void;
|
|
45
|
+
/** The full URL `url` stands for: slash-led URLs resolve against the base URL, others stay as given */
|
|
46
|
+
resolveURL(url: string, method?: string): string;
|
|
43
47
|
goto(url: string, options?: NavigateOptions): Promise<NavigationResult>;
|
|
44
48
|
navigateTo(url: string, options?: NavigateOptions | ReadinessState, protocol?: "http" | "https"): Promise<NavigationResult>;
|
|
45
49
|
reload(options?: {
|
|
@@ -3,6 +3,12 @@ import { BiDiError } from "../transport/bidi-error.js";
|
|
|
3
3
|
import { UnsupportedOperationError } from "../transport/unsupported.js";
|
|
4
4
|
import { WaitTimeoutError } from "../wait/wait-until.js";
|
|
5
5
|
import { LoadStateTimeoutError, NavigationError } from "./navigation-error.js";
|
|
6
|
+
/** A URL starting with "/", "./" or "../" */
|
|
7
|
+
const RELATIVE_URL = /^\.{0,2}\//;
|
|
8
|
+
/** Resolves a slash-led `url` against `base`; any other URL is returned as given */
|
|
9
|
+
export function resolveRelativeURL(url, base) {
|
|
10
|
+
return RELATIVE_URL.test(url) ? new URL(url, base).href : url;
|
|
11
|
+
}
|
|
6
12
|
/** Navigating one page: `goto`, `navigateTo`, `reload` and the wait for a load state */
|
|
7
13
|
export class PageNavigation {
|
|
8
14
|
constructor(connector, contextId, deps) {
|
|
@@ -14,14 +20,18 @@ export class PageNavigation {
|
|
|
14
20
|
setBaseURL(url) {
|
|
15
21
|
this.baseURL = url;
|
|
16
22
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
/** The full URL `url` stands for: slash-led URLs resolve against the base URL, others stay as given */
|
|
24
|
+
resolveURL(url, method = "goto") {
|
|
25
|
+
if (!RELATIVE_URL.test(url))
|
|
26
|
+
return url;
|
|
20
27
|
const base = this.baseURL ?? peekRunSettings()?.baseURL;
|
|
21
28
|
if (!base) {
|
|
22
|
-
throw new Error(
|
|
29
|
+
throw new Error(`${method}(): "${url}" needs a baseURL; set one in samurai.config.ts or the environment`);
|
|
23
30
|
}
|
|
24
|
-
return
|
|
31
|
+
return resolveRelativeURL(url, base);
|
|
32
|
+
}
|
|
33
|
+
async goto(url, options) {
|
|
34
|
+
return this.navigateTo(this.resolveURL(url), options);
|
|
25
35
|
}
|
|
26
36
|
async navigateTo(url, options, protocol) {
|
|
27
37
|
const given = typeof options === "string" ? { wait: options } : (options ?? {});
|
package/dist/browser/page.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export default class Page {
|
|
|
83
83
|
dispose(): Promise<void>;
|
|
84
84
|
/** Sets the base that relative `goto` URLs resolve against; `undefined` falls back to the run's baseURL */
|
|
85
85
|
setBaseURL(url: string | undefined): void;
|
|
86
|
+
/** @internal The full URL `goto` would navigate to: a URL starting with "/", "./" or "../" resolves against the base URL, others stay as given. `method` names the caller in the error */
|
|
87
|
+
resolveURL(url: string, method?: string): string;
|
|
86
88
|
/**
|
|
87
89
|
* Navigates like `navigateTo`, but a URL starting with "/", "./" or "../" resolves against the base URL
|
|
88
90
|
* (`setBaseURL`, else the environment's `baseURL`).
|
package/dist/browser/page.js
CHANGED
|
@@ -162,6 +162,10 @@ export default class Page {
|
|
|
162
162
|
setBaseURL(url) {
|
|
163
163
|
this.nav.setBaseURL(url);
|
|
164
164
|
}
|
|
165
|
+
/** @internal The full URL `goto` would navigate to: a URL starting with "/", "./" or "../" resolves against the base URL, others stay as given. `method` names the caller in the error */
|
|
166
|
+
resolveURL(url, method) {
|
|
167
|
+
return this.nav.resolveURL(url, method);
|
|
168
|
+
}
|
|
165
169
|
/**
|
|
166
170
|
* Navigates like `navigateTo`, but a URL starting with "/", "./" or "../" resolves against the base URL
|
|
167
171
|
* (`setBaseURL`, else the environment's `baseURL`).
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ export declare const CAPABILITIES: {
|
|
|
31
31
|
readonly recorderStartStatus: true;
|
|
32
32
|
/** `outputDir` on `runTests` and `runFlowInProject`: a folder per run for the report and the screenshots. */
|
|
33
33
|
readonly outputDir: true;
|
|
34
|
+
/** `runFlowInProject`'s `outputDir` gives each node its own folder, `nodes/<node id>/`. */
|
|
35
|
+
readonly flowNodeFolders: true;
|
|
36
|
+
/** Assertion matchers version 2: `toBeHidden`, `toBeEnabled`, `toBeChecked`, `toHaveClass` and `toHaveCSS`, in the framework and the step codec. */
|
|
37
|
+
readonly assertionMatchers: 2;
|
|
38
|
+
/** `expect(page).toHaveURL` and `toHaveTitle`, and the `expectPage` step in the step codec. */
|
|
39
|
+
readonly pageAssertions: true;
|
|
34
40
|
};
|
|
35
41
|
/** The shape of {@link CAPABILITIES}. */
|
|
36
42
|
export type Capabilities = typeof CAPABILITIES;
|
package/dist/capabilities.js
CHANGED
|
@@ -31,4 +31,10 @@ export const CAPABILITIES = {
|
|
|
31
31
|
recorderStartStatus: true,
|
|
32
32
|
/** `outputDir` on `runTests` and `runFlowInProject`: a folder per run for the report and the screenshots. */
|
|
33
33
|
outputDir: true,
|
|
34
|
+
/** `runFlowInProject`'s `outputDir` gives each node its own folder, `nodes/<node id>/`. */
|
|
35
|
+
flowNodeFolders: true,
|
|
36
|
+
/** Assertion matchers version 2: `toBeHidden`, `toBeEnabled`, `toBeChecked`, `toHaveClass` and `toHaveCSS`, in the framework and the step codec. */
|
|
37
|
+
assertionMatchers: 2,
|
|
38
|
+
/** `expect(page).toHaveURL` and `toHaveTitle`, and the `expectPage` step in the step codec. */
|
|
39
|
+
pageAssertions: true,
|
|
34
40
|
};
|
|
@@ -7,7 +7,7 @@ export const fail = (e) => ({
|
|
|
7
7
|
export async function runNodeTests(node, run, list) {
|
|
8
8
|
let result;
|
|
9
9
|
try {
|
|
10
|
-
result = await run.runTests(list, (e) => run.onTestEvent(node.id, e), run.nodeBrowsers?.[node.id]);
|
|
10
|
+
result = await run.runTests(node.id, list, (e) => run.onTestEvent(node.id, e), run.nodeBrowsers?.[node.id]);
|
|
11
11
|
}
|
|
12
12
|
catch (e) {
|
|
13
13
|
return fail(e);
|
|
@@ -14,7 +14,7 @@ export interface HandlerRun {
|
|
|
14
14
|
/** The browsers of the nodes that have their own (a Group node's group); the others use the project's */
|
|
15
15
|
nodeBrowsers?: Record<string, SupportedBrowser[]>;
|
|
16
16
|
signal: AbortSignal;
|
|
17
|
-
runTests: (tests: NodeTest[], onEvent: (e: RunEvent) => void, browsers?: SupportedBrowser[]) => Promise<"passed" | "failed" | "cancelled">;
|
|
17
|
+
runTests: (nodeId: string, tests: NodeTest[], onEvent: (e: RunEvent) => void, browsers?: SupportedBrowser[]) => Promise<"passed" | "failed" | "cancelled">;
|
|
18
18
|
sleep: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
19
19
|
onTestEvent: (nodeId: string, e: RunEvent) => void;
|
|
20
20
|
/** Definitions of kinds core doesn't have (steps), for expression scopes */
|
|
@@ -4,6 +4,6 @@ import type { WorkItem } from "../../runner/worker-protocol.js";
|
|
|
4
4
|
import type { SupportedBrowser } from "../../types/browser.js";
|
|
5
5
|
import type { NodeTest } from "./events.js";
|
|
6
6
|
/** The work items of a node: each test in each browser, the browsers one after the other */
|
|
7
|
-
export declare function itemsOf(tests: NodeTest[], browsers: readonly SupportedBrowser[]): WorkItem[];
|
|
7
|
+
export declare function itemsOf(tests: NodeTest[], browsers: readonly SupportedBrowser[], outputDir?: string): WorkItem[];
|
|
8
8
|
/** Runs the tests of a node on the pool and reports them as a run would */
|
|
9
9
|
export declare function runOnPool(pool: Pick<WorkerPool, "run">, items: WorkItem[], onEvent: (event: RunEvent) => void, signal: AbortSignal): Promise<"passed" | "failed" | "cancelled">;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/** The work items of a node: each test in each browser, the browsers one after the other */
|
|
2
|
-
export function itemsOf(tests, browsers) {
|
|
2
|
+
export function itemsOf(tests, browsers, outputDir) {
|
|
3
3
|
return browsers
|
|
4
|
-
.flatMap((browser) => tests.map((t) => ({
|
|
4
|
+
.flatMap((browser) => tests.map((t) => ({
|
|
5
|
+
file: t.file,
|
|
6
|
+
testName: t.name,
|
|
7
|
+
browser,
|
|
8
|
+
...(outputDir !== undefined && { outputDir }),
|
|
9
|
+
})))
|
|
5
10
|
.map((item, index) => ({ ...item, index }));
|
|
6
11
|
}
|
|
7
12
|
/** Runs the tests of a node on the pool and reports them as a run would */
|
|
@@ -20,7 +20,11 @@ export interface RunFlowInProjectOptions extends ProjectOptions {
|
|
|
20
20
|
/** `"fail"` (default): a flow with a node that can't run is refused. `"skip"`: those nodes are skipped. */
|
|
21
21
|
unsupported?: "fail" | "skip";
|
|
22
22
|
port?: number;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The folder for the tests' named screenshots, instead of `<project>/result` (see `runTests`). Each node writes to
|
|
25
|
+
* `nodes/<node id>/` inside it, so a test that runs in two nodes keeps both sets; screenshot paths in a node's
|
|
26
|
+
* results are relative to that folder. Relative to the project folder
|
|
27
|
+
*/
|
|
24
28
|
outputDir?: string;
|
|
25
29
|
/**
|
|
26
30
|
* How many worker processes the tests of the whole run share: a number, or a percentage like `"50%"`. Unset
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { cleanName } from "../../browser/evidence.js";
|
|
2
3
|
import { DEFAULT_SRC_DIR } from "../../config/defaults.js";
|
|
3
4
|
import { projectConfig } from "../../config/config.js";
|
|
4
5
|
import { checkFlow, withKeys, } from "../core/index.js";
|
|
@@ -176,9 +177,6 @@ async function walkFlowInProject(options) {
|
|
|
176
177
|
...(options.headless !== undefined && {
|
|
177
178
|
headless: options.headless,
|
|
178
179
|
}),
|
|
179
|
-
...(options.outputDir !== undefined && {
|
|
180
|
-
outputDir: options.outputDir,
|
|
181
|
-
}),
|
|
182
180
|
files,
|
|
183
181
|
basePort: options.port ?? DEFAULT_PORT,
|
|
184
182
|
...(retries > 0 && { retries }),
|
|
@@ -197,9 +195,13 @@ async function walkFlowInProject(options) {
|
|
|
197
195
|
unsupported,
|
|
198
196
|
handlers: { ...HANDLERS, ...steps.handlers },
|
|
199
197
|
nodes: steps.nodes,
|
|
200
|
-
runTests: async ({ tests, onEvent, signal, browsers }) => {
|
|
198
|
+
runTests: async ({ nodeId, tests, onEvent, signal, browsers }) => {
|
|
199
|
+
// Each node gets a folder of its own: the same test in two nodes would overwrite its screenshots
|
|
200
|
+
const outputDir = options.outputDir !== undefined
|
|
201
|
+
? path.join(options.outputDir, "nodes", cleanName(nodeId))
|
|
202
|
+
: undefined;
|
|
201
203
|
if (pool)
|
|
202
|
-
return runOnPool(pool, itemsOf(tests, browsers ?? browsersFor({ project: config.browsers })), onEvent, signal);
|
|
204
|
+
return runOnPool(pool, itemsOf(tests, browsers ?? browsersFor({ project: config.browsers }), outputDir), onEvent, signal);
|
|
203
205
|
const summary = await run({
|
|
204
206
|
projectDir,
|
|
205
207
|
config,
|
|
@@ -217,9 +219,7 @@ async function walkFlowInProject(options) {
|
|
|
217
219
|
files: [...new Set(tests.map((t) => t.file))],
|
|
218
220
|
testNames: tests.map((t) => t.name),
|
|
219
221
|
reportPath: false,
|
|
220
|
-
...(
|
|
221
|
-
outputDir: options.outputDir,
|
|
222
|
-
}),
|
|
222
|
+
...(outputDir !== undefined && { outputDir }),
|
|
223
223
|
signal,
|
|
224
224
|
onEvent,
|
|
225
225
|
});
|
package/dist/flows/run/run.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface RunFlowOptions {
|
|
|
20
20
|
signal?: AbortSignal;
|
|
21
21
|
onEvent?: (event: FlowEvent) => void;
|
|
22
22
|
runTests: (input: {
|
|
23
|
+
/** The node the tests are for: a node runs at most once in a run */
|
|
24
|
+
nodeId: string;
|
|
23
25
|
tests: NodeTest[];
|
|
24
26
|
onEvent: (e: RunEvent) => void;
|
|
25
27
|
signal: AbortSignal;
|
package/dist/flows/run/run.js
CHANGED
|
@@ -34,7 +34,8 @@ export async function runFlow(options) {
|
|
|
34
34
|
...(options.nodeBrowsers && { nodeBrowsers: options.nodeBrowsers }),
|
|
35
35
|
signal,
|
|
36
36
|
sleep: options.sleep ?? defaultSleep,
|
|
37
|
-
runTests: (tests, onTestEvent, browsers) => options.runTests({
|
|
37
|
+
runTests: (nodeId, tests, onTestEvent, browsers) => options.runTests({
|
|
38
|
+
nodeId,
|
|
38
39
|
tests,
|
|
39
40
|
onEvent: onTestEvent,
|
|
40
41
|
signal,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Body run in the page as `(el) => { ... }`: a checked checkbox or radio input, or `aria-checked="true"` */
|
|
2
|
+
export declare const IS_CHECKED_BODY = "\n const type = (el.getAttribute(\"type\") || \"\").toLowerCase();\n const input = el.tagName === \"INPUT\" && (type === \"checkbox\" || type === \"radio\");\n return (input && el.checked) || el.getAttribute(\"aria-checked\") === \"true\";";
|
|
3
|
+
/** The CSS property name for `getPropertyValue`: camelCase becomes kebab-case (`backgroundColor` to `background-color`), `--custom` and kebab-case names are kept */
|
|
4
|
+
export declare function cssPropertyName(name: string): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Body run in the page as `(el) => { ... }`: a checked checkbox or radio input, or `aria-checked="true"` */
|
|
2
|
+
export const IS_CHECKED_BODY = `
|
|
3
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
4
|
+
const input = el.tagName === "INPUT" && (type === "checkbox" || type === "radio");
|
|
5
|
+
return (input && el.checked) || el.getAttribute("aria-checked") === "true";`;
|
|
6
|
+
/** The CSS property name for `getPropertyValue`: camelCase becomes kebab-case (`backgroundColor` to `background-color`), `--custom` and kebab-case names are kept */
|
|
7
|
+
export function cssPropertyName(name) {
|
|
8
|
+
if (name.startsWith("--"))
|
|
9
|
+
return name;
|
|
10
|
+
return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
11
|
+
}
|
|
@@ -148,6 +148,14 @@ export default class Locator {
|
|
|
148
148
|
count(): Promise<number>;
|
|
149
149
|
/** Whether the element exists and is not disabled; does not wait */
|
|
150
150
|
isEnabled(): Promise<boolean>;
|
|
151
|
+
/** Like `isEnabled`, but null when the element is missing, so callers can tell "disabled" from "not there"; does not wait */
|
|
152
|
+
enabledState(): Promise<boolean | null>;
|
|
153
|
+
/** Whether a checkbox or radio input is checked or the element has `aria-checked="true"`; null when the element is missing */
|
|
154
|
+
isChecked(): Promise<boolean | null>;
|
|
155
|
+
/** The class attribute as written ("" when the element has none), or null when the element is missing */
|
|
156
|
+
classAttribute(): Promise<string | null>;
|
|
157
|
+
/** Computed value of a CSS property (camelCase names become kebab-case, `--custom` is kept), or null when the element is missing */
|
|
158
|
+
cssValue(name: string): Promise<string | null>;
|
|
151
159
|
/** Whether the element exists and accepts typing (enabled, not readonly, a text field); does not wait */
|
|
152
160
|
isEditable(): Promise<boolean>;
|
|
153
161
|
/** Wait for certain time (ms). Default: 300ms */
|
package/dist/locator/locator.js
CHANGED
|
@@ -6,6 +6,7 @@ import { probeState, waitForActionable, waitForState, } from "./actionability.js
|
|
|
6
6
|
import { ChainResolver } from "./chain-resolver.js";
|
|
7
7
|
import { dropFiles, setInputFiles, } from "./file-actions.js";
|
|
8
8
|
import { describeType, } from "./element-state.js";
|
|
9
|
+
import { cssPropertyName, IS_CHECKED_BODY } from "./element-reads.js";
|
|
9
10
|
import { clickRect, DEFAULT_PAUSE_MS, NAMED_KEYS, pause, pressKey, typeText, } from "./input-actions.js";
|
|
10
11
|
import { CLICK_CHECKS, FILL_CHECKS, PRESS_CHECKS, SCREENSHOT_CHECKS, ATTACHED_ONLY, } from "./page-scripts.js";
|
|
11
12
|
import { cssSelector, describeChain, describeSelector, labelSelector, roleSelector, testIdSelector, textSelector, xpathSelector, } from "./selector.js";
|
|
@@ -302,6 +303,23 @@ export default class Locator {
|
|
|
302
303
|
const state = await this.probeState({ scroll: false, hitTest: false });
|
|
303
304
|
return state.attached && state.enabled;
|
|
304
305
|
}
|
|
306
|
+
/** Like `isEnabled`, but null when the element is missing, so callers can tell "disabled" from "not there"; does not wait */
|
|
307
|
+
async enabledState() {
|
|
308
|
+
const state = await this.probeState({ scroll: false, hitTest: false });
|
|
309
|
+
return state.attached ? state.enabled : null;
|
|
310
|
+
}
|
|
311
|
+
/** Whether a checkbox or radio input is checked or the element has `aria-checked="true"`; null when the element is missing */
|
|
312
|
+
async isChecked() {
|
|
313
|
+
return booleanOrNull(await this.callOnElement(null, IS_CHECKED_BODY));
|
|
314
|
+
}
|
|
315
|
+
/** The class attribute as written ("" when the element has none), or null when the element is missing */
|
|
316
|
+
async classAttribute() {
|
|
317
|
+
return stringOrNull(await this.callOnElement(null, `return el.getAttribute("class") ?? "";`));
|
|
318
|
+
}
|
|
319
|
+
/** Computed value of a CSS property (camelCase names become kebab-case, `--custom` is kept), or null when the element is missing */
|
|
320
|
+
async cssValue(name) {
|
|
321
|
+
return stringOrNull(await this.callOnElement(null, `return getComputedStyle(el).getPropertyValue(name);`, ["name"], [cssPropertyName(name)]));
|
|
322
|
+
}
|
|
305
323
|
/** Whether the element exists and accepts typing (enabled, not readonly, a text field); does not wait */
|
|
306
324
|
async isEditable() {
|
|
307
325
|
const state = await this.probeState({ scroll: false, hitTest: false });
|
|
@@ -339,6 +357,13 @@ export default class Locator {
|
|
|
339
357
|
await typeText(this.biDiConnector, this.contextId, keys);
|
|
340
358
|
}
|
|
341
359
|
}
|
|
360
|
+
function booleanOrNull(value) {
|
|
361
|
+
if (typeof value === "boolean")
|
|
362
|
+
return value;
|
|
363
|
+
if (value === null)
|
|
364
|
+
return null;
|
|
365
|
+
throw new Error(`Expected a boolean or null but received "${describeType(value)}"`);
|
|
366
|
+
}
|
|
342
367
|
function stringOrNull(value) {
|
|
343
368
|
if (typeof value === "string")
|
|
344
369
|
return value;
|
|
@@ -3,3 +3,8 @@ import type { Expectation } from "../steps/model.js";
|
|
|
3
3
|
export declare function secretNameFor(hint: string | undefined): string;
|
|
4
4
|
/** The assertion an Alt+click or a pick starts with: the value of a field, else the text (cut when long), else visible */
|
|
5
5
|
export declare function expectationFor(text: string, value: string | undefined): Expectation;
|
|
6
|
+
/**
|
|
7
|
+
* The URL a `toHaveURL` step is written with: its path, query and hash when that resolves against `base` back to
|
|
8
|
+
* exactly `url` (as `toHaveURL` resolves it), else the full URL
|
|
9
|
+
*/
|
|
10
|
+
export declare function urlForAssertion(url: string, base: string | undefined): string;
|
|
@@ -18,3 +18,21 @@ export function expectationFor(text, value) {
|
|
|
18
18
|
? { matcher: "toHaveText", expected: text }
|
|
19
19
|
: { matcher: "toContainText", expected: text.slice(0, TEXT_LIMIT).trim() };
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The URL a `toHaveURL` step is written with: its path, query and hash when that resolves against `base` back to
|
|
23
|
+
* exactly `url` (as `toHaveURL` resolves it), else the full URL
|
|
24
|
+
*/
|
|
25
|
+
export function urlForAssertion(url, base) {
|
|
26
|
+
if (base === undefined)
|
|
27
|
+
return url;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = new URL(url);
|
|
30
|
+
if (parsed.origin !== new URL(base).origin)
|
|
31
|
+
return url;
|
|
32
|
+
const candidate = parsed.pathname + parsed.search + parsed.hash;
|
|
33
|
+
return new URL(candidate, base).href === parsed.href ? candidate : url;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return url;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Expectation, FileEntry, LocatorSpec, Step } from "../steps/model.js";
|
|
1
|
+
import type { Expectation, FileEntry, LocatorSpec, PageExpectation, Step } from "../steps/model.js";
|
|
2
2
|
/** What the recorder does to the step list: a new step at `index`, a step rewritten in place, or one taken out */
|
|
3
3
|
export type RecorderEvent = {
|
|
4
4
|
op: "insert";
|
|
@@ -41,6 +41,12 @@ export type Observation = {
|
|
|
41
41
|
locator: LocatorSpec;
|
|
42
42
|
not: boolean;
|
|
43
43
|
expectation: Expectation;
|
|
44
|
+
}
|
|
45
|
+
/** The toolbar's Page panel: the page's URL or title */
|
|
46
|
+
| {
|
|
47
|
+
type: "expectPage";
|
|
48
|
+
not: boolean;
|
|
49
|
+
expectation: PageExpectation;
|
|
44
50
|
} | {
|
|
45
51
|
type: "insert";
|
|
46
52
|
locator: LocatorSpec;
|