@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.
Files changed (72) hide show
  1. package/dist/assert/expect.d.ts +5 -0
  2. package/dist/assert/expect.js +8 -3
  3. package/dist/assert/locator-assertions.d.ts +16 -1
  4. package/dist/assert/locator-assertions.js +34 -7
  5. package/dist/assert/page-assertions.d.ts +25 -0
  6. package/dist/assert/page-assertions.js +67 -0
  7. package/dist/assert/text-match.d.ts +2 -0
  8. package/dist/assert/text-match.js +7 -0
  9. package/dist/browser/page-navigation.d.ts +4 -0
  10. package/dist/browser/page-navigation.js +15 -5
  11. package/dist/browser/page.d.ts +2 -0
  12. package/dist/browser/page.js +4 -0
  13. package/dist/capabilities.d.ts +6 -0
  14. package/dist/capabilities.js +6 -0
  15. package/dist/flows/run/handlers/support.js +1 -1
  16. package/dist/flows/run/handlers/types.d.ts +1 -1
  17. package/dist/flows/run/node-pool.d.ts +1 -1
  18. package/dist/flows/run/node-pool.js +7 -2
  19. package/dist/flows/run/project-options.d.ts +5 -1
  20. package/dist/flows/run/project.js +8 -8
  21. package/dist/flows/run/run.d.ts +2 -0
  22. package/dist/flows/run/run.js +2 -1
  23. package/dist/locator/element-reads.d.ts +4 -0
  24. package/dist/locator/element-reads.js +11 -0
  25. package/dist/locator/locator.d.ts +8 -0
  26. package/dist/locator/locator.js +25 -0
  27. package/dist/recorder/expectation.d.ts +5 -0
  28. package/dist/recorder/expectation.js +18 -0
  29. package/dist/recorder/normalise.d.ts +7 -1
  30. package/dist/recorder/normalise.js +6 -0
  31. package/dist/recorder/options.d.ts +1 -1
  32. package/dist/recorder/recorder.js +3 -0
  33. package/dist/recorder/toolbar/action-guard.d.ts +2 -2
  34. package/dist/recorder/toolbar/action-guard.js +2 -1
  35. package/dist/recorder/toolbar/actions/assert-page.d.ts +6 -0
  36. package/dist/recorder/toolbar/actions/assert-page.js +33 -0
  37. package/dist/recorder/toolbar/actions/assert.js +17 -1
  38. package/dist/recorder/toolbar/actions/pick.d.ts +3 -1
  39. package/dist/recorder/toolbar/actions/pick.js +11 -3
  40. package/dist/recorder/toolbar/controller-fixture.d.ts +1 -0
  41. package/dist/recorder/toolbar/controller-fixture.js +6 -1
  42. package/dist/recorder/toolbar/controller.d.ts +4 -2
  43. package/dist/recorder/toolbar/controller.js +23 -2
  44. package/dist/recorder/toolbar/inspect.d.ts +2 -1
  45. package/dist/recorder/toolbar/inspect.js +21 -4
  46. package/dist/recorder/toolbar/protocol.d.ts +37 -3
  47. package/dist/recorder/toolbar/recorder-toolbar-fixture.d.ts +21 -0
  48. package/dist/recorder/toolbar/recorder-toolbar-fixture.js +40 -0
  49. package/dist/recorder/toolbar/source/fields.js +9 -0
  50. package/dist/recorder/toolbar/source/handlers.js +25 -1
  51. package/dist/recorder/toolbar/source/picking.js +8 -1
  52. package/dist/recorder/toolbar/source/recorder-side.js +10 -0
  53. package/dist/recorder/toolbar/source/setup.js +6 -1
  54. package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
  55. package/dist/recorder/toolbar/source/views-panels.js +39 -4
  56. package/dist/recorder/toolbar/source/views-rail.js +1 -0
  57. package/dist/recorder/toolbar/source.d.ts +6 -5
  58. package/dist/recorder/toolbar/source.js +6 -5
  59. package/dist/recorder/toolbar/types.d.ts +9 -2
  60. package/dist/recorder/toolbar-bridge.d.ts +2 -0
  61. package/dist/recorder/toolbar-bridge.js +6 -1
  62. package/dist/runner/worker-protocol.d.ts +2 -0
  63. package/dist/runner/worker-session.js +3 -3
  64. package/dist/runner/worker.d.ts +3 -1
  65. package/dist/runner/worker.js +1 -1
  66. package/dist/steps/emit.js +8 -1
  67. package/dist/steps/index.d.ts +1 -1
  68. package/dist/steps/model.d.ts +19 -1
  69. package/dist/steps/parse/expectation.d.ts +2 -1
  70. package/dist/steps/parse/expectation.js +25 -1
  71. package/dist/steps/parse/step.js +9 -2
  72. package/package.json +1 -1
