@augment-vir/test 31.49.0 → 31.50.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.
@@ -0,0 +1,42 @@
1
+ import { RuntimeEnvError } from '@augment-vir/core';
2
+ export { type MenuOptionOptions } from '../test-playwright/get-option.js';
3
+ export { playwrightTeatNameUrlParam, type NavPath } from '../test-playwright/nav.js';
4
+ export { type LocatorScreenshotOptions } from '../test-playwright/screenshot.js';
5
+ declare function importPlaywrightTestApi(this: void): Promise<RuntimeEnvError | {
6
+ /** Navigate to a URL in Playwright via given paths. */
7
+ nav: typeof import("../test-playwright/nav.js").nav;
8
+ /**
9
+ * Expects that all matches for the given locator are either visible or hidden (controlled
10
+ * by `isVisible`).
11
+ */
12
+ expectAllVisible: typeof import("../test-playwright/all-visible.js").expectAllVisible;
13
+ /**
14
+ * Similar to Playwright's `expect().toHaveScreenshot` but allows images to have different
15
+ * sizes and has default comparison threshold options that are wide enough to allow testing
16
+ * between different operating systems without failure (usually).
17
+ */
18
+ expectScreenshot: typeof import("../test-playwright/screenshot.js").expectScreenshot;
19
+ /** Clicks a label to select its input and then types the given text. */
20
+ enterTextByLabel: typeof import("../test-playwright/enter-text").enterTextByLabel;
21
+ /** Find the matching (or first) element with the "option" role. */
22
+ getMenuOption: typeof import("../test-playwright/get-option.js").getMenuOption;
23
+ /** Checks if a locator contains the given class. */
24
+ checkHasClass: typeof import("../test-playwright/has-class").checkHasClass;
25
+ /**
26
+ * Run the trigger and catch a new page _or_ a new download (sometimes Playwright
27
+ * inconsistently chooses on or the other).
28
+ */
29
+ handleNewPageOrDownload: typeof import("../test-playwright/new-page-or-download").handleNewPageOrDownload;
30
+ /** Read from a page's local storage (using `page.evaluate`). */
31
+ readLocalStorage: typeof import("../test-playwright/local-storage.js").readLocalStorage;
32
+ }>;
33
+ /**
34
+ * A suite of Playwright test helpers. This is only accessible within a Playwright test runtime. If
35
+ * accessed outside of a Playwright runtime, it'll be an Error instead of a collection of test
36
+ * helpers.
37
+ *
38
+ * @category Test
39
+ * @category Package : @augment-vir/test
40
+ * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
41
+ */
42
+ export declare const testPlaywright: Exclude<Awaited<ReturnType<typeof importPlaywrightTestApi>>, Error>;
@@ -0,0 +1,53 @@
1
+ import { isInsidePlaywrightTest, RuntimeEnvError } from '@augment-vir/core';
2
+ export { playwrightTeatNameUrlParam } from '../test-playwright/nav.js';
3
+ async function importPlaywrightTestApi() {
4
+ if (!isInsidePlaywrightTest()) {
5
+ return new RuntimeEnvError("The 'testPlaywright' api cannot be used outside of a Playwright test context.");
6
+ }
7
+ const { checkHasClass } = await import('../test-playwright/has-class');
8
+ const { enterTextByLabel } = await import('../test-playwright/enter-text');
9
+ const { expectAllVisible } = await import('../test-playwright/all-visible.js');
10
+ const { expectScreenshot } = await import('../test-playwright/screenshot.js');
11
+ const { getMenuOption } = await import('../test-playwright/get-option');
12
+ const { handleNewPageOrDownload } = await import('../test-playwright/new-page-or-download');
13
+ const { nav } = await import('../test-playwright/nav.js');
14
+ const { readLocalStorage } = await import('../test-playwright/local-storage.js');
15
+ return {
16
+ /** Navigate to a URL in Playwright via given paths. */
17
+ nav,
18
+ /**
19
+ * Expects that all matches for the given locator are either visible or hidden (controlled
20
+ * by `isVisible`).
21
+ */
22
+ expectAllVisible,
23
+ /**
24
+ * Similar to Playwright's `expect().toHaveScreenshot` but allows images to have different
25
+ * sizes and has default comparison threshold options that are wide enough to allow testing
26
+ * between different operating systems without failure (usually).
27
+ */
28
+ expectScreenshot,
29
+ /** Clicks a label to select its input and then types the given text. */
30
+ enterTextByLabel,
31
+ /** Find the matching (or first) element with the "option" role. */
32
+ getMenuOption,
33
+ /** Checks if a locator contains the given class. */
34
+ checkHasClass,
35
+ /**
36
+ * Run the trigger and catch a new page _or_ a new download (sometimes Playwright
37
+ * inconsistently chooses on or the other).
38
+ */
39
+ handleNewPageOrDownload,
40
+ /** Read from a page's local storage (using `page.evaluate`). */
41
+ readLocalStorage,
42
+ };
43
+ }
44
+ /**
45
+ * A suite of Playwright test helpers. This is only accessible within a Playwright test runtime. If
46
+ * accessed outside of a Playwright runtime, it'll be an Error instead of a collection of test
47
+ * helpers.
48
+ *
49
+ * @category Test
50
+ * @category Package : @augment-vir/test
51
+ * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
52
+ */
53
+ export const testPlaywright = (await importPlaywrightTestApi());
@@ -1,8 +1,22 @@
1
- import { isRuntimeEnv, RuntimeEnv } from '@augment-vir/core';
1
+ import { isInsidePlaywrightTest, isRuntimeEnv, RuntimeEnv } from '@augment-vir/core';
2
2
  const describes = isRuntimeEnv(RuntimeEnv.Node)
