@vitest/browser 4.0.0-beta.1 → 4.0.0-beta.11
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/LICENSE +1 -1
- package/README.md +3 -15
- package/context.d.ts +153 -3
- package/dist/client/.vite/manifest.json +6 -6
- package/dist/client/__vitest__/assets/{index-KbpJLW--.css → index-CCvbyxW7.css} +1 -1
- package/dist/client/__vitest__/assets/index-CYIziQD6.js +53 -0
- package/dist/client/__vitest__/index.html +2 -2
- package/dist/client/__vitest_browser__/orchestrator-DOxlOAkk.js +293 -0
- package/dist/client/__vitest_browser__/tester-B7fynsGK.js +2090 -0
- package/dist/client/__vitest_browser__/{utils-Owv5OOOf.js → utils-CPmDBIKG.js} +3 -3
- package/dist/client/error-catcher.js +7 -3
- package/dist/client/esm-client-injector.js +1 -0
- package/dist/client/orchestrator.html +2 -2
- package/dist/client/tester/tester.html +2 -2
- package/dist/client.js +24 -8
- package/dist/context.js +29 -22
- package/dist/expect-element.js +10 -8
- package/dist/index-CwoiDq7G.js +6 -0
- package/dist/index-DDlvjJVO.js +1 -0
- package/dist/index.d.ts +16 -10
- package/dist/index.js +555 -104
- package/dist/locators/index.d.ts +8 -7
- package/dist/locators/index.js +1 -1
- package/dist/locators/playwright.js +1 -1
- package/dist/locators/preview.js +1 -1
- package/dist/locators/webdriverio.js +1 -1
- package/dist/providers/playwright.d.ts +103 -0
- package/dist/{webdriver-KA1WiV0q.js → providers/playwright.js} +37 -180
- package/dist/providers/preview.d.ts +16 -0
- package/dist/{providers.js → providers/preview.js} +17 -21
- package/dist/providers/webdriverio.d.ts +50 -0
- package/dist/providers/webdriverio.js +171 -0
- package/dist/shared/screenshotMatcher/types.d.ts +16 -0
- package/dist/state.js +4 -3
- package/dist/types.d.ts +5 -7
- package/jest-dom.d.ts +95 -1
- package/package.json +22 -32
- package/utils.d.ts +1 -1
- package/dist/client/__vitest__/assets/index-BjtzXzAw.js +0 -58
- package/dist/client/__vitest_browser__/orchestrator-CQgVbcQq.js +0 -3213
- package/dist/client/__vitest_browser__/tester-BScMoGFI.js +0 -3560
- package/dist/index-W1MM53zC.js +0 -1
- package/providers/playwright.d.ts +0 -81
- package/providers/webdriverio.d.ts +0 -22
package/dist/index.js
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import { ManualMockedModule, RedirectedModule, AutomockedModule, AutospiedModule, MockerRegistry } from '@vitest/mocker';
|
|
2
2
|
import { dynamicImportPlugin, ServerMockResolver, interceptorPlugin } from '@vitest/mocker/node';
|
|
3
3
|
import c from 'tinyrainbow';
|
|
4
|
-
import {
|
|
5
|
-
import fs, { readFileSync, lstatSync, promises, existsSync } from 'node:fs';
|
|
4
|
+
import { isValidApiRequest, isFileServingAllowed, distDir, resolveApiServerConfig, resolveFsAllow, rolldownVersion, createDebugger, createViteLogger, createViteServer } from 'vitest/node';
|
|
5
|
+
import fs, { readFileSync, lstatSync, createReadStream, promises, existsSync } from 'node:fs';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
-
import { slash as slash$1, toArray, createDefer } from '@vitest/utils';
|
|
7
|
+
import { slash as slash$1, toArray, deepMerge, createDefer } from '@vitest/utils/helpers';
|
|
8
8
|
import MagicString from 'magic-string';
|
|
9
9
|
import sirv from 'sirv';
|
|
10
|
-
import * as vite from 'vite';
|
|
11
10
|
import { coverageConfigDefaults } from 'vitest/config';
|
|
12
11
|
import { fileURLToPath } from 'node:url';
|
|
13
12
|
import crypto from 'node:crypto';
|
|
14
|
-
import { mkdir, rm, readFile as readFile$1 } from 'node:fs/promises';
|
|
13
|
+
import { mkdir, rm, readFile as readFile$1, writeFile as writeFile$1 } from 'node:fs/promises';
|
|
15
14
|
import { parseErrorStacktrace, parseStacktrace } from '@vitest/utils/source-map';
|
|
16
|
-
import {
|
|
15
|
+
import { PlaywrightBrowserProvider } from './providers/playwright.js';
|
|
16
|
+
import { WebdriverBrowserProvider } from './providers/webdriverio.js';
|
|
17
17
|
import { resolve as resolve$1, basename as basename$1, dirname as dirname$1, normalize as normalize$1 } from 'node:path';
|
|
18
|
-
import { WebSocketServer } from 'ws';
|
|
19
18
|
import * as nodeos from 'node:os';
|
|
19
|
+
import { platform } from 'node:os';
|
|
20
|
+
import { PNG } from 'pngjs';
|
|
21
|
+
import pm from 'pixelmatch';
|
|
22
|
+
import { WebSocketServer } from 'ws';
|
|
20
23
|
import { performance } from 'node:perf_hooks';
|
|
21
24
|
|
|
22
|
-
var version = "4.0.0-beta.
|
|
25
|
+
var version = "4.0.0-beta.11";
|
|
23
26
|
|
|
24
27
|
const _DRIVE_LETTER_START_RE = /^[A-Za-z]:\//;
|
|
25
28
|
function normalizeWindowsPath(input = "") {
|
|
@@ -324,27 +327,28 @@ const stringify = (value, replacer, space) => {
|
|
|
324
327
|
function replacer(code, values) {
|
|
325
328
|
return code.replace(/\{\s*(\w+)\s*\}/g, (_, key) => values[key] ?? _);
|
|
326
329
|
}
|
|
327
|
-
const builtinProviders = [
|
|
328
|
-
"webdriverio",
|
|
329
|
-
"playwright",
|
|
330
|
-
"preview"
|
|
331
|
-
];
|
|
332
330
|
async function getBrowserProvider(options, project) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
331
|
+
const browser = project.config.browser.name;
|
|
332
|
+
const name = project.name ? `[${project.name}] ` : "";
|
|
333
|
+
if (!browser) {
|
|
334
|
+
throw new Error(`${name}Browser name is required. Please, set \`test.browser.instances[].browser\` option manually.`);
|
|
337
335
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
336
|
+
if (options.provider == null || "_cli" in options.provider && typeof options.provider.factory !== "function") {
|
|
337
|
+
const providers = await import('./index-CwoiDq7G.js');
|
|
338
|
+
const name = options.provider?.name || "preview";
|
|
339
|
+
if (!(name in providers)) {
|
|
340
|
+
throw new Error(`Unknown browser provider "${name}". Available providers: ${Object.keys(providers).join(", ")}.`);
|
|
341
|
+
}
|
|
342
|
+
return providers[name]().factory(project);
|
|
343
|
+
}
|
|
344
|
+
const supportedBrowsers = options.provider.supportedBrowser || [];
|
|
345
|
+
if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) {
|
|
346
|
+
throw new Error(`${name}Browser "${browser}" is not supported by the browser provider "${options.provider.name}". Supported browsers: ${supportedBrowsers.join(", ")}.`);
|
|
343
347
|
}
|
|
344
|
-
if (
|
|
345
|
-
throw new
|
|
348
|
+
if (typeof options.provider.factory !== "function") {
|
|
349
|
+
throw new TypeError(`The "${name}" browser provider does not provide a "factory" function. Received ${typeof options.provider.factory}.`);
|
|
346
350
|
}
|
|
347
|
-
return
|
|
351
|
+
return options.provider.factory(project);
|
|
348
352
|
}
|
|
349
353
|
function slash(path) {
|
|
350
354
|
return path.replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
@@ -355,7 +359,7 @@ async function resolveOrchestrator(globalServer, url, res) {
|
|
|
355
359
|
// it's possible to open the page without a context
|
|
356
360
|
if (!sessionId) {
|
|
357
361
|
const contexts = [...globalServer.children].flatMap((p) => [...p.state.orchestrators.keys()]);
|
|
358
|
-
sessionId = contexts
|
|
362
|
+
sessionId = contexts.at(-1) ?? "none";
|
|
359
363
|
}
|
|
360
364
|
// it's ok to not have a session here, especially in the preview provider
|
|
361
365
|
// because the user could refresh the page which would remove the session id from the url
|
|
@@ -364,13 +368,13 @@ async function resolveOrchestrator(globalServer, url, res) {
|
|
|
364
368
|
if (!browserProject) {
|
|
365
369
|
return;
|
|
366
370
|
}
|
|
367
|
-
// ignore
|
|
371
|
+
// ignore unknown pages
|
|
368
372
|
if (sessionId && sessionId !== "none" && !globalServer.vitest._browserSessions.sessionIds.has(sessionId)) {
|
|
369
373
|
return;
|
|
370
374
|
}
|
|
371
375
|
const injectorJs = typeof globalServer.injectorJs === "string" ? globalServer.injectorJs : await globalServer.injectorJs;
|
|
372
376
|
const injector = replacer(injectorJs, {
|
|
373
|
-
__VITEST_PROVIDER__: JSON.stringify(browserProject.config.browser.provider || "preview"),
|
|
377
|
+
__VITEST_PROVIDER__: JSON.stringify(browserProject.config.browser.provider?.name || "preview"),
|
|
374
378
|
__VITEST_CONFIG__: JSON.stringify(browserProject.wrapSerializedConfig()),
|
|
375
379
|
__VITEST_VITE_CONFIG__: JSON.stringify({ root: browserProject.vite.config.root }),
|
|
376
380
|
__VITEST_METHOD__: JSON.stringify("orchestrate"),
|
|
@@ -388,7 +392,7 @@ async function resolveOrchestrator(globalServer, url, res) {
|
|
|
388
392
|
for (const attr in script.attrs || {}) {
|
|
389
393
|
html += `${attr}="${script.attrs[attr]}" `;
|
|
390
394
|
}
|
|
391
|
-
html += `>${script.children}
|
|
395
|
+
html += `>${script.children}<\/script>`;
|
|
392
396
|
return html;
|
|
393
397
|
}).join("\n");
|
|
394
398
|
}
|
|
@@ -402,15 +406,15 @@ async function resolveOrchestrator(globalServer, url, res) {
|
|
|
402
406
|
"{__VITEST_INJECTOR__}",
|
|
403
407
|
"{__VITEST_ERROR_CATCHER__}",
|
|
404
408
|
"{__VITEST_SCRIPTS__}",
|
|
405
|
-
`<script type="module" crossorigin src="${base}${jsEntry}"
|
|
409
|
+
`<script type="module" crossorigin src="${base}${jsEntry}"><\/script>`
|
|
406
410
|
].join("\n"));
|
|
407
411
|
}
|
|
408
412
|
return replacer(baseHtml, {
|
|
409
413
|
__VITEST_FAVICON__: globalServer.faviconUrl,
|
|
410
414
|
__VITEST_TITLE__: "Vitest Browser Runner",
|
|
411
415
|
__VITEST_SCRIPTS__: globalServer.orchestratorScripts,
|
|
412
|
-
__VITEST_INJECTOR__: `<script type="module">${injector}
|
|
413
|
-
__VITEST_ERROR_CATCHER__: `<script type="module" src="${globalServer.errorCatcherUrl}"
|
|
416
|
+
__VITEST_INJECTOR__: `<script type="module">${injector}<\/script>`,
|
|
417
|
+
__VITEST_ERROR_CATCHER__: `<script type="module" src="${globalServer.errorCatcherUrl}"><\/script>`,
|
|
414
418
|
__VITEST_SESSION_ID__: JSON.stringify(sessionId)
|
|
415
419
|
});
|
|
416
420
|
}
|
|
@@ -671,6 +675,31 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
671
675
|
}
|
|
672
676
|
next();
|
|
673
677
|
});
|
|
678
|
+
// handle attachments the same way as in packages/ui/node/index.ts
|
|
679
|
+
server.middlewares.use((req, res, next) => {
|
|
680
|
+
if (!req.url) {
|
|
681
|
+
return next();
|
|
682
|
+
}
|
|
683
|
+
const url = new URL(req.url, "http://localhost");
|
|
684
|
+
if (url.pathname !== "/__vitest_attachment__") {
|
|
685
|
+
return next();
|
|
686
|
+
}
|
|
687
|
+
const path = url.searchParams.get("path");
|
|
688
|
+
const contentType = url.searchParams.get("contentType");
|
|
689
|
+
if (!isValidApiRequest(parentServer.config, req) || !contentType || !path) {
|
|
690
|
+
return next();
|
|
691
|
+
}
|
|
692
|
+
const fsPath = decodeURIComponent(path);
|
|
693
|
+
if (!isFileServingAllowed(parentServer.vite.config, fsPath)) {
|
|
694
|
+
return next();
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
res.setHeader("content-type", contentType);
|
|
698
|
+
return createReadStream(fsPath).pipe(res).on("close", () => res.end());
|
|
699
|
+
} catch (err) {
|
|
700
|
+
return next(err);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
674
703
|
}
|
|
675
704
|
},
|
|
676
705
|
{
|
|
@@ -680,8 +709,7 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
680
709
|
// this plugin can be used in different projects, but all of them
|
|
681
710
|
// have the same `include` pattern, so it doesn't matter which project we use
|
|
682
711
|
const project = parentServer.project;
|
|
683
|
-
const { testFiles:
|
|
684
|
-
const browserTestFiles = allTestFiles.filter((file) => getFilePoolName(project, file) === "browser");
|
|
712
|
+
const { testFiles: browserTestFiles } = await project.globTestFiles();
|
|
685
713
|
const setupFiles = toArray(project.config.setupFiles);
|
|
686
714
|
// replace env values - cannot be reassign at runtime
|
|
687
715
|
const define = {};
|
|
@@ -723,7 +751,7 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
723
751
|
entries.push(project.config.diff);
|
|
724
752
|
}
|
|
725
753
|
if (parentServer.vitest.coverageProvider) {
|
|
726
|
-
const coverage = parentServer.vitest.
|
|
754
|
+
const coverage = parentServer.vitest._coverageOptions;
|
|
727
755
|
const provider = coverage.provider;
|
|
728
756
|
if (provider === "v8") {
|
|
729
757
|
const path = tryResolve("@vitest/coverage-v8", [parentServer.config.root]);
|
|
@@ -744,9 +772,8 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
744
772
|
const include = [
|
|
745
773
|
"vitest > expect-type",
|
|
746
774
|
"vitest > @vitest/snapshot > magic-string",
|
|
747
|
-
"vitest > chai",
|
|
748
|
-
"vitest > chai > loupe",
|
|
749
|
-
"vitest > @vitest/runner > strip-literal",
|
|
775
|
+
"vitest > @vitest/expect > chai",
|
|
776
|
+
"vitest > @vitest/expect > chai > loupe",
|
|
750
777
|
"vitest > @vitest/utils > loupe",
|
|
751
778
|
"@vitest/browser > @testing-library/user-event",
|
|
752
779
|
"@vitest/browser > @testing-library/dom"
|
|
@@ -835,7 +862,7 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
835
862
|
viteConfig.esbuild ||= {};
|
|
836
863
|
viteConfig.esbuild.legalComments = "inline";
|
|
837
864
|
}
|
|
838
|
-
const defaultPort = parentServer.vitest.
|
|
865
|
+
const defaultPort = parentServer.vitest.state._data.browserLastPort++;
|
|
839
866
|
const api = resolveApiServerConfig(viteConfig.test?.browser || {}, defaultPort);
|
|
840
867
|
viteConfig.server = {
|
|
841
868
|
...viteConfig.server,
|
|
@@ -859,7 +886,7 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
859
886
|
return;
|
|
860
887
|
}
|
|
861
888
|
const s = new MagicString(code, { filename });
|
|
862
|
-
s.prepend(`import.meta
|
|
889
|
+
s.prepend(`Object.defineProperty(import.meta, 'vitest', { get() { return typeof __vitest_worker__ !== 'undefined' && __vitest_worker__.filepath === "${filename.replace(/"/g, "\\\"")}" ? __vitest_index__ : undefined } });\n`);
|
|
863
890
|
return {
|
|
864
891
|
code: s.toString(),
|
|
865
892
|
map: s.generateMap({ hires: true })
|
|
@@ -890,10 +917,6 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
890
917
|
if (!projectBrowser) {
|
|
891
918
|
return;
|
|
892
919
|
}
|
|
893
|
-
if (!parentServer.testerScripts) {
|
|
894
|
-
const testerScripts = await parentServer.formatScripts(parentServer.config.browser.testerScripts);
|
|
895
|
-
parentServer.testerScripts = testerScripts;
|
|
896
|
-
}
|
|
897
920
|
const stateJs = typeof parentServer.stateJs === "string" ? parentServer.stateJs : await parentServer.stateJs;
|
|
898
921
|
const testerTags = [];
|
|
899
922
|
const isDefaultTemplate = resolve(distRoot, "client/tester/tester.html") === projectBrowser.testerFilepath;
|
|
@@ -976,7 +999,6 @@ body {
|
|
|
976
999
|
},
|
|
977
1000
|
injectTo: "head"
|
|
978
1001
|
} : null,
|
|
979
|
-
...parentServer.testerScripts,
|
|
980
1002
|
...testerTags
|
|
981
1003
|
].filter((s) => s != null);
|
|
982
1004
|
}
|
|
@@ -1005,7 +1027,7 @@ body {
|
|
|
1005
1027
|
});
|
|
1006
1028
|
}
|
|
1007
1029
|
};
|
|
1008
|
-
return { optimizeDeps:
|
|
1030
|
+
return { optimizeDeps: rolldownVersion ? { rollupOptions: { plugins: [rolldownPlugin] } } : { esbuildOptions: { plugins: [esbuildPlugin] } } };
|
|
1009
1031
|
}
|
|
1010
1032
|
}
|
|
1011
1033
|
];
|
|
@@ -1027,7 +1049,8 @@ function getRequire() {
|
|
|
1027
1049
|
}
|
|
1028
1050
|
function resolveCoverageFolder(vitest) {
|
|
1029
1051
|
const options = vitest.config;
|
|
1030
|
-
const
|
|
1052
|
+
const coverageOptions = vitest._coverageOptions;
|
|
1053
|
+
const htmlReporter = coverageOptions?.enabled ? toArray(options.coverage.reporter).find((reporter) => {
|
|
1031
1054
|
if (typeof reporter === "string") {
|
|
1032
1055
|
return reporter === "html";
|
|
1033
1056
|
}
|
|
@@ -1037,7 +1060,7 @@ function resolveCoverageFolder(vitest) {
|
|
|
1037
1060
|
return undefined;
|
|
1038
1061
|
}
|
|
1039
1062
|
// reportsDirectory not resolved yet
|
|
1040
|
-
const root = resolve(options.root || process.cwd(),
|
|
1063
|
+
const root = resolve(options.root || process.cwd(), coverageOptions.reportsDirectory || coverageConfigDefaults.reportsDirectory);
|
|
1041
1064
|
const subdir = Array.isArray(htmlReporter) && htmlReporter.length > 1 && "subdir" in htmlReporter[1] ? htmlReporter[1].subdir : undefined;
|
|
1042
1065
|
if (!subdir || typeof subdir !== "string") {
|
|
1043
1066
|
return [root, `/${basename(root)}/`];
|
|
@@ -1650,7 +1673,7 @@ _Mime_extensionToType = new WeakMap(), _Mime_typeToExtension = new WeakMap(), _M
|
|
|
1650
1673
|
var mime = new Mime(types)._freeze();
|
|
1651
1674
|
|
|
1652
1675
|
function assertFileAccess(path, project) {
|
|
1653
|
-
if (!isFileServingAllowed(path, project.vite) && !isFileServingAllowed(path, project.vitest.
|
|
1676
|
+
if (!isFileServingAllowed(path, project.vite) && !isFileServingAllowed(path, project.vitest.vite)) {
|
|
1654
1677
|
throw new Error(`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`);
|
|
1655
1678
|
}
|
|
1656
1679
|
}
|
|
@@ -2353,44 +2376,73 @@ function selectAll() {
|
|
|
2353
2376
|
}
|
|
2354
2377
|
|
|
2355
2378
|
const screenshot = async (context, name, options = {}) => {
|
|
2356
|
-
if (!context.testPath) {
|
|
2357
|
-
throw new Error(`Cannot take a screenshot without a test path`);
|
|
2358
|
-
}
|
|
2359
2379
|
options.save ??= true;
|
|
2360
2380
|
if (!options.save) {
|
|
2361
2381
|
options.base64 = true;
|
|
2362
2382
|
}
|
|
2363
|
-
const
|
|
2383
|
+
const { buffer, path } = await takeScreenshot(context, name, options);
|
|
2384
|
+
return returnResult(options, path, buffer);
|
|
2385
|
+
};
|
|
2386
|
+
/**
|
|
2387
|
+
* Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path.
|
|
2388
|
+
*
|
|
2389
|
+
* **Note**: the returned `path` indicates where the screenshot *might* be found.
|
|
2390
|
+
* It is not guaranteed to exist, especially if `options.save` is `false`.
|
|
2391
|
+
*
|
|
2392
|
+
* @throws {Error} If the function is not called within a test or if the browser provider does not support screenshots.
|
|
2393
|
+
*/
|
|
2394
|
+
async function takeScreenshot(context, name, options) {
|
|
2395
|
+
if (!context.testPath) {
|
|
2396
|
+
throw new Error(`Cannot take a screenshot without a test path`);
|
|
2397
|
+
}
|
|
2398
|
+
const path = resolveScreenshotPath(context.testPath, name, context.project.config, options.path);
|
|
2364
2399
|
const savePath = normalize$1(path);
|
|
2365
2400
|
await mkdir(dirname(path), { recursive: true });
|
|
2366
2401
|
if (context.provider instanceof PlaywrightBrowserProvider) {
|
|
2402
|
+
const mask = options.mask?.map((selector) => context.iframe.locator(selector));
|
|
2367
2403
|
if (options.element) {
|
|
2368
2404
|
const { element: selector,...config } = options;
|
|
2369
|
-
const element = context.iframe.locator(
|
|
2405
|
+
const element = context.iframe.locator(selector);
|
|
2370
2406
|
const buffer = await element.screenshot({
|
|
2371
2407
|
...config,
|
|
2408
|
+
mask,
|
|
2372
2409
|
path: options.save ? savePath : undefined
|
|
2373
2410
|
});
|
|
2374
|
-
return
|
|
2411
|
+
return {
|
|
2412
|
+
buffer,
|
|
2413
|
+
path
|
|
2414
|
+
};
|
|
2375
2415
|
}
|
|
2376
2416
|
const buffer = await context.iframe.locator("body").screenshot({
|
|
2377
2417
|
...options,
|
|
2418
|
+
mask,
|
|
2378
2419
|
path: options.save ? savePath : undefined
|
|
2379
2420
|
});
|
|
2380
|
-
return
|
|
2421
|
+
return {
|
|
2422
|
+
buffer,
|
|
2423
|
+
path
|
|
2424
|
+
};
|
|
2381
2425
|
}
|
|
2382
2426
|
if (context.provider instanceof WebdriverBrowserProvider) {
|
|
2383
2427
|
const page = context.provider.browser;
|
|
2384
2428
|
const element = !options.element ? await page.$("body") : await page.$(`${options.element}`);
|
|
2385
|
-
|
|
2429
|
+
// webdriverio expects the path to contain the extension and only works with PNG files
|
|
2430
|
+
const savePathWithExtension = savePath.endsWith(".png") ? savePath : `${savePath}.png`;
|
|
2431
|
+
const buffer = await element.saveScreenshot(savePathWithExtension);
|
|
2386
2432
|
if (!options.save) {
|
|
2387
|
-
await rm(
|
|
2433
|
+
await rm(savePathWithExtension, { force: true });
|
|
2388
2434
|
}
|
|
2389
|
-
return
|
|
2435
|
+
return {
|
|
2436
|
+
buffer,
|
|
2437
|
+
path
|
|
2438
|
+
};
|
|
2390
2439
|
}
|
|
2391
2440
|
throw new Error(`Provider "${context.provider.name}" does not support screenshots`);
|
|
2392
|
-
}
|
|
2393
|
-
function resolveScreenshotPath(testPath, name, config) {
|
|
2441
|
+
}
|
|
2442
|
+
function resolveScreenshotPath(testPath, name, config, customPath) {
|
|
2443
|
+
if (customPath) {
|
|
2444
|
+
return resolve(dirname(testPath), customPath);
|
|
2445
|
+
}
|
|
2394
2446
|
const dir = dirname(testPath);
|
|
2395
2447
|
const base = basename(testPath);
|
|
2396
2448
|
if (config.browser.screenshotDirectory) {
|
|
@@ -2411,6 +2463,401 @@ function returnResult(options, path, buffer) {
|
|
|
2411
2463
|
return path;
|
|
2412
2464
|
}
|
|
2413
2465
|
|
|
2466
|
+
const codec = {
|
|
2467
|
+
decode: (buffer, options) => {
|
|
2468
|
+
const { data, alpha, bpp, color, colorType, depth, height, interlace, palette, width } = PNG.sync.read(Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), options);
|
|
2469
|
+
return {
|
|
2470
|
+
metadata: {
|
|
2471
|
+
alpha,
|
|
2472
|
+
bpp,
|
|
2473
|
+
color,
|
|
2474
|
+
colorType,
|
|
2475
|
+
depth,
|
|
2476
|
+
height,
|
|
2477
|
+
interlace,
|
|
2478
|
+
palette,
|
|
2479
|
+
width
|
|
2480
|
+
},
|
|
2481
|
+
data
|
|
2482
|
+
};
|
|
2483
|
+
},
|
|
2484
|
+
encode: ({ data, metadata: { height, width } }, options) => {
|
|
2485
|
+
const png = new PNG({
|
|
2486
|
+
height,
|
|
2487
|
+
width
|
|
2488
|
+
});
|
|
2489
|
+
png.data = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
2490
|
+
return PNG.sync.write(png, options);
|
|
2491
|
+
}
|
|
2492
|
+
};
|
|
2493
|
+
|
|
2494
|
+
function getCodec(type) {
|
|
2495
|
+
switch (type) {
|
|
2496
|
+
case "png": return codec;
|
|
2497
|
+
default: throw new Error(`No codec found for type ${type}`);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
const defaultOptions$1 = {
|
|
2502
|
+
allowedMismatchedPixelRatio: undefined,
|
|
2503
|
+
allowedMismatchedPixels: undefined,
|
|
2504
|
+
threshold: .1,
|
|
2505
|
+
includeAA: false,
|
|
2506
|
+
alpha: .1,
|
|
2507
|
+
aaColor: [
|
|
2508
|
+
255,
|
|
2509
|
+
255,
|
|
2510
|
+
0
|
|
2511
|
+
],
|
|
2512
|
+
diffColor: [
|
|
2513
|
+
255,
|
|
2514
|
+
0,
|
|
2515
|
+
0
|
|
2516
|
+
],
|
|
2517
|
+
diffColorAlt: undefined,
|
|
2518
|
+
diffMask: false
|
|
2519
|
+
};
|
|
2520
|
+
const pixelmatch = (reference, actual, { createDiff,...options }) => {
|
|
2521
|
+
if (reference.metadata.height !== actual.metadata.height || reference.metadata.width !== actual.metadata.width) {
|
|
2522
|
+
return {
|
|
2523
|
+
pass: false,
|
|
2524
|
+
diff: null,
|
|
2525
|
+
message: `Expected image dimensions to be ${reference.metadata.width}×${reference.metadata.height}px, but received ${actual.metadata.width}×${actual.metadata.height}px.`
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
const optionsWithDefaults = {
|
|
2529
|
+
...defaultOptions$1,
|
|
2530
|
+
...options
|
|
2531
|
+
};
|
|
2532
|
+
const diffBuffer = createDiff ? new Uint8Array(reference.data.length) : undefined;
|
|
2533
|
+
const mismatchedPixels = pm(reference.data, actual.data, diffBuffer, reference.metadata.width, reference.metadata.height, optionsWithDefaults);
|
|
2534
|
+
const imageArea = reference.metadata.width * reference.metadata.height;
|
|
2535
|
+
let allowedMismatchedPixels = Math.min(optionsWithDefaults.allowedMismatchedPixels ?? Number.POSITIVE_INFINITY, (optionsWithDefaults.allowedMismatchedPixelRatio ?? Number.POSITIVE_INFINITY) * imageArea);
|
|
2536
|
+
if (allowedMismatchedPixels === Number.POSITIVE_INFINITY) {
|
|
2537
|
+
allowedMismatchedPixels = 0;
|
|
2538
|
+
}
|
|
2539
|
+
const pass = mismatchedPixels <= allowedMismatchedPixels;
|
|
2540
|
+
return {
|
|
2541
|
+
pass,
|
|
2542
|
+
diff: diffBuffer ?? null,
|
|
2543
|
+
message: pass ? null : `${mismatchedPixels} pixels (ratio ${(Math.ceil(mismatchedPixels / imageArea * 100) / 100).toFixed(2)}) differ.`
|
|
2544
|
+
};
|
|
2545
|
+
};
|
|
2546
|
+
|
|
2547
|
+
const comparators = new Map(Object.entries({ pixelmatch }));
|
|
2548
|
+
function getComparator(comparator) {
|
|
2549
|
+
if (comparators.has(comparator)) {
|
|
2550
|
+
return comparators.get(comparator);
|
|
2551
|
+
}
|
|
2552
|
+
throw new Error(`Unrecognized comparator ${comparator}`);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
const defaultOptions = {
|
|
2556
|
+
comparatorName: "pixelmatch",
|
|
2557
|
+
comparatorOptions: {},
|
|
2558
|
+
screenshotOptions: {
|
|
2559
|
+
animations: "disabled",
|
|
2560
|
+
caret: "hide",
|
|
2561
|
+
fullPage: false,
|
|
2562
|
+
maskColor: "#ff00ff",
|
|
2563
|
+
omitBackground: false,
|
|
2564
|
+
scale: "device"
|
|
2565
|
+
},
|
|
2566
|
+
timeout: 5e3,
|
|
2567
|
+
resolveDiffPath: ({ arg, ext, root, attachmentsDir, browserName, platform, testFileDirectory, testFileName }) => resolve(root, attachmentsDir, testFileDirectory, testFileName, `${arg}-${browserName}-${platform}${ext}`),
|
|
2568
|
+
resolveScreenshotPath: ({ arg, ext, root, screenshotDirectory, testFileDirectory, testFileName, browserName }) => resolve(root, testFileDirectory, screenshotDirectory, testFileName, `${arg}-${browserName}-${platform}${ext}`)
|
|
2569
|
+
};
|
|
2570
|
+
const supportedExtensions = ["png"];
|
|
2571
|
+
function resolveOptions({ context, name, options, testName }) {
|
|
2572
|
+
if (context.testPath === undefined) {
|
|
2573
|
+
throw new Error("`resolveOptions` has to be used in a test file");
|
|
2574
|
+
}
|
|
2575
|
+
const resolvedOptions = deepMerge(Object.create(null), defaultOptions, context.project.config.browser.expect?.toMatchScreenshot ?? {}, options);
|
|
2576
|
+
const extensionFromName = extname(name);
|
|
2577
|
+
// technically the type is a lie, but we check beneath and reassign otherwise
|
|
2578
|
+
let extension = extensionFromName.replace(/^\./, "");
|
|
2579
|
+
// when `type` will be supported in `screenshotOptions`:
|
|
2580
|
+
// - `'png'` should end up in `defaultOptions.screenshotOptions.type`
|
|
2581
|
+
// - this condition should be switched around
|
|
2582
|
+
// - the assignment should be `resolvedOptions.screenshotOptions.type = extension`
|
|
2583
|
+
// - everything using `extension` should use `resolvedOptions.screenshotOptions.type`
|
|
2584
|
+
if (supportedExtensions.includes(extension) === false) {
|
|
2585
|
+
extension = "png";
|
|
2586
|
+
}
|
|
2587
|
+
const { root } = context.project.serializedConfig;
|
|
2588
|
+
const resolvePathData = {
|
|
2589
|
+
arg: sanitizeArg(
|
|
2590
|
+
// remove the extension only if it ends up being used
|
|
2591
|
+
extensionFromName.endsWith(extension) ? basename(name, extensionFromName) : name
|
|
2592
|
+
),
|
|
2593
|
+
ext: `.${extension}`,
|
|
2594
|
+
platform: platform(),
|
|
2595
|
+
root,
|
|
2596
|
+
screenshotDirectory: relative(root, join(root, context.project.config.browser.screenshotDirectory ?? "__screenshots__")),
|
|
2597
|
+
attachmentsDir: relative(root, context.project.config.attachmentsDir),
|
|
2598
|
+
testFileDirectory: relative(root, dirname(context.testPath)),
|
|
2599
|
+
testFileName: basename(context.testPath),
|
|
2600
|
+
testName: sanitize(testName, false),
|
|
2601
|
+
browserName: context.project.config.browser.name
|
|
2602
|
+
};
|
|
2603
|
+
return {
|
|
2604
|
+
codec: getCodec(extension),
|
|
2605
|
+
comparator: getComparator(resolvedOptions.comparatorName),
|
|
2606
|
+
resolvedOptions,
|
|
2607
|
+
paths: {
|
|
2608
|
+
reference: resolvedOptions.resolveScreenshotPath(resolvePathData),
|
|
2609
|
+
get diffs() {
|
|
2610
|
+
const diffs = {
|
|
2611
|
+
reference: resolvedOptions.resolveDiffPath({
|
|
2612
|
+
...resolvePathData,
|
|
2613
|
+
arg: `${resolvePathData.arg}-reference`
|
|
2614
|
+
}),
|
|
2615
|
+
actual: resolvedOptions.resolveDiffPath({
|
|
2616
|
+
...resolvePathData,
|
|
2617
|
+
arg: `${resolvePathData.arg}-actual`
|
|
2618
|
+
}),
|
|
2619
|
+
diff: resolvedOptions.resolveDiffPath({
|
|
2620
|
+
...resolvePathData,
|
|
2621
|
+
arg: `${resolvePathData.arg}-diff`
|
|
2622
|
+
})
|
|
2623
|
+
};
|
|
2624
|
+
Object.defineProperty(this, "diffs", { value: diffs });
|
|
2625
|
+
return diffs;
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Sanitizes a string by removing or transforming characters to ensure it is
|
|
2632
|
+
* safe for use as a filename or path segment. It supports two modes:
|
|
2633
|
+
*
|
|
2634
|
+
* 1. Non-path mode (`keepPaths === false`):
|
|
2635
|
+
* - Replaces one or more whitespace characters (`\s+`) with a single hyphen (`-`).
|
|
2636
|
+
* - Removes any character that is not a word character (`\w`) or a hyphen (`-`).
|
|
2637
|
+
* - Collapses multiple consecutive hyphens (`-{2,}`) into a single hyphen.
|
|
2638
|
+
*
|
|
2639
|
+
* 2. Path-preserving mode (`keepPaths === true`):
|
|
2640
|
+
* - Splits the input string on the path separator.
|
|
2641
|
+
* - Sanitizes each path segment individually in non-path mode.
|
|
2642
|
+
* - Joins the sanitized segments back together.
|
|
2643
|
+
*
|
|
2644
|
+
* @param input - The raw string to sanitize.
|
|
2645
|
+
* @param keepPaths - If `false`, performs a flat sanitization (drops path segments).
|
|
2646
|
+
* If `true`, treats `input` as a path: each segment is sanitized independently,
|
|
2647
|
+
* preserving separators.
|
|
2648
|
+
*/
|
|
2649
|
+
function sanitize(input, keepPaths) {
|
|
2650
|
+
if (keepPaths === false) {
|
|
2651
|
+
return input.replace(/\s+/g, "-").replace(/[^\w-]+/g, "").replace(/-{2,}/g, "-");
|
|
2652
|
+
}
|
|
2653
|
+
return input.split("/").map((path) => sanitize(path, false)).join("/");
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* Takes a string, treats it as a potential path or filename, and ensures it cannot
|
|
2657
|
+
* escape the root directory or contain invalid characters. Internally, it:
|
|
2658
|
+
*
|
|
2659
|
+
* 1. Prepends the path separator to the raw input to form a path-like string.
|
|
2660
|
+
* 2. Uses {@linkcode relative|relative('/', <that-path>)} to compute a relative
|
|
2661
|
+
* path from the root, which effectively strips any leading separators and prevents
|
|
2662
|
+
* traversal above the root.
|
|
2663
|
+
* 3. Passes the resulting relative path into {@linkcode sanitize|sanitize(..., true)},
|
|
2664
|
+
* preserving any path separators but sanitizing each segment.
|
|
2665
|
+
*
|
|
2666
|
+
* @param input - The raw string to clean.
|
|
2667
|
+
*/
|
|
2668
|
+
function sanitizeArg(input) {
|
|
2669
|
+
return sanitize(relative("/", join("/", input)), true);
|
|
2670
|
+
}
|
|
2671
|
+
/**
|
|
2672
|
+
* Takes a screenshot and decodes it using the provided codec.
|
|
2673
|
+
*
|
|
2674
|
+
* The screenshot is taken as a base64 string and then decoded into the format
|
|
2675
|
+
* expected by the comparator.
|
|
2676
|
+
*
|
|
2677
|
+
* @returns `Promise` resolving to the decoded screenshot data
|
|
2678
|
+
*/
|
|
2679
|
+
function takeDecodedScreenshot({ codec, context, element, name, screenshotOptions }) {
|
|
2680
|
+
return takeScreenshot(context, name, {
|
|
2681
|
+
...screenshotOptions,
|
|
2682
|
+
save: false,
|
|
2683
|
+
element
|
|
2684
|
+
}).then(({ buffer }) => codec.decode(buffer, {}));
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* Creates a promise that resolves to `null` after the specified timeout.
|
|
2688
|
+
* If the timeout is `0`, the promise resolves immediately.
|
|
2689
|
+
*
|
|
2690
|
+
* @param timeout - The delay in milliseconds before the promise resolves
|
|
2691
|
+
* @returns `Promise` that resolves to `null` after the timeout
|
|
2692
|
+
*/
|
|
2693
|
+
function asyncTimeout(timeout) {
|
|
2694
|
+
return new Promise((resolve) => {
|
|
2695
|
+
if (timeout === 0) {
|
|
2696
|
+
resolve(null);
|
|
2697
|
+
} else {
|
|
2698
|
+
setTimeout(() => resolve(null), timeout);
|
|
2699
|
+
}
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
const screenshotMatcher = async (context, name, testName, options) => {
|
|
2704
|
+
if (!context.testPath) {
|
|
2705
|
+
throw new Error(`Cannot compare screenshots without a test path`);
|
|
2706
|
+
}
|
|
2707
|
+
const { element } = options;
|
|
2708
|
+
const { codec, comparator, paths, resolvedOptions: { comparatorOptions, screenshotOptions, timeout } } = resolveOptions({
|
|
2709
|
+
context,
|
|
2710
|
+
name,
|
|
2711
|
+
testName,
|
|
2712
|
+
options
|
|
2713
|
+
});
|
|
2714
|
+
const referenceFile = await readFile$1(paths.reference).catch(() => null);
|
|
2715
|
+
const reference = referenceFile && await codec.decode(await readFile$1(paths.reference), {});
|
|
2716
|
+
const abortController = new AbortController();
|
|
2717
|
+
const stableScreenshot = getStableScreenshots({
|
|
2718
|
+
codec,
|
|
2719
|
+
comparator,
|
|
2720
|
+
comparatorOptions,
|
|
2721
|
+
context,
|
|
2722
|
+
element,
|
|
2723
|
+
name: `${Date.now()}-${basename(paths.reference)}`,
|
|
2724
|
+
reference,
|
|
2725
|
+
screenshotOptions,
|
|
2726
|
+
signal: abortController.signal
|
|
2727
|
+
});
|
|
2728
|
+
const value = await (timeout === 0 ? stableScreenshot : Promise.race([stableScreenshot, asyncTimeout(timeout).finally(() => {
|
|
2729
|
+
abortController.abort();
|
|
2730
|
+
})]));
|
|
2731
|
+
// case #01
|
|
2732
|
+
// - impossible to get a stable screenshot to compare against
|
|
2733
|
+
// - fail
|
|
2734
|
+
if (value === null || value.actual === null) {
|
|
2735
|
+
return {
|
|
2736
|
+
pass: false,
|
|
2737
|
+
reference: referenceFile && paths.reference,
|
|
2738
|
+
actual: null,
|
|
2739
|
+
diff: null,
|
|
2740
|
+
message: `Could not capture a stable screenshot within ${timeout}ms.`
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
const { updateSnapshot } = context.project.serializedConfig.snapshotOptions;
|
|
2744
|
+
// if there's no reference or if we want to update snapshots, we have to finish the comparison early
|
|
2745
|
+
if (reference === null || updateSnapshot === "all") {
|
|
2746
|
+
const shouldCreateReference = updateSnapshot !== "none";
|
|
2747
|
+
const referencePath = shouldCreateReference ? paths.reference : paths.diffs.reference;
|
|
2748
|
+
await writeScreenshot(referencePath, await codec.encode(value.actual, {}));
|
|
2749
|
+
// case #02
|
|
2750
|
+
// - got a stable screenshot, but there is no reference and we don't want to update screenshots
|
|
2751
|
+
// - fail
|
|
2752
|
+
if (updateSnapshot !== "all") {
|
|
2753
|
+
return {
|
|
2754
|
+
pass: false,
|
|
2755
|
+
reference: referencePath,
|
|
2756
|
+
actual: null,
|
|
2757
|
+
diff: null,
|
|
2758
|
+
message: `No existing reference screenshot found${shouldCreateReference ? "; a new one was created. Review it before running tests again." : "."}`
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
// case #03
|
|
2762
|
+
// - got a stable screenshot, there is no reference, but we want to update screenshots
|
|
2763
|
+
// - pass
|
|
2764
|
+
return { pass: true };
|
|
2765
|
+
}
|
|
2766
|
+
// case #04
|
|
2767
|
+
// - got a stable screenshot with no retries and there's a reference
|
|
2768
|
+
// - pass
|
|
2769
|
+
if (referenceFile && value.retries === 0) {
|
|
2770
|
+
return { pass: true };
|
|
2771
|
+
}
|
|
2772
|
+
const finalResult = await comparator(reference, value.actual, {
|
|
2773
|
+
createDiff: true,
|
|
2774
|
+
...comparatorOptions
|
|
2775
|
+
});
|
|
2776
|
+
if (finalResult.pass === false && finalResult.diff !== null) {
|
|
2777
|
+
const diff = await codec.encode({
|
|
2778
|
+
data: finalResult.diff,
|
|
2779
|
+
metadata: {
|
|
2780
|
+
height: reference.metadata.height,
|
|
2781
|
+
width: reference.metadata.width
|
|
2782
|
+
}
|
|
2783
|
+
}, {});
|
|
2784
|
+
await writeScreenshot(paths.diffs.diff, diff);
|
|
2785
|
+
}
|
|
2786
|
+
// case #05
|
|
2787
|
+
// - reference matches stable screenshot
|
|
2788
|
+
// - pass
|
|
2789
|
+
if (finalResult.pass === true) {
|
|
2790
|
+
return { pass: true };
|
|
2791
|
+
}
|
|
2792
|
+
const actual = await codec.encode(value.actual, {});
|
|
2793
|
+
await writeScreenshot(paths.diffs.actual, actual);
|
|
2794
|
+
// case #06
|
|
2795
|
+
// - fallback, reference does NOT match stable screenshot
|
|
2796
|
+
// - fail
|
|
2797
|
+
return {
|
|
2798
|
+
pass: false,
|
|
2799
|
+
reference: paths.reference,
|
|
2800
|
+
actual: paths.diffs.actual,
|
|
2801
|
+
diff: finalResult.diff && paths.diffs.diff,
|
|
2802
|
+
message: `Screenshot does not match the stored reference.${finalResult.message === null ? "" : `\n${finalResult.message}`}`
|
|
2803
|
+
};
|
|
2804
|
+
};
|
|
2805
|
+
async function writeScreenshot(path, image) {
|
|
2806
|
+
try {
|
|
2807
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2808
|
+
await writeFile$1(path, image);
|
|
2809
|
+
} catch {
|
|
2810
|
+
throw new Error("Couldn't write file to fs");
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Takes screenshots repeatedly until the page reaches a visually stable state.
|
|
2815
|
+
*
|
|
2816
|
+
* This function compares consecutive screenshots and continues taking new ones
|
|
2817
|
+
* until two consecutive screenshots match according to the provided comparator.
|
|
2818
|
+
*
|
|
2819
|
+
* The process works as follows:
|
|
2820
|
+
*
|
|
2821
|
+
* 1. Uses as baseline an optional reference screenshot or takes a new screenshot
|
|
2822
|
+
* 2. Takes a screenshot and compares with baseline
|
|
2823
|
+
* 3. If they match, the page is considered stable and the function returns
|
|
2824
|
+
* 4. If they don't match, it continues with the newer screenshot as the baseline
|
|
2825
|
+
* 5. Repeats until stability is achieved or the operation is aborted
|
|
2826
|
+
*
|
|
2827
|
+
* @returns `Promise` resolving to an object containing the retry count and
|
|
2828
|
+
* final screenshot
|
|
2829
|
+
*/
|
|
2830
|
+
async function getStableScreenshots({ codec, context, comparator, comparatorOptions, element, name, reference, screenshotOptions, signal }) {
|
|
2831
|
+
const screenshotArgument = {
|
|
2832
|
+
codec,
|
|
2833
|
+
context,
|
|
2834
|
+
element,
|
|
2835
|
+
name,
|
|
2836
|
+
screenshotOptions
|
|
2837
|
+
};
|
|
2838
|
+
let retries = 0;
|
|
2839
|
+
let decodedBaseline = reference;
|
|
2840
|
+
while (signal.aborted === false) {
|
|
2841
|
+
if (decodedBaseline === null) {
|
|
2842
|
+
decodedBaseline = takeDecodedScreenshot(screenshotArgument);
|
|
2843
|
+
}
|
|
2844
|
+
const [image1, image2] = await Promise.all([decodedBaseline, takeDecodedScreenshot(screenshotArgument)]);
|
|
2845
|
+
const comparatorResult = (await comparator(image1, image2, {
|
|
2846
|
+
...comparatorOptions,
|
|
2847
|
+
createDiff: false
|
|
2848
|
+
})).pass;
|
|
2849
|
+
decodedBaseline = image2;
|
|
2850
|
+
if (comparatorResult) {
|
|
2851
|
+
break;
|
|
2852
|
+
}
|
|
2853
|
+
retries += 1;
|
|
2854
|
+
}
|
|
2855
|
+
return {
|
|
2856
|
+
retries,
|
|
2857
|
+
actual: await decodedBaseline
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2414
2861
|
const selectOptions = async (context, selector, userValues, options = {}) => {
|
|
2415
2862
|
if (context.provider instanceof PlaywrightBrowserProvider) {
|
|
2416
2863
|
const value = userValues;
|
|
@@ -2551,7 +2998,8 @@ var builtinCommands = {
|
|
|
2551
2998
|
__vitest_dragAndDrop: dragAndDrop,
|
|
2552
2999
|
__vitest_hover: hover,
|
|
2553
3000
|
__vitest_cleanup: keyboardCleanup,
|
|
2554
|
-
__vitest_viewport: viewport
|
|
3001
|
+
__vitest_viewport: viewport,
|
|
3002
|
+
__vitest_screenshotMatcher: screenshotMatcher
|
|
2555
3003
|
};
|
|
2556
3004
|
|
|
2557
3005
|
class BrowserServerState {
|
|
@@ -2595,22 +3043,7 @@ class ProjectBrowser {
|
|
|
2595
3043
|
if (this.provider) {
|
|
2596
3044
|
return;
|
|
2597
3045
|
}
|
|
2598
|
-
|
|
2599
|
-
this.provider = new Provider();
|
|
2600
|
-
const browser = project.config.browser.name;
|
|
2601
|
-
const name = project.name ? `[${project.name}] ` : "";
|
|
2602
|
-
if (!browser) {
|
|
2603
|
-
throw new Error(`${name}Browser name is required. Please, set \`test.browser.instances[].browser\` option manually.`);
|
|
2604
|
-
}
|
|
2605
|
-
const supportedBrowsers = this.provider.getSupportedBrowsers();
|
|
2606
|
-
if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) {
|
|
2607
|
-
throw new Error(`${name}Browser "${browser}" is not supported by the browser provider "${this.provider.name}". Supported browsers: ${supportedBrowsers.join(", ")}.`);
|
|
2608
|
-
}
|
|
2609
|
-
const providerOptions = project.config.browser.providerOptions;
|
|
2610
|
-
await this.provider.initialize(project, {
|
|
2611
|
-
browser,
|
|
2612
|
-
options: providerOptions
|
|
2613
|
-
});
|
|
3046
|
+
this.provider = await getBrowserProvider(project.config.browser, project);
|
|
2614
3047
|
}
|
|
2615
3048
|
parseErrorStacktrace(e, options = {}) {
|
|
2616
3049
|
return this.parent.parseErrorStacktrace(e, options);
|
|
@@ -2631,7 +3064,6 @@ function wrapConfig(config) {
|
|
|
2631
3064
|
|
|
2632
3065
|
class ParentBrowserProject {
|
|
2633
3066
|
orchestratorScripts;
|
|
2634
|
-
testerScripts;
|
|
2635
3067
|
faviconUrl;
|
|
2636
3068
|
prefixOrchestratorUrl;
|
|
2637
3069
|
prefixTesterUrl;
|
|
@@ -2677,18 +3109,19 @@ class ParentBrowserProject {
|
|
|
2677
3109
|
return result?.map;
|
|
2678
3110
|
},
|
|
2679
3111
|
getUrlId: (id) => {
|
|
2680
|
-
const
|
|
3112
|
+
const moduleGraph = this.vite.environments.client.moduleGraph;
|
|
3113
|
+
const mod = moduleGraph.getModuleById(id);
|
|
2681
3114
|
if (mod) {
|
|
2682
3115
|
return id;
|
|
2683
3116
|
}
|
|
2684
3117
|
const resolvedPath = resolve(this.vite.config.root, id.slice(1));
|
|
2685
|
-
const modUrl =
|
|
3118
|
+
const modUrl = moduleGraph.getModuleById(resolvedPath);
|
|
2686
3119
|
if (modUrl) {
|
|
2687
3120
|
return resolvedPath;
|
|
2688
3121
|
}
|
|
2689
3122
|
// some browsers (looking at you, safari) don't report queries in stack traces
|
|
2690
3123
|
// the next best thing is to try the first id that this file resolves to
|
|
2691
|
-
const files =
|
|
3124
|
+
const files = moduleGraph.getModulesByFile(resolvedPath);
|
|
2692
3125
|
if (files && files.size) {
|
|
2693
3126
|
return files.values().next().value.id;
|
|
2694
3127
|
}
|
|
@@ -2719,7 +3152,7 @@ class ParentBrowserProject {
|
|
|
2719
3152
|
"webdriverio",
|
|
2720
3153
|
"preview"
|
|
2721
3154
|
];
|
|
2722
|
-
const providerName = project.config.browser.provider || "preview";
|
|
3155
|
+
const providerName = project.config.browser.provider?.name || "preview";
|
|
2723
3156
|
if (builtinProviders.includes(providerName)) {
|
|
2724
3157
|
this.locatorsUrl = join("/@fs/", distRoot, "locators", `${providerName}.js`);
|
|
2725
3158
|
}
|
|
@@ -2841,7 +3274,7 @@ function defaultSerialize(i) {
|
|
|
2841
3274
|
return i;
|
|
2842
3275
|
}
|
|
2843
3276
|
const defaultDeserialize = defaultSerialize;
|
|
2844
|
-
const { clearTimeout, setTimeout } = globalThis;
|
|
3277
|
+
const { clearTimeout, setTimeout: setTimeout$1 } = globalThis;
|
|
2845
3278
|
const random = Math.random.bind(Math);
|
|
2846
3279
|
function createBirpc(functions, options) {
|
|
2847
3280
|
const {
|
|
@@ -2865,10 +3298,13 @@ function createBirpc(functions, options) {
|
|
|
2865
3298
|
return functions;
|
|
2866
3299
|
if (method === "$close")
|
|
2867
3300
|
return close;
|
|
3301
|
+
if (method === "$rejectPendingCalls") {
|
|
3302
|
+
return rejectPendingCalls;
|
|
3303
|
+
}
|
|
2868
3304
|
if (method === "$closed")
|
|
2869
3305
|
return closed;
|
|
2870
3306
|
if (method === "then" && !eventNames.includes("then") && !("then" in functions))
|
|
2871
|
-
return
|
|
3307
|
+
return void 0;
|
|
2872
3308
|
const sendEvent = (...args) => {
|
|
2873
3309
|
post(serialize({ m: method, a: args, t: TYPE_REQUEST }));
|
|
2874
3310
|
};
|
|
@@ -2883,14 +3319,14 @@ function createBirpc(functions, options) {
|
|
|
2883
3319
|
try {
|
|
2884
3320
|
await _promise;
|
|
2885
3321
|
} finally {
|
|
2886
|
-
_promise =
|
|
3322
|
+
_promise = void 0;
|
|
2887
3323
|
}
|
|
2888
3324
|
}
|
|
2889
3325
|
return new Promise((resolve, reject) => {
|
|
2890
3326
|
const id = nanoid();
|
|
2891
3327
|
let timeoutId;
|
|
2892
3328
|
if (timeout >= 0) {
|
|
2893
|
-
timeoutId = setTimeout(() => {
|
|
3329
|
+
timeoutId = setTimeout$1(() => {
|
|
2894
3330
|
try {
|
|
2895
3331
|
const handleResult = options.onTimeoutError?.(method, args);
|
|
2896
3332
|
if (handleResult !== true)
|
|
@@ -2911,14 +3347,30 @@ function createBirpc(functions, options) {
|
|
|
2911
3347
|
return sendCall;
|
|
2912
3348
|
}
|
|
2913
3349
|
});
|
|
2914
|
-
function close(
|
|
3350
|
+
function close(customError) {
|
|
2915
3351
|
closed = true;
|
|
2916
3352
|
rpcPromiseMap.forEach(({ reject, method }) => {
|
|
2917
|
-
|
|
3353
|
+
const error = new Error(`[birpc] rpc is closed, cannot call "${method}"`);
|
|
3354
|
+
if (customError) {
|
|
3355
|
+
customError.cause ??= error;
|
|
3356
|
+
return reject(customError);
|
|
3357
|
+
}
|
|
3358
|
+
reject(error);
|
|
2918
3359
|
});
|
|
2919
3360
|
rpcPromiseMap.clear();
|
|
2920
3361
|
off(onMessage);
|
|
2921
3362
|
}
|
|
3363
|
+
function rejectPendingCalls(handler) {
|
|
3364
|
+
const entries = Array.from(rpcPromiseMap.values());
|
|
3365
|
+
const handlerResults = entries.map(({ method, reject }) => {
|
|
3366
|
+
if (!handler) {
|
|
3367
|
+
return reject(new Error(`[birpc]: rejected pending call "${method}".`));
|
|
3368
|
+
}
|
|
3369
|
+
return handler({ method, reject });
|
|
3370
|
+
});
|
|
3371
|
+
rpcPromiseMap.clear();
|
|
3372
|
+
return handlerResults;
|
|
3373
|
+
}
|
|
2922
3374
|
async function onMessage(data, ...extra) {
|
|
2923
3375
|
let msg;
|
|
2924
3376
|
try {
|
|
@@ -3018,12 +3470,13 @@ function setupBrowserRpc(globalServer, defaultMockerRegistry) {
|
|
|
3018
3470
|
if (!sessionId || !rpcId || projectName == null) {
|
|
3019
3471
|
return error(new Error(`[vitest] Invalid URL ${request.url}. "projectName", "sessionId" and "rpcId" queries are required.`));
|
|
3020
3472
|
}
|
|
3021
|
-
|
|
3022
|
-
|
|
3473
|
+
const sessions = vitest._browserSessions;
|
|
3474
|
+
if (!sessions.sessionIds.has(sessionId)) {
|
|
3475
|
+
const ids = [...sessions.sessionIds].join(", ");
|
|
3023
3476
|
return error(new Error(`[vitest] Unknown session id "${sessionId}". Expected one of ${ids}.`));
|
|
3024
3477
|
}
|
|
3025
3478
|
if (type === "orchestrator") {
|
|
3026
|
-
const session =
|
|
3479
|
+
const session = sessions.getSession(sessionId);
|
|
3027
3480
|
// it's possible the session was already resolved by the preview provider
|
|
3028
3481
|
session?.connected();
|
|
3029
3482
|
}
|
|
@@ -3043,7 +3496,7 @@ function setupBrowserRpc(globalServer, defaultMockerRegistry) {
|
|
|
3043
3496
|
clients.delete(rpcId);
|
|
3044
3497
|
globalServer.removeCDPHandler(rpcId);
|
|
3045
3498
|
if (type === "orchestrator") {
|
|
3046
|
-
|
|
3499
|
+
sessions.destroySession(sessionId);
|
|
3047
3500
|
}
|
|
3048
3501
|
// this will reject any hanging methods if there are any
|
|
3049
3502
|
rpc.$close(new Error(`[vitest] Browser connection was closed while running tests. Was the page closed unexpectedly?`));
|
|
@@ -3061,7 +3514,7 @@ function setupBrowserRpc(globalServer, defaultMockerRegistry) {
|
|
|
3061
3514
|
}
|
|
3062
3515
|
}
|
|
3063
3516
|
function setupClient(project, rpcId, ws) {
|
|
3064
|
-
const mockResolver = new ServerMockResolver(globalServer.vite, { moduleDirectories: project.config
|
|
3517
|
+
const mockResolver = new ServerMockResolver(globalServer.vite, { moduleDirectories: project.config?.deps?.moduleDirectories });
|
|
3065
3518
|
const mocker = project.browser?.provider.mocker;
|
|
3066
3519
|
const rpc = createBirpc({
|
|
3067
3520
|
async onUnhandledError(error, type) {
|
|
@@ -3252,11 +3705,8 @@ function setupBrowserRpc(globalServer, defaultMockerRegistry) {
|
|
|
3252
3705
|
on: (fn) => ws.on("message", fn),
|
|
3253
3706
|
eventNames: ["onCancel", "cdpEvent"],
|
|
3254
3707
|
serialize: (data) => stringify(data, stringifyReplace),
|
|
3255
|
-
timeout: -1,
|
|
3256
3708
|
deserialize: parse,
|
|
3257
|
-
|
|
3258
|
-
throw new Error(`[vitest-api]: Timeout calling "${functionName}"`);
|
|
3259
|
-
}
|
|
3709
|
+
timeout: -1
|
|
3260
3710
|
});
|
|
3261
3711
|
vitest.onCancel((reason) => rpc.onCancel(reason));
|
|
3262
3712
|
return rpc;
|
|
@@ -3349,14 +3799,15 @@ function createBrowserPool(vitest) {
|
|
|
3349
3799
|
}
|
|
3350
3800
|
const parallelPools = [];
|
|
3351
3801
|
const nonParallelPools = [];
|
|
3352
|
-
for (const
|
|
3353
|
-
if (!
|
|
3802
|
+
for (const pool of initialisedPools) {
|
|
3803
|
+
if (!pool) {
|
|
3804
|
+
// this means it was cancelled
|
|
3354
3805
|
return;
|
|
3355
3806
|
}
|
|
3356
|
-
if (
|
|
3357
|
-
parallelPools.push(
|
|
3807
|
+
if (pool.provider.mocker && pool.provider.supportsParallelism) {
|
|
3808
|
+
parallelPools.push(pool.runTests);
|
|
3358
3809
|
} else {
|
|
3359
|
-
nonParallelPools.push(
|
|
3810
|
+
nonParallelPools.push(pool.runTests);
|
|
3360
3811
|
}
|
|
3361
3812
|
}
|
|
3362
3813
|
await Promise.all(parallelPools.map((runTests) => runTests()));
|
|
@@ -3515,7 +3966,7 @@ class BrowserPool {
|
|
|
3515
3966
|
debug?.("[%s] test %s finished running", sessionId, file);
|
|
3516
3967
|
this.runNextTest(method, sessionId);
|
|
3517
3968
|
}).catch((error) => {
|
|
3518
|
-
// if user
|
|
3969
|
+
// if user cancels the test run manually, ignore the error and exit gracefully
|
|
3519
3970
|
if (this.project.vitest.isCancelling && error instanceof Error && error.message.startsWith("Browser connection was closed while running tests")) {
|
|
3520
3971
|
this.cancel();
|
|
3521
3972
|
this._promise?.resolve();
|