@@ -1,2 +1,2 @@
1
- /** Views: the Assert, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
1
+ /** Views: the Assert, Page, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
2
2
  export declare const VIEWS_PANELS_JS: string;
@@ -1,8 +1,18 @@
1
- /** Views: the Assert, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
1
+ /** Views: the Assert, Page, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
2
2
  export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher) =>
3
3
  (matcher === "toHaveValue" && info.value === undefined) ||
4
4
  (matcher === "toHaveAttribute" && info.attributes.length === 0) ||
5
- (matcher === "toHaveCount" && info.counts.length === 0);
5
+ (matcher === "toHaveCount" && info.counts.length === 0) ||
6
+ (matcher === "toBeChecked" && !info.checkable) ||
7
+ (matcher === "toBeEnabled" && !info.disableable) ||
8
+ (matcher === "toHaveClass" && !info.className);
9
+ // The computed value, as toHaveCSS reads it: camelCase names become kebab-case, --custom is kept
10
+ const cssValueOf = (el, name) => {
11
+ const trimmed = name.trim();
12
+ if (!trimmed) return "";
13
+ const property = trimmed.startsWith("--") ? trimmed : trimmed.replace(/[A-Z]/g, (letter) => "-" + letter.toLowerCase());
14
+ try { return getComputedStyle(el).getPropertyValue(property); } catch (err) { return ""; }
15
+ };
6
16
  const prefill = (a, matcher) => {
7
17
  const info = a.info;
8
18
  if (matcher === "toHaveAttribute") {
@@ -13,6 +23,8 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
13
23
  const found = info.counts[a.countIndex];
14
24
  return found ? String(found.count) : "";
15
25
  }
26
+ if (matcher === "toHaveClass") return info.className || "";
27
+ if (matcher === "toHaveCSS") return a.cssValue;
16
28
  if (matcher === info.first.matcher && info.first.expected !== undefined) return info.first.expected;
17
29
  if (matcher === "toHaveText" || matcher === "toContainText") return info.text;
18
30
  if (matcher === "toHaveValue") return info.value || "";
@@ -21,7 +33,12 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
21
33
  const assertValue = (a) => {
22
34
  const info = a.info;
23
35
  const expected = (type) => h("input", { type, class: "in", "data-field": "expected", value: a.expected, "data-enter": "assert-add", autocomplete: "off" });
24
- if (a.matcher === "toHaveText" || a.matcher === "toContainText" || a.matcher === "toHaveValue") return h("div", { class: "val" }, expected("text"));
36
+ if (a.matcher === "toHaveText" || a.matcher === "toContainText" || a.matcher === "toHaveValue" || a.matcher === "toHaveClass") return h("div", { class: "val" }, expected("text"));
37
+ if (a.matcher === "toHaveCSS") {
38
+ return h("div", { class: "val" },
39
+ h("input", { type: "text", class: "in code", "data-field": "cssName", value: a.cssName, placeholder: "Property", "data-enter": "assert-add", autocomplete: "off", spellcheck: "false" }),
40
+ expected("text"));
41
+ }
25
42
  if (a.matcher === "toHaveAttribute") {
26
43
  return h("div", { class: "val" },
27
44
  h("select", { class: "in", "data-field": "attribute" }, info.attributes.map((at) => {
@@ -56,6 +73,24 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
56
73
  ];
57
74
  };
58
75
 
76
+ const pagePanel = () => {
77
+ const p = ui.page;
78
+ const head = h("div", { class: "h" }, "Page");
79
+ if (!p.info) return [head, h("div", { class: "msg" }, "…")];
80
+ if (!p.info.ok) return [head, h("div", { class: "err" }, p.info.error)];
81
+ return [
82
+ head,
83
+ h("div", { class: "opts" }, PAGE_MATCHERS.map(([matcher, label]) =>
84
+ h("button", { class: p.matcher === matcher ? "o sel" : "o", "data-action": "page-matcher", "data-matcher": matcher }, label))),
85
+ h("div", { class: "val" },
86
+ h("input", { type: "text", class: "in", "data-field": "pageExpected", value: p.expected, "data-enter": "page-add", autocomplete: "off", spellcheck: "false" })),
87
+ p.error && h("div", { class: "err" }, p.error),
88
+ h("div", { class: "row" },
89
+ h("label", { class: "neg" }, h("input", { type: "checkbox", "data-action": "page-not", checked: p.not }), "Not"),
90
+ h("button", { class: "add", "data-action": "page-add", disabled: ui.busy }, "Add")),
91
+ ];
92
+ };
93
+
59
94
  const matches = () => {
60
95
  const filter = ui.insert.filter.trim().toLowerCase();
61
96
  const hit = (item) => !filter || item.name.toLowerCase().indexOf(filter) >= 0;
@@ -146,5 +181,5 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
146
181
  radio("fullPage", "true", ui.shot.fullPage, "Whole page"),
147
182
  h("div", { class: "foot" }, h("button", { class: "add", "data-action": "screenshot-add", disabled: ui.busy }, "Add")),
148
183
  ];
149
- const PANELS = { assert: assertPanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
184
+ const PANELS = { assert: assertPanel, page: pagePanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
150
185
  `;
@@ -31,6 +31,7 @@ export const VIEWS_RAIL_JS = String.raw ` // ---- Views ----
31
31
  : h("button", { class: "b", "data-action": "pause" }, ico("❚❚"), "Pause"),
32
32
  tool("assert", "✓", "Assert"),
33
33
  ui.picking && h("div", { class: "note" }, "Click an element · Esc cancels"),
34
+ tool("page", "⌂", "Page"),
34
35
  tool("insert", "{x}", "Insert"),
35
36
  tool("wait", "⏱", "Wait"),
36
37
  tool("screenshot", "◉", "Screenshot"),
@@ -8,19 +8,20 @@
8
8
  *
9
9
  * `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
10
10
  * - `update(state)`: the recorder's state, after every change and every page load.
11
- * - `reply(id, result)`: the answer to `pick` (a `PickInfo`) or to `assert`, `insert` and `add-insertable`
12
- * (an `ActionReply`).
11
+ * - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
12
+ * `assert-page`, `insert` and `add-insertable` (an `ActionReply`).
13
13
  * - `root`: the shadow root, for tests.
14
14
  * - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
15
15
  * `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
16
- * `assert`, `insert`, `wait`, `screenshot` (open or close the tool);
16
+ * `assert`, `page`, `insert`, `wait`, `screenshot` (open or close the tool);
17
17
  * `pick { target }` (the element a picking click hit);
18
18
  * `matcher { matcher }`, `not`, `assert-add` (Assert panel);
19
+ * `page-matcher { matcher }`, `page-not`, `page-add` (Page panel);
19
20
  * `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
20
21
  * `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
21
22
  * `wait-add`, `screenshot-add`; `escape` (the Esc key); `dismiss { id }` (a warning's Dismiss button).
22
- * Form inputs carry `data-field` (`expected`, `attribute`, `countIndex`, `filter`, `name`, `value`,
23
- * `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
23
+ * Form inputs carry `data-field` (`expected`, `attribute`, `cssName`, `countIndex`, `pageExpected`, `filter`,
24
+ * `name`, `value`, `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
24
25
  * - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
25
26
  * swaps in that recording's toolbar channel.
26
27
  * - `state()`, `picking()`: the last state from `update` and whether the Assert picker is on (for tests).
@@ -23,19 +23,20 @@ import { ATTACH_JS } from "./source/attach.js";
23
23
  *
24
24
  * `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
25
25
  * - `update(state)`: the recorder's state, after every change and every page load.
26
- * - `reply(id, result)`: the answer to `pick` (a `PickInfo`) or to `assert`, `insert` and `add-insertable`
27
- * (an `ActionReply`).
26
+ * - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
27
+ * `assert-page`, `insert` and `add-insertable` (an `ActionReply`).
28
28
  * - `root`: the shadow root, for tests.
29
29
  * - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
30
30
  * `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
31
- * `assert`, `insert`, `wait`, `screenshot` (open or close the tool);
31
+ * `assert`, `page`, `insert`, `wait`, `screenshot` (open or close the tool);
32
32
  * `pick { target }` (the element a picking click hit);
33
33
  * `matcher { matcher }`, `not`, `assert-add` (Assert panel);
34
+ * `page-matcher { matcher }`, `page-not`, `page-add` (Page panel);
34
35
  * `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
35
36
  * `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
36
37
  * `wait-add`, `screenshot-add`; `escape` (the Esc key); `dismiss { id }` (a warning's Dismiss button).
37
- * Form inputs carry `data-field` (`expected`, `attribute`, `countIndex`, `filter`, `name`, `value`,
38
- * `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
38
+ * Form inputs carry `data-field` (`expected`, `attribute`, `cssName`, `countIndex`, `pageExpected`, `filter`,
39
+ * `name`, `value`, `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
39
40
  * - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
40
41
  * swaps in that recording's toolbar channel.
41
42
  * - `state()`, `picking()`: the last state from `update` and whether the Assert picker is on (for tests).
@@ -1,7 +1,7 @@
1
1
  import type { ElementHandle } from "../../script/element-handle.js";
2
2
  import type { LocatorCall, LocatorSpec } from "../../steps/model.js";
3
3
  import type { Observation } from "../normalise.js";
4
- import type { ActionReply, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
4
+ import type { ActionReply, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
5
5
  /** What `inspect` found for a picked element: the reply, and the locator behind each Count choice */