3
- ? {
4
- node: (await import('node:test')).describe,
5
- }
3
+ ? isInsidePlaywrightTest()
4
+ ? {
5
+ playwright: await (async () => {
6
+ const playwrightImport = await import('@playwright/test');
7
+ /** `as any` cast to prevent type guarding {@link playwrightImport}. */
8
+ if ('default' in playwrightImport) {
9
+ return playwrightImport.default.describe;
10
+ }
11
+ else {
12
+ return playwrightImport
13
+ .describe;
14
+ }
15
+ })(),
16
+ }
17
+ : {
18
+ node: (await import('node:test')).describe,
19
+ }
6
20
  : {
7
21
  mocha: globalThis.describe,
8
22
  };
@@ -33,4 +47,4 @@ const describes = isRuntimeEnv(RuntimeEnv.Node)
33
47
  *
34
48
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
35
49
  */
36
- export const describe = describes.mocha || describes.node;
50
+ export const describe = describes.mocha || describes.playwright || describes.node;
@@ -1,4 +1,5 @@
1
- import { isRuntimeEnv, RuntimeEnv } from '@augment-vir/core';
1
+ import { randomString } from '@augment-vir/common';
2
+ import { isInsidePlaywrightTest, isRuntimeEnv, RuntimeEnv } from '@augment-vir/core';
2
3
  function createWebIt() {
3
4
  const webIt = Object.assign((doesThis, callback) => {
4
5
  return globalThis.it(doesThis, async function () {
@@ -21,6 +22,101 @@ function createWebIt() {
21
22
  });
22
23
  return webIt;
23
24
  }
25
+ async function createPlaywrightIt() {
26
+ const rawPlaywrightImport = await import('@playwright/test');
27
+ const originalPlaywrightIt = 'default' in rawPlaywrightImport
28
+ ? rawPlaywrightImport.default
29
+ : rawPlaywrightImport;
30
+ const playwrightIt = Object.assign((doesThis, callback) => {
31
+ return originalPlaywrightIt(doesThis, async ({ page, baseURL, browser, context, extraHTTPHeaders, viewport, video, userAgent, timezoneId, serviceWorkers, screenshot, isMobile, headless, hasTouch, }, testInfo) => {
32
+ const playwrightTestContext = {
33
+ page,
34
+ baseURL,
35
+ browser,
36
+ context,
37
+ extraHTTPHeaders,
38
+ viewport,
39
+ video,
40
+ userAgent,
41
+ timezoneId,
42
+ serviceWorkers,
43
+ screenshot,
44
+ isMobile,
45
+ headless,
46
+ hasTouch,
47
+ testInfo,
48
+ testName: {
49
+ clean: testInfo.titlePath.join(' > '),
50
+ unique: [
51
+ ...testInfo.titlePath,
52
+ randomString(),
53
+ ].join(' > '),
54
+ },
55
+ };
56
+ await callback(playwrightTestContext);
57
+ });
58
+ }, {
59
+ skip: (doesThis, callback) => {
60
+ return originalPlaywrightIt.skip(doesThis, async ({ page, baseURL, browser, context, extraHTTPHeaders, viewport, video, userAgent, timezoneId, serviceWorkers, screenshot, isMobile, headless, hasTouch, }, testInfo) => {
61
+ const playwrightTestContext = {
62
+ page,
63
+ baseURL,
64
+ browser,
65
+ context,
66
+ extraHTTPHeaders,
67
+ viewport,
68
+ video,
69
+ userAgent,
70
+ timezoneId,
71
+ serviceWorkers,
72
+ screenshot,
73
+ isMobile,
74
+ headless,
75
+ hasTouch,
76
+ testInfo,
77
+ testName: {
78
+ clean: testInfo.titlePath.join(' > '),
79
+ unique: [
80
+ ...testInfo.titlePath,
81
+ randomString(),
82
+ ].join(' > '),
83
+ },
84
+ };
85
+ await callback(playwrightTestContext);
86
+ });
87
+ },
88
+ only: (doesThis, callback) => {
89
+ return originalPlaywrightIt.only(doesThis, async ({ page, baseURL, browser, context, extraHTTPHeaders, viewport, video, userAgent, timezoneId, serviceWorkers, screenshot, isMobile, headless, hasTouch, }, testInfo) => {
90
+ const playwrightTestContext = {
91
+ page,
92
+ baseURL,
93
+ browser,
94
+ context,
95
+ extraHTTPHeaders,
96
+ viewport,
97
+ video,
98
+ userAgent,
99
+ timezoneId,
100
+ serviceWorkers,
101
+ screenshot,
102
+ isMobile,
103
+ headless,
104
+ hasTouch,
105
+ testInfo,
106
+ testName: {
107
+ clean: testInfo.titlePath.join(' > '),
108
+ unique: [
109
+ ...testInfo.titlePath,
110
+ randomString(),
111
+ ].join(' > '),
112
+ },
113
+ };
114
+ await callback(playwrightTestContext);
115
+ });
116
+ },
117
+ });
118
+ return playwrightIt;
119
+ }
24
120
  /**
25
121
  * A single test declaration. This can be used in both web tests _and_ node tests, so you only have
26
122
  * import from a single place and learn a single interface.
@@ -50,5 +146,7 @@ function createWebIt() {
50
146
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
51
147
  */
52
148
  export const it = isRuntimeEnv(RuntimeEnv.Node)
53
- ? (await import('node:test')).it
149
+ ? isInsidePlaywrightTest()
150
+ ? await createPlaywrightIt()
151
+ : (await import('node:test')).it
54
152
  : createWebIt();
@@ -1,6 +1,6 @@
1
1
  import { check } from '@augment-vir/assert';
2
- import { extractErrorMessage, getOrSet, RuntimeEnv } from '@augment-vir/common';
3
- import { extractTestName, isTestContext, } from './universal-test-context.js';
2
+ import { extractErrorMessage, getOrSet } from '@augment-vir/common';
3
+ import { determineTestContextEnv, extractTestName, isTestContext, TestEnv, } from './universal-test-context.js';
4
4
  /**
5
5
  * An error that is thrown from {@link assertSnapshot} when the snapshot comparison fails due to the
6
6
  * snapshot expectation file simply not existing.
@@ -27,9 +27,9 @@ export class SnapshotFileMissingError extends Error {
27
27
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
28
28
  */
29
29
  export async function assertSnapshot(testContext, data) {
30
- const { snapshotName, testName } = getTestName(testContext);
31
30
  const serializedData = check.isString(data) ? data : JSON.stringify(data);
32
- if (isTestContext(testContext, RuntimeEnv.Node)) {
31
+ if (isTestContext(testContext, TestEnv.Node)) {
32
+ const { testName } = getTestName(testContext);
33
33
  try {
34
34
  testContext.assert.snapshot(serializedData);
35
35
  }
@@ -42,7 +42,8 @@ export async function assertSnapshot(testContext, data) {
42
42
  }
43
43
  }
44
44
  }
45
- else {
45
+ else if (isTestContext(testContext, TestEnv.Web)) {
46
+ const { snapshotName, testName } = getTestName(testContext);
46
47
  const { SnapshotCommand } = await import('@virmator/test/dist/web-snapshot-plugin/snapshot-payload.js');
47
48
  const { executeServerCommand } = await import('@web/test-runner-commands');
48
49
  const result = await executeServerCommand(SnapshotCommand.CompareSnapshot, {
@@ -58,6 +59,10 @@ export async function assertSnapshot(testContext, data) {
58
59
  }
59
60
  }
60
61
  }
62
+ else {
63
+ const testEnv = determineTestContextEnv(testContext);
64
+ throw new Error(`assertSnapshot not supported for test env '${testEnv}'.`);
65
+ }
61
66
  }
62
67
  function getTestName(testContext) {
63
68
  const testName = extractTestName(testContext);
@@ -1,7 +1,7 @@
1
- import { RuntimeEnv } from '@augment-vir/core';
1
+ import { type SelectFrom } from '@augment-vir/common';
2
+ import { type PlaywrightTestArgs, type PlaywrightTestOptions, type PlaywrightWorkerArgs, type PlaywrightWorkerOptions, type TestInfo } from '@playwright/test';
2
3
  import { type TestContext as NodeTestContextImport } from 'node:test';
3
4
  import { type MochaTestContext } from './mocha-types.js';
4
- export { RuntimeEnv } from '@augment-vir/core';
5
5
  /**
6
6
  * The test context for [Node.js's test runner](https://nodejs.org/api/test.html).
7
7
  *
@@ -15,30 +15,65 @@ export type NodeTestContext = Readonly<NodeTestContextImport> & {
15
15
  [TestName in string]: number;
16
16
  };
17
17
  };
18
+ /**
19
+ * The test context for Playwright tests.
20
+ *
21
+ * @category Test : Util
22
+ * @category Package : @augment-vir/test
23
+ * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
24
+ */
25
+ export type PlaywrightTestContext = SelectFrom<PlaywrightTestArgs & PlaywrightTestOptions & PlaywrightWorkerArgs & PlaywrightWorkerOptions, {
26
+ page: true;
27
+ baseURL: true;
28
+ browser: true;
29
+ context: true;
30
+ extraHTTPHeaders: true;
31
+ viewport: true;
32
+ video: true;
33
+ userAgent: true;
34
+ timezoneId: true;
35
+ serviceWorkers: true;
36
+ screenshot: true;
37
+ isMobile: true;
38
+ headless: true;
39
+ hasTouch: true;
40
+ }> & {
41
+ testInfo: TestInfo;
42
+ testName: {
43
+ /** Clean, easily readable for humans. */
44
+ clean: string;
45
+ /** Unique with a random slug appended. */
46
+ unique: string;
47
+ };
48
+ };
18
49
  /**
19
50
  * Test context provided by `it`'s callback.
20
51
  *
21
- * Compatible with both [Node.js's test runner](https://nodejs.org/api/test.html) and
52
+ * Compatible with both [Node.js's test runner](https://nodejs.org/api/test.html),
22
53
  * [web-test-runner](https://modern-web.dev/docs/test-runner/overview/) or other Mocha-style test
23
- * runners.
54
+ * runners, and Playwright's test runner.
24
55
  *
25
56
  * @category Test : Util
26
57
  * @category Package : @augment-vir/test
27
58
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
28
59
  */
29
- export type UniversalTestContext = NodeTestContext | MochaTestContext;
60
+ export type UniversalTestContext = NodeTestContext | MochaTestContext | PlaywrightTestContext;
61
+ export declare enum TestEnv {
62
+ Node = "node",
63
+ Web = "web",
64
+ Playwright = "playwright"
65
+ }
30
66
  /**
31
- * Test context by runtime env when [Node.js's test runner](https://nodejs.org/api/test.html) is
32
- * used for Node tests and [web-test-runner](https://modern-web.dev/docs/test-runner/overview/) is
33
- * used for web tests.
67
+ * Test context by the env they run in.
34
68
  *
35
69
  * @category Test : Util
36
70
  * @category Package : @augment-vir/test
37
71
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
38
72
  */
39
- export type ContextByEnv = {
40
- [RuntimeEnv.Node]: NodeTestContext;
41
- [RuntimeEnv.Web]: MochaTestContext;
73
+ export type TestContextByEnv = {
74
+ [TestEnv.Node]: NodeTestContext;
75
+ [TestEnv.Web]: MochaTestContext;
76
+ [TestEnv.Playwright]: PlaywrightTestContext;
42
77
  };
43
78
  /**
44
79
  * Extracts the full test name (including parent describes) of a given test context. Whether the
@@ -58,6 +93,14 @@ export declare function extractTestName(testContext: UniversalTestContext): stri
58
93
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
59
94
  */
60
95
  export declare function extractTestNameAsDir(testContext: UniversalTestContext): string;
96
+ /**
97
+ * Same as {@link extractTestNameAsDir} but sanitizes any input in the same way.
98
+ *
99
+ * @category Test : Util
100
+ * @category Package : @augment-vir/test
101
+ * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
102
+ */
103
+ export declare function cleanTestNameAsDir(testName: string): string;
61
104
  /**
62
105
  * Asserts that the given context is for the given env and returns that context.
63
106
  *
@@ -66,7 +109,7 @@ export declare function extractTestNameAsDir(testContext: UniversalTestContext):
66
109
  * @throws `TypeError` if the context does not match the env.
67
110
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
68
111
  */
69
- export declare function assertWrapTestContext<const SpecificEnv extends RuntimeEnv>(this: void, context: UniversalTestContext, env: SpecificEnv): ContextByEnv[SpecificEnv];
112
+ export declare function assertWrapTestContext<const SpecificEnv extends TestEnv>(this: void, context: UniversalTestContext, env: SpecificEnv): TestContextByEnv[SpecificEnv];
70
113
  /**
71
114
  * Asserts that the given context is for the given env, otherwise throws an Error.
72
115
  *
@@ -74,7 +117,7 @@ export declare function assertWrapTestContext<const SpecificEnv extends RuntimeE
74
117
  * @category Package : @augment-vir/test
75
118
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
76
119
  */
77
- export declare function assertTestContext<const SpecificEnv extends RuntimeEnv>(this: void, context: UniversalTestContext, env: SpecificEnv): asserts context is ContextByEnv[SpecificEnv];
120
+ export declare function assertTestContext<const SpecificEnv extends TestEnv>(this: void, context: UniversalTestContext, env: SpecificEnv): asserts context is TestContextByEnv[SpecificEnv];
78
121
  /**
79
122
  * Checks that the given context is for the given env.
80
123
  *
@@ -82,7 +125,7 @@ export declare function assertTestContext<const SpecificEnv extends RuntimeEnv>(
82
125
  * @category Package : @augment-vir/test
83
126
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
84
127
  */
85
- export declare function isTestContext<const SpecificEnv extends RuntimeEnv>(this: void, context: UniversalTestContext, env: SpecificEnv): context is ContextByEnv[SpecificEnv];
128
+ export declare function isTestContext<const SpecificEnv extends TestEnv>(this: void, context: UniversalTestContext, env: SpecificEnv): context is TestContextByEnv[SpecificEnv];
86
129
  /**
87
130
  * Determine the env for the given test context.
88
131
  *
@@ -90,4 +133,4 @@ export declare function isTestContext<const SpecificEnv extends RuntimeEnv>(this
90
133
  * @category Package : @augment-vir/test
91
134
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
92
135
  */
93
- export declare function determineTestContextEnv(this: void, context: UniversalTestContext): RuntimeEnv;
136
+ export declare function determineTestContextEnv(this: void, context: UniversalTestContext): TestEnv;
@@ -1,6 +1,11 @@
1
- import { camelCaseToKebabCase } from '@augment-vir/common';
2
- import { RuntimeEnv } from '@augment-vir/core';
3
- export { RuntimeEnv } from '@augment-vir/core';
1
+ import { assertWrap } from '@augment-vir/assert';
2
+ import { camelCaseToKebabCase, sanitizeFilePath } from '@augment-vir/common';
3
+ export var TestEnv;
4
+ (function (TestEnv) {
5
+ TestEnv["Node"] = "node";
6
+ TestEnv["Web"] = "web";
7
+ TestEnv["Playwright"] = "playwright";
8
+ })(TestEnv || (TestEnv = {}));
4
9
  /**
5
10
  * Extracts the full test name (including parent describes) of a given test context. Whether the
6
11
  * test be run in web or node tests, the name will be the same.
@@ -10,9 +15,12 @@ export { RuntimeEnv } from '@augment-vir/core';
10
15
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
11
16
  */
12
17
  export function extractTestName(testContext) {
13
- if (isTestContext(testContext, RuntimeEnv.Node)) {
18
+ if (isTestContext(testContext, TestEnv.Node)) {
14
19
  return testContext.fullName;
15
20
  }
21
+ else if (isTestContext(testContext, TestEnv.Playwright)) {
22
+ return testContext.testName.clean;
23
+ }
16
24
  else {
17
25
  return flattenMochaParentTitles(testContext.test).join(' > ');
18
26
  }
@@ -26,7 +34,17 @@ export function extractTestName(testContext) {
26
34
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
27
35
  */
28
36
  export function extractTestNameAsDir(testContext) {
29
- return camelCaseToKebabCase(extractTestName(testContext)).replaceAll(/[<>:"/\-\\|?*_\s]+/g, '_');
37
+ return assertWrap.isTruthy(cleanTestNameAsDir(extractTestName(testContext)));
38
+ }
39
+ /**
40
+ * Same as {@link extractTestNameAsDir} but sanitizes any input in the same way.
41
+ *
42
+ * @category Test : Util
43
+ * @category Package : @augment-vir/test
44
+ * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
45
+ */
46
+ export function cleanTestNameAsDir(testName) {
47
+ return assertWrap.isTruthy(sanitizeFilePath(camelCaseToKebabCase(testName).replaceAll(/[<>:"/\-\\|?*_\s]+/g, '_')));
30
48
  }
31
49
  function flattenMochaParentTitles(node) {
32
50
  if (node.root) {
@@ -81,6 +99,7 @@ export function isTestContext(context, env) {
81
99
  }
82
100
  }
83
101
  const nodeOnlyCheckKey = 'diagnostic';
102
+ const playwrightOnlyCheckKey = 'browser';
84
103
  /**
85
104
  * Determine the env for the given test context.
86
105
  *
@@ -89,5 +108,13 @@ const nodeOnlyCheckKey = 'diagnostic';
89
108
  * @package [`@augment-vir/test`](https://www.npmjs.com/package/@augment-vir/test)
90
109
  */
91
110
  export function determineTestContextEnv(context) {
92
- return nodeOnlyCheckKey in context ? RuntimeEnv.Node : RuntimeEnv.Web;
111
+ if (playwrightOnlyCheckKey in context) {
112
+ return TestEnv.Playwright;
113
+ }
114
+ else if (nodeOnlyCheckKey in context) {
115
+ return TestEnv.Node;
116
+ }
117
+ else {
118
+ return TestEnv.Web;
119
+ }
93
120
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './augments/test-playwright.js';
1
2
  export * from './augments/test-web.js';
2
3
  export * from './augments/universal-testing-suite/it-cases-with-context.js';
3
4
  export * from './augments/universal-testing-suite/it-cases.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './augments/test-playwright.js';
1
2
  export * from './augments/test-web.js';
2
3
  export * from './augments/universal-testing-suite/it-cases-with-context.js';
3
4
  export * from './augments/universal-testing-suite/it-cases.js';
@@ -0,0 +1,8 @@
1
+ import { type Locator } from '@playwright/test';
2
+ /**
3
+ * Expects that all matches for the given locator are either visible or hidden (controlled by
4
+ * `isVisible`).
5
+ *
6
+ * @category Internal
7
+ */
8
+ export declare function expectAllVisible(locator: Readonly<Locator>, isVisible: boolean): Promise<void>;
@@ -0,0 +1,18 @@
1
+ import { expect } from '@playwright/test';
2
+ /**
3
+ * Expects that all matches for the given locator are either visible or hidden (controlled by
4
+ * `isVisible`).
5
+ *
6
+ * @category Internal
7
+ */
8
+ export async function expectAllVisible(locator, isVisible) {
9
+ const count = await locator.count();
10
+ for (let i = 0; i < count; i++) {
11
+ if (isVisible) {
12
+ await expect(locator.nth(i)).toBeVisible();
13
+ }
14
+ else {
15
+ await expect(locator.nth(i)).toBeHidden();
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,10 @@
1
+ import { type Page } from '@playwright/test';
2
+ /**
3
+ * Clicks a label to select its input and then types the given text.
4
+ *
5
+ * @category Internal
6
+ */
7
+ export declare function enterTextByLabel(page: Readonly<Page>, { label, text }: {
8
+ label: string;
9
+ text: string;
10
+ }): Promise<void>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Clicks a label to select its input and then types the given text.
3
+ *
4
+ * @category Internal
5
+ */
6
+ export async function enterTextByLabel(page, { label, text }) {
7
+ await page.getByLabel(label).first().click();
8
+ await page.keyboard.type(text);
9
+ }
@@ -0,0 +1,15 @@
1
+ import { type Page } from '@playwright/test';
2
+ /**
3
+ * Options for `testPlaywright.getMenuOption`.
4
+ *
5
+ * @category Internal
6
+ */
7
+ export type MenuOptionOptions = Parameters<Page['getByRole']>[1] & Partial<{
8
+ nth: number;
9
+ }>;
10
+ /**
11
+ * Find the matching (or first) "option" element.
12
+ *
13
+ * @category Internal
14
+ */
15
+ export declare function getMenuOption(page: Readonly<Page>, options?: MenuOptionOptions | undefined): import("playwright-core").Locator;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Find the matching (or first) "option" element.
3
+ *
4
+ * @category Internal
5
+ */
6
+ export function getMenuOption(page, options) {
7
+ const baseLocator = page.getByRole('option', options);
8
+ if (options && 'nth' in options) {
9
+ return baseLocator.nth(options.nth);
10
+ }
11
+ else {
12
+ return baseLocator.first();
13
+ }
14
+ }
@@ -0,0 +1,7 @@
1
+ import { type Locator } from '@playwright/test';
2
+ /**
3
+ * Checks if a locator contains the given class.
4
+ *
5
+ * @category Internal
6
+ */
7
+ export declare function checkHasClass(locator: Readonly<Locator>, className: string): Promise<boolean>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Checks if a locator contains the given class.
3
+ *
4
+ * @category Internal
5
+ */
6
+ export async function checkHasClass(locator, className) {
7
+ if (!className) {
8
+ return false;
9
+ }
10
+ const currentClassValue = (await locator.getAttribute('class')) || '';
11
+ return currentClassValue.includes(className);
12
+ }
@@ -0,0 +1,7 @@
1
+ import { type Page } from '@playwright/test';
2
+ /**
3
+ * Read from a page's local storage.
4
+ *
5
+ * @category Internal
6
+ */
7
+ export declare function readLocalStorage(page: Readonly<Page>, storageKey: string): Promise<string | undefined>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Read from a page's local storage.
3
+ *
4
+ * @category Internal
5
+ */
6
+ export async function readLocalStorage(page, storageKey) {
7
+ return ((await page.evaluate((keyToRead) => {
8
+ return localStorage.getItem(keyToRead);
9
+ }, storageKey)) || undefined);
10
+ }
@@ -0,0 +1,28 @@
1
+ import { type GenericTreePaths } from 'spa-router-vir';
2
+ import { type UniversalTestContext } from '../augments/universal-testing-suite/universal-test-context.js';
3
+ /**
4
+ * Used for the `path` argument of `testPlaywright.nav`.
5
+ *
6
+ * @category Internal
7
+ */
8
+ export type NavPath = /** A full URL to load, will not be appended to the auto detected frontend url. */ string
9
+ /** Path array to append to the auto detected frontend url. */
10
+ | string[]
11
+ /** Prefer using tree paths with `frontendPathTree` */
12
+ | GenericTreePaths;
13
+ /**
14
+ * The test name appended to the frontend when `testPlaywright.nav` is used.
15
+ *
16
+ * @category Internal
17
+ */
18
+ export declare const playwrightTeatNameUrlParam = "test-name";
19
+ /**
20
+ * Navigate to a URL in Playwright via given paths.
21
+ *
22
+ * @category Internal
23
+ */
24
+ export declare function nav(testContext: UniversalTestContext, { path, baseFrontendUrl, }: {
25
+ path: NavPath;
26
+ /** If not provided, the page's current URL will be used. */
27
+ baseFrontendUrl?: string | undefined;
28
+ }): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { check } from '@augment-vir/assert';
2
+ import { buildUrl } from 'url-vir';
3
+ import { assertTestContext, extractTestNameAsDir, TestEnv, } from '../augments/universal-testing-suite/universal-test-context.js';
4
+ function extractNavUrl(frontendUrl, path) {
5
+ return check.isString(path)
6
+ ? path
7
+ : check.isArray(path)
8
+ ? buildUrl(frontendUrl, {
9
+ paths: path,
10
+ }).href
11
+ : buildUrl(frontendUrl, {
12
+ paths: path.fullPaths,
13
+ }).href;
14
+ }
15
+ /**
16
+ * The test name appended to the frontend when `testPlaywright.nav` is used.
17
+ *
18
+ * @category Internal
19
+ */
20
+ export const playwrightTeatNameUrlParam = 'test-name';
21
+ /**
22
+ * Navigate to a URL in Playwright via given paths.
23
+ *
24
+ * @category Internal
25
+ */
26
+ export async function nav(testContext, { path, baseFrontendUrl, }) {
27
+ assertTestContext(testContext, TestEnv.Playwright);
28
+ const page = testContext.page;
29
+ const testName = extractTestNameAsDir(testContext);
30
+ const finalPath = buildUrl(extractNavUrl(baseFrontendUrl || page.url(), path), {
31
+ search: {
32
+ [playwrightTeatNameUrlParam]: [testName],
33
+ },
34
+ }).href;
35
+ if (page.url() === finalPath) {
36
+ return;
37
+ }
38
+ else {
39
+ await page.goto(finalPath, {
40
+ waitUntil: 'domcontentloaded',
41
+ });
42
+ }
43
+ }
@@ -0,0 +1,13 @@
1
+ import { type MaybePromise } from '@augment-vir/common';
2
+ import { type Download, type Page } from '@playwright/test';
3
+ import { type RequireExactlyOne } from 'type-fest';
4
+ /**
5
+ * Run the trigger and catch a new page _or_ a new download (sometimes Playwright inconsistently
6
+ * chooses on or the other).
7
+ *
8
+ * @category Internal
9
+ */
10
+ export declare function handleNewPageOrDownload(page: Readonly<Page>, trigger: () => MaybePromise<void>): Promise<RequireExactlyOne<{
11
+ newPage: Page;
12
+ download: Download;
13
+ }>>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Run the trigger and catch a new page _or_ a new download (sometimes Playwright inconsistently
3
+ * chooses on or the other).
4
+ *
5
+ * @category Internal
6
+ */
7
+ export async function handleNewPageOrDownload(page, trigger) {
8
+ const openOrDownload = Promise.race([
9
+ page
10
+ .context()
11
+ .waitForEvent('page', async (newPage) => {
12
+ return (await newPage.opener()) === page;
13
+ })
14
+ .then((result) => {
15
+ return { page: result };
16
+ }),
17
+ page.waitForEvent('download').then((result) => {
18
+ return { download: result };
19
+ }),
20
+ ]);
21
+ await trigger();
22
+ return (await openOrDownload);
23
+ }
@@ -0,0 +1,66 @@
1
+ import { type Locator, type Page, type TestInfo } from '@playwright/test';
2
+ /** This is used for type extraction because Playwright does not export the types we need. */
3
+ declare function extractScreenshotMethod(): {
4
+ (name: string | ReadonlyArray<string>, options?: {
5
+ animations?: "disabled" | "allow";
6
+ caret?: "hide" | "initial";
7
+ mask?: Array<Locator>;
8
+ maskColor?: string;
9
+ maxDiffPixelRatio?: number;
10
+ maxDiffPixels?: number;
11
+ omitBackground?: boolean;
12
+ scale?: "css" | "device";
13
+ stylePath?: string | Array<string>;
14
+ threshold?: number;
15
+ timeout?: number;
16
+ }): Promise<void>;
17
+ (options?: {
18
+ animations?: "disabled" | "allow";
19
+ caret?: "hide" | "initial";
20
+ mask?: Array<Locator>;
21
+ maskColor?: string;
22
+ maxDiffPixelRatio?: number;
23
+ maxDiffPixels?: number;
24
+ omitBackground?: boolean;
25
+ scale?: "css" | "device";
26
+ stylePath?: string | Array<string>;
27
+ threshold?: number;
28
+ timeout?: number;
29
+ }): Promise<void>;
30
+ };
31
+ /**
32
+ * Correct options for `locator.screenshot`. (Playwright's `LocatorScreenshotOptions` export is
33
+ * wrong.)
34
+ *
35
+ * @category Internal
36
+ * @default defaultScreenshotOptions
37
+ */
38
+ export type LocatorScreenshotOptions = NonNullable<Parameters<ReturnType<typeof extractScreenshotMethod>>[0]>;
39
+ /**
40
+ * Default internal options for {@link LocatorScreenshotOptions}, used in {@link expectScreenshot}
41
+ *
42
+ * @category Internal
43
+ */
44
+ export declare const defaultScreenshotOptions: {
45
+ animations: "disabled";
46
+ caret: "hide";
47
+ timeout: number;
48
+ scale: "css";
49
+ threshold: number;
50
+ maxDiffPixelRatio: number;
51
+ };
52
+ /**
53
+ * Similar to Playwright's `expect().toHaveScreenshot` but allows images to have different sizes and
54
+ * has default comparison threshold options that are wide enough to allow testing between different
55
+ * operating systems without failure (usually).
56
+ *
57
+ * @category Internal
58
+ */
59
+ export declare function expectScreenshot(page: Readonly<Page>, { locator, screenshotName, testInfo, options, }: {
60
+ testInfo: Readonly<TestInfo>;
61
+ /** If no locator is provided, a screenshot of the whole page will be taken. */
62
+ locator: Readonly<Locator> | undefined;
63
+ screenshotName: string;
64
+ options?: Partial<LocatorScreenshotOptions> | undefined;
65
+ }): Promise<void>;
66
+ export {};
@@ -0,0 +1,128 @@
1
+ import { assert } from '@augment-vir/assert';
2
+ import { addSuffix, log } from '@augment-vir/common';
3
+ import { writeFileAndDir } from '@augment-vir/node';
4
+ import { expect } from '@playwright/test';
5
+ import { existsSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { relative } from 'node:path';
8
+ import pixelmatch from 'pixelmatch';
9
+ import { PNG } from 'pngjs';
10
+ import sharp from 'sharp';
11
+ /** This is used for type extraction because Playwright does not export the types we need. */
12
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
13
+ function extractScreenshotMethod() {
14
+ assert.never('this function should not be executed, it is only used for types');
15
+ // eslint-disable-next-line @typescript-eslint/unbound-method
16
+ return expect({}).toHaveScreenshot;
17
+ }
18
+ /**
19
+ * Default internal options for {@link LocatorScreenshotOptions}, used in {@link expectScreenshot}
20
+ *
21
+ * @category Internal
22
+ */
23
+ export const defaultScreenshotOptions = {
24
+ animations: 'disabled',
25
+ caret: 'hide',
26
+ timeout: 10_000,
27
+ scale: 'css',
28
+ threshold: 0.1,
29
+ maxDiffPixelRatio: 0.08,
30
+ };
31
+ async function padImage(image, { height, width }) {
32
+ return await sharp({
33
+ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
34
+ })
35
+ /** Top-left align. */
36
+ .composite([{ input: image, left: 0, top: 0 }])
37
+ .png()
38
+ .toBuffer();
39
+ }
40
+ /** Pads both images to the same canvas (max width/height) without scaling. */
41
+ async function padToSameCanvas(aBuf, bBuf) {
42
+ const [aMeta, bMeta,] = await Promise.all([
43
+ sharp(aBuf).metadata(),
44
+ sharp(bBuf).metadata(),
45
+ ]);
46
+ if (!aMeta.width || !aMeta.height || !bMeta.width || !bMeta.height) {
47
+ throw new Error('Unable to read image dimensions.');
48
+ }
49
+ const dimensions = {
50
+ width: Math.max(aMeta.width, bMeta.width),
51
+ height: Math.max(aMeta.height, bMeta.height),
52
+ };
53
+ const [aPadded, bPadded,] = await Promise.all([
54
+ padImage(aBuf, dimensions),
55
+ padImage(bBuf, dimensions),
56
+ ]);
57
+ const aPng = PNG.sync.read(aPadded);
58
+ const bPng = PNG.sync.read(bPadded);
59
+ return {
60
+ aPng,
61
+ bPng,
62
+ dimensions,
63
+ };
64
+ }
65
+ async function takeScreenshot({ locator, page, options, }) {
66
+ if (locator) {
67
+ /** The locator expectation has different options than the page expectation. */
68
+ return await locator.screenshot({ ...defaultScreenshotOptions, ...options });
69
+ }
70
+ else {
71
+ return await page.screenshot({
72
+ ...defaultScreenshotOptions,
73
+ ...options,
74
+ });
75
+ }
76
+ }
77
+ /**
78
+ * Similar to Playwright's `expect().toHaveScreenshot` but allows images to have different sizes and
79
+ * has default comparison threshold options that are wide enough to allow testing between different
80
+ * operating systems without failure (usually).
81
+ *
82
+ * @category Internal
83
+ */
84
+ export async function expectScreenshot(page, { locator, screenshotName, testInfo, options = {}, }) {
85
+ const screenshotFileName = addSuffix({ value: screenshotName, suffix: '.png' });
86
+ const currentScreenshotBuffer = await takeScreenshot({ page, locator, options });
87
+ const screenshotFilePath = testInfo.snapshotPath(screenshotFileName);
88
+ async function writeNewScreenshot() {
89
+ log.mutate(`Updated screenshot: ${relative(process.cwd(), screenshotFilePath)}`);
90
+ await writeFileAndDir(screenshotFilePath, currentScreenshotBuffer);
91
+ }
92
+ async function writeExpectationScreenshot(contents, fileName) {
93
+ const filePath = testInfo.outputPath(addSuffix({ value: fileName, suffix: '.png' }));
94
+ await writeFileAndDir(filePath, contents);
95
+ }
96
+ if (existsSync(screenshotFilePath)) {
97
+ if (testInfo.config.updateSnapshots === 'changed') {
98
+ await writeNewScreenshot();
99
+ }
100
+ }
101
+ else {
102
+ if (testInfo.config.updateSnapshots !== 'none') {
103
+ await writeNewScreenshot();
104
+ }
105
+ await writeExpectationScreenshot(currentScreenshotBuffer, 'actual');
106
+ throw new Error(`Baseline screenshot not found: ${screenshotFilePath}. Re-run with --update-snapshots to create it.`);
107
+ }
108
+ const baseScreenshotBuffer = await readFile(screenshotFilePath);
109
+ const { aPng: baseScreenshotPng, bPng: currentScreenshotPng, dimensions, } = await padToSameCanvas(baseScreenshotBuffer, currentScreenshotBuffer);
110
+ const diffPng = new PNG(dimensions);
111
+ const diffPixelCount = pixelmatch(baseScreenshotPng.data, currentScreenshotPng.data, diffPng.data, dimensions.width, dimensions.height, {
112
+ threshold: defaultScreenshotOptions.threshold,
113
+ });
114
+ const totalPixels = dimensions.width * dimensions.height;
115
+ const diffRatio = diffPixelCount / totalPixels;
116
+ const ratioOk = diffRatio <= defaultScreenshotOptions.maxDiffPixelRatio;
117
+ if (!ratioOk) {
118
+ if (process.env.CI) {
119
+ await writeNewScreenshot();
120
+ }
121
+ else {
122
+ await writeExpectationScreenshot(PNG.sync.write(baseScreenshotPng), 'expected');
123
+ await writeExpectationScreenshot(PNG.sync.write(currentScreenshotPng), 'actual');
124
+ await writeExpectationScreenshot(PNG.sync.write(diffPng), 'diff');
125
+ throw new Error(`Screenshot mismatch: ${screenshotFilePath}\n diff=${diffPixelCount}px (${(diffRatio * 100).toFixed(3)}%) (limit: ${(defaultScreenshotOptions.maxDiffPixelRatio * 100).toFixed(3)}%). Run with --update-snapshots to update screenshot.`);
126
+ }
127
+ }
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augment-vir/test",
3
- "version": "31.49.0",
3
+ "version": "31.50.0",
4
4
  "description": "A universal testing suite that works with Mocha style test runners _and_ Node.js's built-in test runner.",
5
5
  "keywords": [
6
6
  "test",
@@ -36,20 +36,22 @@
36
36
  "scripts": {
37
37
  "compile": "virmator compile",
38
38
  "start": "tsx src/index.ts",
39
- "test": "runstorm --names node,web \"npm run test:node\" \"npm run test:web\"",
39
+ "test": "runstorm --names node,web,playwright \"npm run test:node\" \"npm run test:web\" \"npm run test:playwright\"",
40
40
  "test:coverage": "npm run test",
41
41
  "test:node": "virmator test --no-deps node 'src/augments/universal-testing-suite/**/*.test.ts'",
42
+ "test:playwright": "playwright test --config configs/playwright.config.ts",
42
43
  "test:update": "npm test",
43
44
  "test:web": "virmator test --no-deps web 'src/test-web/**/*.test.ts' 'src/augments/universal-testing-suite/**/*.test.ts'"
44
45
  },
45
46
  "dependencies": {
46
- "@augment-vir/assert": "^31.49.0",
47
- "@augment-vir/common": "^31.49.0",
47
+ "@augment-vir/assert": "^31.50.0",
48
+ "@augment-vir/common": "^31.50.0",
48
49
  "@open-wc/testing-helpers": "^3.0.1",
49
- "@virmator/test": "^14.2.1",
50
+ "@virmator/test": "^14.2.2",
50
51
  "type-fest": "^5.2.0"
51
52
  },
52
53
  "devDependencies": {
54
+ "@playwright/test": "^1.56.1",
53
55
  "@types/node": "^24.10.0",
54
56
  "@web/dev-server-esbuild": "^1.0.4",
55
57
  "@web/test-runner": "^0.20.2",
@@ -57,11 +59,22 @@
57
59
  "@web/test-runner-playwright": "^0.11.1",
58
60
  "element-vir": "^26.11.1",
59
61
  "istanbul-smart-text-reporter": "^1.1.5",
62
+ "pixelmatch": "^5.3.0",
63
+ "pngjs": "^7.0.0",
60
64
  "runstorm": "^0.6.2",
61
- "typescript": "^5.9.3"
65
+ "sharp": "^0.34.5",
66
+ "spa-router-vir": "^6.3.1",
67
+ "typescript": "^5.9.3",
68
+ "url-vir": "^2.1.6"
62
69
  },
63
70
  "peerDependencies": {
64
- "element-vir": "*"
71
+ "@playwright/test": "*",
72
+ "element-vir": "*",
73
+ "pixelmatch": ">=5",
74
+ "pngjs": ">=7",
75
+ "sharp": ">=0.34",
76
+ "spa-router-vir": ">=6",
77
+ "url-vir": ">=2"
65
78
  },
66
79
  "engines": {
67
80
  "node": ">=22"