6
6
  export interface Inspection {
7
7
  info: PickInfo & {
@@ -14,8 +14,15 @@ export interface Inspection {
14
14
  export interface ControllerDeps {
15
15
  /** Sends the state to every loaded top-level document's toolbar */
16
16
  push(state: ToolbarState): void;
17
- reply(id: number, result: PickInfo | ActionReply): void;
17
+ reply(id: number, result: PickInfo | PageInfo | ActionReply): void;
18
18
  currentUrl(): Promise<string>;
19
+ /** The recorded page's URL and title */
20
+ pageInfo(): Promise<{
21
+ url: string;
22
+ title: string;
23
+ }>;
24
+ /** The recording's base URL: the Page panel writes URLs inside it relative to it */
25
+ readonly baseURL?: string;
19
26
  rank(target: ElementHandle, candidates: LocatorCall[]): Promise<LocatorSpec | undefined>;
20
27
  inspect(target: ElementHandle, locator: LocatorSpec, candidates: LocatorCall[]): Promise<Inspection>;
21
28
  /** Types into the field with capture muted */
@@ -10,6 +10,8 @@ export interface BridgeHost {
10
10
  page: Page;
11
11
  connector: BiDiConnector;
12
12
  context: string;
13
+ /** The recording's base URL: the Page panel writes URLs inside it relative to it */
14
+ baseURL?: string;
13
15
  mute(): Promise<void>;
14
16
  unmute(): Promise<void>;
15
17
  /** A step the toolbar made (wait, screenshot, insert, assertion) */
@@ -65,12 +65,17 @@ export class ToolbarBridge {
65
65
  }
66
66
  }
67
67
  createController(toolbar) {
68
- const { page, connector, context } = this.host;
68
+ const { page, connector, context, baseURL } = this.host;
69
69
  const { projectDir, environment, onStatus, onStop, onSecretAdded } = toolbar;
70
70
  return new ToolbarController({
71
71
  push: (state) => this.call("update", [state]),
72
72
  reply: (id, result) => this.call("reply", [id, result]),
73
73
  currentUrl: () => page.url(),
74
+ pageInfo: async () => {
75
+ const [url, title] = await Promise.all([page.url(), page.title()]);
76
+ return { url, title };
77
+ },
78
+ ...(baseURL !== undefined && { baseURL }),
74
79
  rank: (target, candidates) => rankLocator(page, target, candidates),
75
80
  inspect: (target, locator, candidates) => inspectElement(page, connector, context, target, locator, candidates),
76
81
  type: async (locator, value) => {
@@ -11,6 +11,8 @@ export interface WorkItem {
11
11
  browser: SupportedBrowser;
12
12
  /** Items with the same key never run at the same time */
13
13
  serialKey?: string;
14
+ /** Where this test's screenshots go, instead of the run's `outputDir` (a flow gives each node its own) */
15
+ outputDir?: string;
14
16
  }
15
17
  /** What a worker needs to start: the project and the settings that are the same for every test */
16
18
  export interface WorkerInit {
@@ -36,7 +36,7 @@ export function openSession(init) {
36
36
  });
37
37
  resolve({
38
38
  tests: loaded.all,
39
- async runTest(test, browser, signal, onEvent) {
39
+ async runTest(test, browser, signal, onEvent, perTest = {}) {
40
40
  const reporter = new TestReporter({
41
41
  environment: run.settings.environment,
42
42
  browsers: [browser],
@@ -48,8 +48,8 @@ export function openSession(init) {
48
48
  ...(init.reportPath !== undefined && {
49
49
  reportPath: init.reportPath,
50
50
  }),
51
- ...(init.outputDir !== undefined && {
52
- outputDir: init.outputDir,
51
+ ...((perTest.outputDir ?? init.outputDir) !== undefined && {
52
+ outputDir: perTest.outputDir ?? init.outputDir,
53
53
  }),
54
54
  port,
55
55
  signal,
@@ -6,7 +6,9 @@ import type { FromWorker, ToWorker, WorkerInit } from "./worker-protocol.js";
6
6
  export interface WorkerSession {
7
7
  tests: readonly RegisteredTestCase[];
8
8
  /** Runs one test; `onEvent` gets the reporter's events for it. Aborting `signal` ends the test as aborted */
9
- runTest(test: RegisteredTestCase, browser: SupportedBrowser, signal: AbortSignal, onEvent: (event: RunEvent) => void): Promise<void>;
9
+ runTest(test: RegisteredTestCase, browser: SupportedBrowser, signal: AbortSignal, onEvent: (event: RunEvent) => void, options?: {
10
+ outputDir?: string;
11
+ }): Promise<void>;
10
12
  /** Ends the project's session */
11
13
  close(): Promise<void>;
12
14
  }
@@ -54,7 +54,7 @@ export function handleMessages(channel, { open, exit }) {
54
54
  id,
55
55
  result: { ...event.result, worker: workerIndex },
56
56
  });
57
- });
57
+ }, item.outputDir !== undefined ? { outputDir: item.outputDir } : {});
58
58
  }
59
59
  finally {
60
60
  current = undefined;
@@ -61,9 +61,14 @@ function matcherToSource(expected) {
61
61
  function expectationToSource(expectation) {
62
62
  switch (expectation.matcher) {
63
63
  case "toBeVisible":
64
- return "toBeVisible()";
64
+ case "toBeHidden":
65
+ case "toBeEnabled":
66
+ case "toBeChecked":
67
+ return `${expectation.matcher}()`;
65
68
  case "toHaveAttribute":
66
69
  return `toHaveAttribute(${str(expectation.name)}, ${matcherToSource(expectation.expected)})`;
70
+ case "toHaveCSS":
71
+ return `toHaveCSS(${str(expectation.name)}, ${matcherToSource(expectation.expected)})`;
67
72
  case "toHaveCount":
68
73
  return `toHaveCount(${expectation.expected})`;
69
74
  default:
@@ -83,6 +88,8 @@ export function stepToSource(step) {
83
88
  return `await ${locatorToSource(step.locator)}.press(${str(step.key)});`;
84
89
  case "expect":
85
90
  return `await expect(${locatorToSource(step.locator)})${step.not ? ".not" : ""}.${expectationToSource(step.expectation)};`;
91
+ case "expectPage":
92
+ return `await expect(page)${step.not ? ".not" : ""}.${expectationToSource(step.expectation)};`;
86
93
  case "waitForNetworkIdle":
87
94
  return "await page.waitForNetworkIdle();";
88
95
  case "waitForTimeout":
@@ -3,4 +3,4 @@ export { applyEdit, type StepEdit } from "./edit.js";
3
3
  export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
4
4
  export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
5
5
  export { locatorFromSpec } from "./build.js";
6
- export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, UnparsedTest, } from "./model.js";
6
+ export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, PageExpectation, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, UnparsedTest, } from "./model.js";
@@ -48,7 +48,14 @@ export type TextMatcher = string | {
48
48
  };
49
49
  };
50
50
  export type Expectation = {
51
- matcher: "toBeVisible";
51
+ matcher: "toBeVisible" | "toBeHidden" | "toBeEnabled" | "toBeChecked";
52
+ } | {
53
+ matcher: "toHaveClass";
54
+ expected: TextMatcher;
55
+ } | {
56
+ matcher: "toHaveCSS";
57
+ name: string;
58
+ expected: TextMatcher;
52
59
  } | {
53
60
  matcher: "toHaveText" | "toContainText" | "toHaveValue";
54
61
  expected: TextMatcher;
@@ -60,6 +67,11 @@ export type Expectation = {
60
67
  matcher: "toHaveCount";
61
68
  expected: number;
62
69
  };
70
+ /** What an assertion expects of the page itself: its URL or title */
71
+ export interface PageExpectation {
72
+ matcher: "toHaveURL" | "toHaveTitle";
73
+ expected: TextMatcher;
74
+ }
63
75
  /** A file generated when the step runs: seeded random bytes of `size`, never stored in the project */
64
76
  export interface GeneratedFileSpec {
65
77
  name: string;
@@ -93,6 +105,12 @@ export type Step = {
93
105
  not: boolean;
94
106
  expectation: Expectation;
95
107
  }
108
+ /** An assertion on the page, `expect(page).toHaveURL(…)` */
109
+ | {
110
+ kind: "expectPage";
111
+ not: boolean;
112
+ expectation: PageExpectation;
113
+ }
96
114
  /** A key pressed while the element has focus, e.g. `Enter` to submit a field. Named as Playwright names keys */
97
115
  | {
98
116
  kind: "press";
@@ -1,5 +1,6 @@
1
1
  import ts from "typescript";
2
- import type { Expectation, TextValue } from "../model.js";
2
+ import type { Expectation, PageExpectation, TextValue } from "../model.js";
3
3
  /** `env.NAME`, `env["NAME"]`, `secrets.NAME`, `secrets["NAME"]` or a string literal */
4
4
  export declare function readValue(node: ts.Expression | undefined): TextValue | undefined;
5
5
  export declare function readExpectation(matcher: string, args: readonly ts.Expression[]): Expectation | undefined;
6
+ export declare function readPageExpectation(matcher: string, args: readonly ts.Expression[]): PageExpectation | undefined;
@@ -46,8 +46,24 @@ function readTextMatcher(node) {
46
46
  return undefined;
47
47
  }
48
48
  export function readExpectation(matcher, args) {
49
- if (matcher === "toBeVisible")
49
+ if (matcher === "toBeVisible" ||
50
+ matcher === "toBeHidden" ||
51
+ matcher === "toBeEnabled" ||
52
+ matcher === "toBeChecked")
50
53
  return args.length === 0 ? { matcher } : undefined;
54
+ if (matcher === "toHaveClass") {
55
+ const expected = readTextMatcher(args[0]);
56
+ return args.length === 1 && expected !== undefined
57
+ ? { matcher, expected }
58
+ : undefined;
59
+ }
60
+ if (matcher === "toHaveCSS") {
61
+ const name = stringOf(args[0]);
62
+ const expected = readTextMatcher(args[1]);
63
+ return args.length === 2 && name !== undefined && expected !== undefined
64
+ ? { matcher, name, expected }
65
+ : undefined;
66
+ }
51
67
  if (matcher === "toHaveCount") {
52
68
  const [count] = args;
53
69
  return args.length === 1 && count && ts.isNumericLiteral(count)
@@ -69,3 +85,11 @@ export function readExpectation(matcher, args) {
69
85
  }
70
86
  return undefined;
71
87
  }
88
+ export function readPageExpectation(matcher, args) {
89
+ if (matcher !== "toHaveURL" && matcher !== "toHaveTitle")
90
+ return undefined;
91
+ const expected = readTextMatcher(args[0]);
92
+ return args.length === 1 && expected !== undefined
93
+ ? { matcher, expected }
94
+ : undefined;
95
+ }
@@ -1,6 +1,6 @@
1
1
  import ts from "typescript";
2
2
  import { readLocator } from "./call.js";
3
- import { readExpectation, readValue } from "./expectation.js";
3
+ import { readExpectation, readPageExpectation, readValue, } from "./expectation.js";
4
4
  import { readFiles } from "./file-entry.js";
5
5
  import { readObject, stringOf } from "./literals.js";
6
6
  /** The step a statement stands for, or undefined when it is outside the recorder's set */
@@ -51,7 +51,14 @@ export function readStep(statement) {
51
51
  ts.isIdentifier(subject.expression) &&
52
52
  subject.expression.text === "expect" &&
53
53
  subject.arguments.length === 1) {
54
- const locator = readLocator(subject.arguments[0]);
54
+ const arg = subject.arguments[0];
55
+ if (ts.isIdentifier(arg) && arg.text === "page") {
56
+ const expectation = readPageExpectation(name.text, args);
57
+ return expectation
58
+ ? { kind: "expectPage", not: negated, expectation }
59
+ : undefined;
60
+ }
61
+ const locator = readLocator(arg);
55
62
  const expectation = readExpectation(name.text, args);
56
63
  return locator && expectation
57
64
  ? { kind: "expect", locator, not: negated, expectation }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {