@vitest/browser 4.0.0-beta.3 → 4.0.0-beta.4
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/context.d.ts +123 -0
- package/dist/client/__vitest__/assets/index-YGltZS-e.js +57 -0
- package/dist/client/__vitest__/index.html +1 -1
- package/dist/client.js +24 -8
- package/dist/expect-element.js +3 -2
- package/dist/{index-W1MM53zC.js → index-D_g_FMM5.js} +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +507 -39
- 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.js +1 -1
- package/dist/shared/screenshotMatcher/types.d.ts +13 -0
- package/dist/{webdriver-KA1WiV0q.js → webdriver-AHRa6U3j.js} +21 -11
- package/jest-dom.d.ts +48 -0
- package/package.json +13 -10
- package/providers/playwright.d.ts +13 -0
- package/providers/webdriverio.d.ts +13 -0
- package/dist/client/__vitest__/assets/index-DYFYwZ2-.js +0 -57
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 { distDir, resolveApiServerConfig, resolveFsAllow,
|
|
5
|
-
import fs, { readFileSync, lstatSync, promises, existsSync } from 'node:fs';
|
|
4
|
+
import { isValidApiRequest, isFileServingAllowed, distDir, resolveApiServerConfig, resolveFsAllow, 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';
|
|
8
8
|
import MagicString from 'magic-string';
|
|
9
9
|
import sirv from 'sirv';
|
|
10
10
|
import * as vite from 'vite';
|
|
11
11
|
import { coverageConfigDefaults } from 'vitest/config';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import crypto from 'node:crypto';
|
|
14
|
-
import { mkdir, rm, readFile as readFile$1 } from 'node:fs/promises';
|
|
14
|
+
import { mkdir, rm, readFile as readFile$1, writeFile as writeFile$1 } from 'node:fs/promises';
|
|
15
15
|
import { parseErrorStacktrace, parseStacktrace } from '@vitest/utils/source-map';
|
|
16
|
-
import { P as PlaywrightBrowserProvider, W as WebdriverBrowserProvider } from './webdriver-
|
|
16
|
+
import { P as PlaywrightBrowserProvider, W as WebdriverBrowserProvider } from './webdriver-AHRa6U3j.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.4";
|
|
23
26
|
|
|
24
27
|
const _DRIVE_LETTER_START_RE = /^[A-Za-z]:\//;
|
|
25
28
|
function normalizeWindowsPath(input = "") {
|
|
@@ -671,6 +674,31 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
671
674
|
}
|
|
672
675
|
next();
|
|
673
676
|
});
|
|
677
|
+
// handle attachments the same way as in packages/ui/node/index.ts
|
|
678
|
+
server.middlewares.use((req, res, next) => {
|
|
679
|
+
if (!req.url) {
|
|
680
|
+
return next();
|
|
681
|
+
}
|
|
682
|
+
const url = new URL(req.url, "http://localhost");
|
|
683
|
+
if (url.pathname !== "/__vitest_attachment__") {
|
|
684
|
+
return next();
|
|
685
|
+
}
|
|
686
|
+
const path = url.searchParams.get("path");
|
|
687
|
+
const contentType = url.searchParams.get("contentType");
|
|
688
|
+
if (!isValidApiRequest(parentServer.config, req) || !contentType || !path) {
|
|
689
|
+
return next();
|
|
690
|
+
}
|
|
691
|
+
const fsPath = decodeURIComponent(path);
|
|
692
|
+
if (!isFileServingAllowed(parentServer.vite.config, fsPath)) {
|
|
693
|
+
return next();
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
res.setHeader("content-type", contentType);
|
|
697
|
+
return createReadStream(fsPath).pipe(res).on("close", () => res.end());
|
|
698
|
+
} catch (err) {
|
|
699
|
+
return next(err);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
674
702
|
}
|
|
675
703
|
},
|
|
676
704
|
{
|
|
@@ -834,7 +862,7 @@ var BrowserPlugin = (parentServer, base = "/") => {
|
|
|
834
862
|
viteConfig.esbuild ||= {};
|
|
835
863
|
viteConfig.esbuild.legalComments = "inline";
|
|
836
864
|
}
|
|
837
|
-
const defaultPort = parentServer.vitest.
|
|
865
|
+
const defaultPort = parentServer.vitest.state._data.browserLastPort++;
|
|
838
866
|
const api = resolveApiServerConfig(viteConfig.test?.browser || {}, defaultPort);
|
|
839
867
|
viteConfig.server = {
|
|
840
868
|
...viteConfig.server,
|
|
@@ -2352,14 +2380,26 @@ function selectAll() {
|
|
|
2352
2380
|
}
|
|
2353
2381
|
|
|
2354
2382
|
const screenshot = async (context, name, options = {}) => {
|
|
2355
|
-
if (!context.testPath) {
|
|
2356
|
-
throw new Error(`Cannot take a screenshot without a test path`);
|
|
2357
|
-
}
|
|
2358
2383
|
options.save ??= true;
|
|
2359
2384
|
if (!options.save) {
|
|
2360
2385
|
options.base64 = true;
|
|
2361
2386
|
}
|
|
2362
|
-
const
|
|
2387
|
+
const { buffer, path } = await takeScreenshot(context, name, options);
|
|
2388
|
+
return returnResult(options, path, buffer);
|
|
2389
|
+
};
|
|
2390
|
+
/**
|
|
2391
|
+
* Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path.
|
|
2392
|
+
*
|
|
2393
|
+
* **Note**: the returned `path` indicates where the screenshot *might* be found.
|
|
2394
|
+
* It is not guaranteed to exist, especially if `options.save` is `false`.
|
|
2395
|
+
*
|
|
2396
|
+
* @throws {Error} If the function is not called within a test or if the browser provider does not support screenshots.
|
|
2397
|
+
*/
|
|
2398
|
+
async function takeScreenshot(context, name, options) {
|
|
2399
|
+
if (!context.testPath) {
|
|
2400
|
+
throw new Error(`Cannot take a screenshot without a test path`);
|
|
2401
|
+
}
|
|
2402
|
+
const path = resolveScreenshotPath(context.testPath, name, context.project.config, options.path);
|
|
2363
2403
|
const savePath = normalize$1(path);
|
|
2364
2404
|
await mkdir(dirname(path), { recursive: true });
|
|
2365
2405
|
if (context.provider instanceof PlaywrightBrowserProvider) {
|
|
@@ -2370,26 +2410,40 @@ const screenshot = async (context, name, options = {}) => {
|
|
|
2370
2410
|
...config,
|
|
2371
2411
|
path: options.save ? savePath : undefined
|
|
2372
2412
|
});
|
|
2373
|
-
return
|
|
2413
|
+
return {
|
|
2414
|
+
buffer,
|
|
2415
|
+
path
|
|
2416
|
+
};
|
|
2374
2417
|
}
|
|
2375
2418
|
const buffer = await context.iframe.locator("body").screenshot({
|
|
2376
2419
|
...options,
|
|
2377
2420
|
path: options.save ? savePath : undefined
|
|
2378
2421
|
});
|
|
2379
|
-
return
|
|
2422
|
+
return {
|
|
2423
|
+
buffer,
|
|
2424
|
+
path
|
|
2425
|
+
};
|
|
2380
2426
|
}
|
|
2381
2427
|
if (context.provider instanceof WebdriverBrowserProvider) {
|
|
2382
2428
|
const page = context.provider.browser;
|
|
2383
2429
|
const element = !options.element ? await page.$("body") : await page.$(`${options.element}`);
|
|
2384
|
-
|
|
2430
|
+
// webdriverio expects the path to contain the extension and only works with PNG files
|
|
2431
|
+
const savePathWithExtension = savePath.endsWith(".png") ? savePath : `${savePath}.png`;
|
|
2432
|
+
const buffer = await element.saveScreenshot(savePathWithExtension);
|
|
2385
2433
|
if (!options.save) {
|
|
2386
|
-
await rm(
|
|
2434
|
+
await rm(savePathWithExtension, { force: true });
|
|
2387
2435
|
}
|
|
2388
|
-
return
|
|
2436
|
+
return {
|
|
2437
|
+
buffer,
|
|
2438
|
+
path
|
|
2439
|
+
};
|
|
2389
2440
|
}
|
|
2390
2441
|
throw new Error(`Provider "${context.provider.name}" does not support screenshots`);
|
|
2391
|
-
}
|
|
2392
|
-
function resolveScreenshotPath(testPath, name, config) {
|
|
2442
|
+
}
|
|
2443
|
+
function resolveScreenshotPath(testPath, name, config, customPath) {
|
|
2444
|
+
if (customPath) {
|
|
2445
|
+
return resolve(dirname(testPath), customPath);
|
|
2446
|
+
}
|
|
2393
2447
|
const dir = dirname(testPath);
|
|
2394
2448
|
const base = basename(testPath);
|
|
2395
2449
|
if (config.browser.screenshotDirectory) {
|
|
@@ -2410,6 +2464,401 @@ function returnResult(options, path, buffer) {
|
|
|
2410
2464
|
return path;
|
|
2411
2465
|
}
|
|
2412
2466
|
|
|
2467
|
+
const codec = {
|
|
2468
|
+
decode: (buffer, options) => {
|
|
2469
|
+
const { data, alpha, bpp, color, colorType, depth, height, interlace, palette, width } = PNG.sync.read(Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), options);
|
|
2470
|
+
return {
|
|
2471
|
+
metadata: {
|
|
2472
|
+
alpha,
|
|
2473
|
+
bpp,
|
|
2474
|
+
color,
|
|
2475
|
+
colorType,
|
|
2476
|
+
depth,
|
|
2477
|
+
height,
|
|
2478
|
+
interlace,
|
|
2479
|
+
palette,
|
|
2480
|
+
width
|
|
2481
|
+
},
|
|
2482
|
+
data
|
|
2483
|
+
};
|
|
2484
|
+
},
|
|
2485
|
+
encode: ({ data, metadata: { height, width } }, options) => {
|
|
2486
|
+
const png = new PNG({
|
|
2487
|
+
height,
|
|
2488
|
+
width
|
|
2489
|
+
});
|
|
2490
|
+
png.data = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
2491
|
+
return PNG.sync.write(png, options);
|
|
2492
|
+
}
|
|
2493
|
+
};
|
|
2494
|
+
|
|
2495
|
+
function getCodec(type) {
|
|
2496
|
+
switch (type) {
|
|
2497
|
+
case "png": return codec;
|
|
2498
|
+
default: throw new Error(`No codec found for type ${type}`);
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
const defaultOptions$1 = {
|
|
2503
|
+
allowedMismatchedPixelRatio: undefined,
|
|
2504
|
+
allowedMismatchedPixels: undefined,
|
|
2505
|
+
threshold: .1,
|
|
2506
|
+
includeAA: false,
|
|
2507
|
+
alpha: .1,
|
|
2508
|
+
aaColor: [
|
|
2509
|
+
255,
|
|
2510
|
+
255,
|
|
2511
|
+
0
|
|
2512
|
+
],
|
|
2513
|
+
diffColor: [
|
|
2514
|
+
255,
|
|
2515
|
+
0,
|
|
2516
|
+
0
|
|
2517
|
+
],
|
|
2518
|
+
diffColorAlt: undefined,
|
|
2519
|
+
diffMask: false
|
|
2520
|
+
};
|
|
2521
|
+
const pixelmatch = (reference, actual, { createDiff,...options }) => {
|
|
2522
|
+
if (reference.metadata.height !== actual.metadata.height || reference.metadata.width !== actual.metadata.width) {
|
|
2523
|
+
return {
|
|
2524
|
+
pass: false,
|
|
2525
|
+
diff: null,
|
|
2526
|
+
message: `Expected image dimensions to be ${reference.metadata.width}×${reference.metadata.height}px, but received ${actual.metadata.width}×${actual.metadata.height}px.`
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
const optionsWithDefaults = {
|
|
2530
|
+
...defaultOptions$1,
|
|
2531
|
+
...options
|
|
2532
|
+
};
|
|
2533
|
+
const diffBuffer = createDiff ? new Uint8Array(reference.data.length) : undefined;
|
|
2534
|
+
const mismatchedPixels = pm(reference.data, actual.data, diffBuffer, reference.metadata.width, reference.metadata.height, optionsWithDefaults);
|
|
2535
|
+
const imageArea = reference.metadata.width * reference.metadata.height;
|
|
2536
|
+
let allowedMismatchedPixels = Math.min(optionsWithDefaults.allowedMismatchedPixels ?? Number.POSITIVE_INFINITY, (optionsWithDefaults.allowedMismatchedPixelRatio ?? Number.POSITIVE_INFINITY) * imageArea);
|
|
2537
|
+
if (allowedMismatchedPixels === Number.POSITIVE_INFINITY) {
|
|
2538
|
+
allowedMismatchedPixels = 0;
|
|
2539
|
+
}
|
|
2540
|
+
const pass = mismatchedPixels <= allowedMismatchedPixels;
|
|
2541
|
+
return {
|
|
2542
|
+
pass,
|
|
2543
|
+
diff: diffBuffer ?? null,
|
|
2544
|
+
message: pass ? null : `${mismatchedPixels} pixels (ratio ${(Math.ceil(mismatchedPixels / imageArea * 100) / 100).toFixed(2)}) differ.`
|
|
2545
|
+
};
|
|
2546
|
+
};
|
|
2547
|
+
|
|
2548
|
+
const comparators = new Map(Object.entries({ pixelmatch }));
|
|
2549
|
+
function getComparator(comparator) {
|
|
2550
|
+
if (comparators.has(comparator)) {
|
|
2551
|
+
return comparators.get(comparator);
|
|
2552
|
+
}
|
|
2553
|
+
throw new Error(`Unrecognized comparator ${comparator}`);
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
const defaultOptions = {
|
|
2557
|
+
comparatorName: "pixelmatch",
|
|
2558
|
+
comparatorOptions: {},
|
|
2559
|
+
screenshotOptions: {
|
|
2560
|
+
animations: "disabled",
|
|
2561
|
+
caret: "hide",
|
|
2562
|
+
fullPage: false,
|
|
2563
|
+
maskColor: "#ff00ff",
|
|
2564
|
+
omitBackground: false,
|
|
2565
|
+
scale: "device"
|
|
2566
|
+
},
|
|
2567
|
+
timeout: 5e3,
|
|
2568
|
+
resolveDiffPath: ({ arg, ext, root, attachmentsDir, browserName, platform, testFileDirectory, testFileName }) => resolve(root, attachmentsDir, testFileDirectory, testFileName, `${arg}-${browserName}-${platform}${ext}`),
|
|
2569
|
+
resolveScreenshotPath: ({ arg, ext, root, screenshotDirectory, testFileDirectory, testFileName, browserName }) => resolve(root, testFileDirectory, screenshotDirectory, testFileName, `${arg}-${browserName}-${platform}${ext}`)
|
|
2570
|
+
};
|
|
2571
|
+
const supportedExtensions = ["png"];
|
|
2572
|
+
function resolveOptions({ context, name, options, testName }) {
|
|
2573
|
+
if (context.testPath === undefined) {
|
|
2574
|
+
throw new Error("`resolveOptions` has to be used in a test file");
|
|
2575
|
+
}
|
|
2576
|
+
const resolvedOptions = deepMerge(Object.create(null), defaultOptions, context.project.config.browser.expect?.toMatchScreenshot ?? {}, options);
|
|
2577
|
+
const extensionFromName = extname(name);
|
|
2578
|
+
// technically the type is a lie, but we check beneath and reassign otherwise
|
|
2579
|
+
let extension = extensionFromName.replace(/^\./, "");
|
|
2580
|
+
// when `type` will be supported in `screenshotOptions`:
|
|
2581
|
+
// - `'png'` should end up in `defaultOptions.screenshotOptions.type`
|
|
2582
|
+
// - this condition should be switched around
|
|
2583
|
+
// - the assignment should be `resolvedOptions.screenshotOptions.type = extension`
|
|
2584
|
+
// - everything using `extension` should use `resolvedOptions.screenshotOptions.type`
|
|
2585
|
+
if (supportedExtensions.includes(extension) === false) {
|
|
2586
|
+
extension = "png";
|
|
2587
|
+
}
|
|
2588
|
+
const { root } = context.project.serializedConfig;
|
|
2589
|
+
const resolvePathData = {
|
|
2590
|
+
arg: sanitizeArg(
|
|
2591
|
+
// remove the extension only if it ends up being used
|
|
2592
|
+
extensionFromName.endsWith(extension) ? basename(name, extensionFromName) : name
|
|
2593
|
+
),
|
|
2594
|
+
ext: `.${extension}`,
|
|
2595
|
+
platform: platform(),
|
|
2596
|
+
root,
|
|
2597
|
+
screenshotDirectory: relative(root, join(root, context.project.config.browser.screenshotDirectory ?? "__screenshots__")),
|
|
2598
|
+
attachmentsDir: relative(root, context.project.config.attachmentsDir),
|
|
2599
|
+
testFileDirectory: relative(root, dirname(context.testPath)),
|
|
2600
|
+
testFileName: basename(context.testPath),
|
|
2601
|
+
testName: sanitize(testName, false),
|
|
2602
|
+
browserName: context.project.config.browser.name
|
|
2603
|
+
};
|
|
2604
|
+
return {
|
|
2605
|
+
codec: getCodec(extension),
|
|
2606
|
+
comparator: getComparator(resolvedOptions.comparatorName),
|
|
2607
|
+
resolvedOptions,
|
|
2608
|
+
paths: {
|
|
2609
|
+
reference: resolvedOptions.resolveScreenshotPath(resolvePathData),
|
|
2610
|
+
get diffs() {
|
|
2611
|
+
const diffs = {
|
|
2612
|
+
reference: resolvedOptions.resolveDiffPath({
|
|
2613
|
+
...resolvePathData,
|
|
2614
|
+
arg: `${resolvePathData.arg}-reference`
|
|
2615
|
+
}),
|
|
2616
|
+
actual: resolvedOptions.resolveDiffPath({
|
|
2617
|
+
...resolvePathData,
|
|
2618
|
+
arg: `${resolvePathData.arg}-actual`
|
|
2619
|
+
}),
|
|
2620
|
+
diff: resolvedOptions.resolveDiffPath({
|
|
2621
|
+
...resolvePathData,
|
|
2622
|
+
arg: `${resolvePathData.arg}-diff`
|
|
2623
|
+
})
|
|
2624
|
+
};
|
|
2625
|
+
Object.defineProperty(this, "diffs", { value: diffs });
|
|
2626
|
+
return diffs;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Sanitizes a string by removing or transforming characters to ensure it is
|
|
2633
|
+
* safe for use as a filename or path segment. It supports two modes:
|
|
2634
|
+
*
|
|
2635
|
+
* 1. Non-path mode (`keepPaths === false`):
|
|
2636
|
+
* - Replaces one or more whitespace characters (`\s+`) with a single hyphen (`-`).
|
|
2637
|
+
* - Removes any character that is not a word character (`\w`) or a hyphen (`-`).
|
|
2638
|
+
* - Collapses multiple consecutive hyphens (`-{2,}`) into a single hyphen.
|
|
2639
|
+
*
|
|
2640
|
+
* 2. Path-preserving mode (`keepPaths === true`):
|
|
2641
|
+
* - Splits the input string on the path separator.
|
|
2642
|
+
* - Sanitizes each path segment individually in non-path mode.
|
|
2643
|
+
* - Joins the sanitized segments back together.
|
|
2644
|
+
*
|
|
2645
|
+
* @param input - The raw string to sanitize.
|
|
2646
|
+
* @param keepPaths - If `false`, performs a flat sanitization (drops path segments).
|
|
2647
|
+
* If `true`, treats `input` as a path: each segment is sanitized independently,
|
|
2648
|
+
* preserving separators.
|
|
2649
|
+
*/
|
|
2650
|
+
function sanitize(input, keepPaths) {
|
|
2651
|
+
if (keepPaths === false) {
|
|
2652
|
+
return input.replace(/\s+/g, "-").replace(/[^\w-]+/g, "").replace(/-{2,}/g, "-");
|
|
2653
|
+
}
|
|
2654
|
+
return input.split("/").map((path) => sanitize(path, false)).join("/");
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* Takes a string, treats it as a potential path or filename, and ensures it cannot
|
|
2658
|
+
* escape the root directory or contain invalid characters. Internally, it:
|
|
2659
|
+
*
|
|
2660
|
+
* 1. Prepends the path separator to the raw input to form a path-like string.
|
|
2661
|
+
* 2. Uses {@linkcode relative|relative('/', <that-path>)} to compute a relative
|
|
2662
|
+
* path from the root, which effectively strips any leading separators and prevents
|
|
2663
|
+
* traversal above the root.
|
|
2664
|
+
* 3. Passes the resulting relative path into {@linkcode sanitize|sanitize(..., true)},
|
|
2665
|
+
* preserving any path separators but sanitizing each segment.
|
|
2666
|
+
*
|
|
2667
|
+
* @param input - The raw string to clean.
|
|
2668
|
+
*/
|
|
2669
|
+
function sanitizeArg(input) {
|
|
2670
|
+
return sanitize(relative("/", join("/", input)), true);
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Takes a screenshot and decodes it using the provided codec.
|
|
2674
|
+
*
|
|
2675
|
+
* The screenshot is taken as a base64 string and then decoded into the format
|
|
2676
|
+
* expected by the comparator.
|
|
2677
|
+
*
|
|
2678
|
+
* @returns `Promise` resolving to the decoded screenshot data
|
|
2679
|
+
*/
|
|
2680
|
+
function takeDecodedScreenshot({ codec, context, element, name, screenshotOptions }) {
|
|
2681
|
+
return takeScreenshot(context, name, {
|
|
2682
|
+
...screenshotOptions,
|
|
2683
|
+
save: false,
|
|
2684
|
+
element
|
|
2685
|
+
}).then(({ buffer }) => codec.decode(buffer, {}));
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Creates a promise that resolves to `null` after the specified timeout.
|
|
2689
|
+
* If the timeout is `0`, the promise resolves immediately.
|
|
2690
|
+
*
|
|
2691
|
+
* @param timeout - The delay in milliseconds before the promise resolves
|
|
2692
|
+
* @returns `Promise` that resolves to `null` after the timeout
|
|
2693
|
+
*/
|
|
2694
|
+
function asyncTimeout(timeout) {
|
|
2695
|
+
return new Promise((resolve) => {
|
|
2696
|
+
if (timeout === 0) {
|
|
2697
|
+
resolve(null);
|
|
2698
|
+
} else {
|
|
2699
|
+
setTimeout(() => resolve(null), timeout);
|
|
2700
|
+
}
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
const screenshotMatcher = async (context, name, testName, options) => {
|
|
2705
|
+
if (!context.testPath) {
|
|
2706
|
+
throw new Error(`Cannot compare screenshots without a test path`);
|
|
2707
|
+
}
|
|
2708
|
+
const { element } = options;
|
|
2709
|
+
const { codec, comparator, paths, resolvedOptions: { comparatorOptions, screenshotOptions, timeout } } = resolveOptions({
|
|
2710
|
+
context,
|
|
2711
|
+
name,
|
|
2712
|
+
testName,
|
|
2713
|
+
options
|
|
2714
|
+
});
|
|
2715
|
+
const referenceFile = await readFile$1(paths.reference).catch(() => null);
|
|
2716
|
+
const reference = referenceFile && await codec.decode(await readFile$1(paths.reference), {});
|
|
2717
|
+
const abortController = new AbortController();
|
|
2718
|
+
const stableScreenshot = getStableScreenshots({
|
|
2719
|
+
codec,
|
|
2720
|
+
comparator,
|
|
2721
|
+
comparatorOptions,
|
|
2722
|
+
context,
|
|
2723
|
+
element,
|
|
2724
|
+
name: `${Date.now()}-${basename(paths.reference)}`,
|
|
2725
|
+
reference,
|
|
2726
|
+
screenshotOptions,
|
|
2727
|
+
signal: abortController.signal
|
|
2728
|
+
});
|
|
2729
|
+
const value = await (timeout === 0 ? stableScreenshot : Promise.race([stableScreenshot, asyncTimeout(timeout).finally(() => {
|
|
2730
|
+
abortController.abort();
|
|
2731
|
+
})]));
|
|
2732
|
+
// case #01
|
|
2733
|
+
// - impossible to get a stable screenshot to compare against
|
|
2734
|
+
// - fail
|
|
2735
|
+
if (value === null || value.actual === null) {
|
|
2736
|
+
return {
|
|
2737
|
+
pass: false,
|
|
2738
|
+
reference: referenceFile && paths.reference,
|
|
2739
|
+
actual: null,
|
|
2740
|
+
diff: null,
|
|
2741
|
+
message: `Could not capture a stable screenshot within ${timeout}ms.`
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
const { updateSnapshot } = context.project.serializedConfig.snapshotOptions;
|
|
2745
|
+
// if there's no reference or if we want to update snapshots, we have to finish the comparison early
|
|
2746
|
+
if (reference === null || updateSnapshot === "all") {
|
|
2747
|
+
const shouldCreateReference = updateSnapshot !== "none";
|
|
2748
|
+
const referencePath = shouldCreateReference ? paths.reference : paths.diffs.reference;
|
|
2749
|
+
await writeScreenshot(referencePath, await codec.encode(value.actual, {}));
|
|
2750
|
+
// case #02
|
|
2751
|
+
// - got a stable screenshot, but there is no reference and we don't want to update screenshots
|
|
2752
|
+
// - fail
|
|
2753
|
+
if (updateSnapshot !== "all") {
|
|
2754
|
+
return {
|
|
2755
|
+
pass: false,
|
|
2756
|
+
reference: referencePath,
|
|
2757
|
+
actual: null,
|
|
2758
|
+
diff: null,
|
|
2759
|
+
message: `No existing reference screenshot found${shouldCreateReference ? "; a new one was created. Review it before running tests again." : "."}`
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
// case #03
|
|
2763
|
+
// - got a stable screenshot, there is no reference, but we want to update screenshots
|
|
2764
|
+
// - pass
|
|
2765
|
+
return { pass: true };
|
|
2766
|
+
}
|
|
2767
|
+
// case #04
|
|
2768
|
+
// - got a stable screenshot with no retries and there's a reference
|
|
2769
|
+
// - pass
|
|
2770
|
+
if (referenceFile && value.retries === 0) {
|
|
2771
|
+
return { pass: true };
|
|
2772
|
+
}
|
|
2773
|
+
const finalResult = await comparator(reference, value.actual, {
|
|
2774
|
+
createDiff: true,
|
|
2775
|
+
...comparatorOptions
|
|
2776
|
+
});
|
|
2777
|
+
if (finalResult.pass === false && finalResult.diff !== null) {
|
|
2778
|
+
const diff = await codec.encode({
|
|
2779
|
+
data: finalResult.diff,
|
|
2780
|
+
metadata: {
|
|
2781
|
+
height: reference.metadata.height,
|
|
2782
|
+
width: reference.metadata.width
|
|
2783
|
+
}
|
|
2784
|
+
}, {});
|
|
2785
|
+
await writeScreenshot(paths.diffs.diff, diff);
|
|
2786
|
+
}
|
|
2787
|
+
// case #05
|
|
2788
|
+
// - reference matches stable screenshot
|
|
2789
|
+
// - pass
|
|
2790
|
+
if (finalResult.pass === true) {
|
|
2791
|
+
return { pass: true };
|
|
2792
|
+
}
|
|
2793
|
+
const actual = await codec.encode(value.actual, {});
|
|
2794
|
+
await writeScreenshot(paths.diffs.actual, actual);
|
|
2795
|
+
// case #06
|
|
2796
|
+
// - fallback, reference does NOT match stable screenshot
|
|
2797
|
+
// - fail
|
|
2798
|
+
return {
|
|
2799
|
+
pass: false,
|
|
2800
|
+
reference: paths.reference,
|
|
2801
|
+
actual: paths.diffs.actual,
|
|
2802
|
+
diff: finalResult.diff && paths.diffs.diff,
|
|
2803
|
+
message: `Screenshot does not match the stored reference.${finalResult.message === null ? "" : `\n${finalResult.message}`}`
|
|
2804
|
+
};
|
|
2805
|
+
};
|
|
2806
|
+
async function writeScreenshot(path, image) {
|
|
2807
|
+
try {
|
|
2808
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2809
|
+
await writeFile$1(path, image);
|
|
2810
|
+
} catch {
|
|
2811
|
+
throw new Error("Couldn't write file to fs");
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
/**
|
|
2815
|
+
* Takes screenshots repeatedly until the page reaches a visually stable state.
|
|
2816
|
+
*
|
|
2817
|
+
* This function compares consecutive screenshots and continues taking new ones
|
|
2818
|
+
* until two consecutive screenshots match according to the provided comparator.
|
|
2819
|
+
*
|
|
2820
|
+
* The process works as follows:
|
|
2821
|
+
*
|
|
2822
|
+
* 1. Uses as baseline an optional reference screenshot or takes a new screenshot
|
|
2823
|
+
* 2. Takes a screenshot and compares with baseline
|
|
2824
|
+
* 3. If they match, the page is considered stable and the function returns
|
|
2825
|
+
* 4. If they don't match, it continues with the newer screenshot as the baseline
|
|
2826
|
+
* 5. Repeats until stability is achieved or the operation is aborted
|
|
2827
|
+
*
|
|
2828
|
+
* @returns `Promise` resolving to an object containing the retry count and
|
|
2829
|
+
* final screenshot
|
|
2830
|
+
*/
|
|
2831
|
+
async function getStableScreenshots({ codec, context, comparator, comparatorOptions, element, name, reference, screenshotOptions, signal }) {
|
|
2832
|
+
const screenshotArgument = {
|
|
2833
|
+
codec,
|
|
2834
|
+
context,
|
|
2835
|
+
element,
|
|
2836
|
+
name,
|
|
2837
|
+
screenshotOptions
|
|
2838
|
+
};
|
|
2839
|
+
let retries = 0;
|
|
2840
|
+
let decodedBaseline = reference;
|
|
2841
|
+
while (signal.aborted === false) {
|
|
2842
|
+
if (decodedBaseline === null) {
|
|
2843
|
+
decodedBaseline = takeDecodedScreenshot(screenshotArgument);
|
|
2844
|
+
}
|
|
2845
|
+
const [image1, image2] = await Promise.all([decodedBaseline, takeDecodedScreenshot(screenshotArgument)]);
|
|
2846
|
+
const comparatorResult = (await comparator(image1, image2, {
|
|
2847
|
+
...comparatorOptions,
|
|
2848
|
+
createDiff: false
|
|
2849
|
+
})).pass;
|
|
2850
|
+
decodedBaseline = image2;
|
|
2851
|
+
if (comparatorResult) {
|
|
2852
|
+
break;
|
|
2853
|
+
}
|
|
2854
|
+
retries += 1;
|
|
2855
|
+
}
|
|
2856
|
+
return {
|
|
2857
|
+
retries,
|
|
2858
|
+
actual: await decodedBaseline
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2413
2862
|
const selectOptions = async (context, selector, userValues, options = {}) => {
|
|
2414
2863
|
if (context.provider instanceof PlaywrightBrowserProvider) {
|
|
2415
2864
|
const value = userValues;
|
|
@@ -2550,7 +2999,8 @@ var builtinCommands = {
|
|
|
2550
2999
|
__vitest_dragAndDrop: dragAndDrop,
|
|
2551
3000
|
__vitest_hover: hover,
|
|
2552
3001
|
__vitest_cleanup: keyboardCleanup,
|
|
2553
|
-
__vitest_viewport: viewport
|
|
3002
|
+
__vitest_viewport: viewport,
|
|
3003
|
+
__vitest_screenshotMatcher: screenshotMatcher
|
|
2554
3004
|
};
|
|
2555
3005
|
|
|
2556
3006
|
class BrowserServerState {
|
|
@@ -2840,7 +3290,7 @@ function defaultSerialize(i) {
|
|
|
2840
3290
|
return i;
|
|
2841
3291
|
}
|
|
2842
3292
|
const defaultDeserialize = defaultSerialize;
|
|
2843
|
-
const { clearTimeout, setTimeout } = globalThis;
|
|
3293
|
+
const { clearTimeout, setTimeout: setTimeout$1 } = globalThis;
|
|
2844
3294
|
const random = Math.random.bind(Math);
|
|
2845
3295
|
function createBirpc(functions, options) {
|
|
2846
3296
|
const {
|
|
@@ -2864,10 +3314,13 @@ function createBirpc(functions, options) {
|
|
|
2864
3314
|
return functions;
|
|
2865
3315
|
if (method === "$close")
|
|
2866
3316
|
return close;
|
|
3317
|
+
if (method === "$rejectPendingCalls") {
|
|
3318
|
+
return rejectPendingCalls;
|
|
3319
|
+
}
|
|
2867
3320
|
if (method === "$closed")
|
|
2868
3321
|
return closed;
|
|
2869
3322
|
if (method === "then" && !eventNames.includes("then") && !("then" in functions))
|
|
2870
|
-
return
|
|
3323
|
+
return void 0;
|
|
2871
3324
|
const sendEvent = (...args) => {
|
|
2872
3325
|
post(serialize({ m: method, a: args, t: TYPE_REQUEST }));
|
|
2873
3326
|
};
|
|
@@ -2882,14 +3335,14 @@ function createBirpc(functions, options) {
|
|
|
2882
3335
|
try {
|
|
2883
3336
|
await _promise;
|
|
2884
3337
|
} finally {
|
|
2885
|
-
_promise =
|
|
3338
|
+
_promise = void 0;
|
|
2886
3339
|
}
|
|
2887
3340
|
}
|
|
2888
3341
|
return new Promise((resolve, reject) => {
|
|
2889
3342
|
const id = nanoid();
|
|
2890
3343
|
let timeoutId;
|
|
2891
3344
|
if (timeout >= 0) {
|
|
2892
|
-
timeoutId = setTimeout(() => {
|
|
3345
|
+
timeoutId = setTimeout$1(() => {
|
|
2893
3346
|
try {
|
|
2894
3347
|
const handleResult = options.onTimeoutError?.(method, args);
|
|
2895
3348
|
if (handleResult !== true)
|
|
@@ -2910,14 +3363,30 @@ function createBirpc(functions, options) {
|
|
|
2910
3363
|
return sendCall;
|
|
2911
3364
|
}
|
|
2912
3365
|
});
|
|
2913
|
-
function close(
|
|
3366
|
+
function close(customError) {
|
|
2914
3367
|
closed = true;
|
|
2915
3368
|
rpcPromiseMap.forEach(({ reject, method }) => {
|
|
2916
|
-
|
|
3369
|
+
const error = new Error(`[birpc] rpc is closed, cannot call "${method}"`);
|
|
3370
|
+
if (customError) {
|
|
3371
|
+
customError.cause ??= error;
|
|
3372
|
+
return reject(customError);
|
|
3373
|
+
}
|
|
3374
|
+
reject(error);
|
|
2917
3375
|
});
|
|
2918
3376
|
rpcPromiseMap.clear();
|
|
2919
3377
|
off(onMessage);
|
|
2920
3378
|
}
|
|
3379
|
+
function rejectPendingCalls(handler) {
|
|
3380
|
+
const entries = Array.from(rpcPromiseMap.values());
|
|
3381
|
+
const handlerResults = entries.map(({ method, reject }) => {
|
|
3382
|
+
if (!handler) {
|
|
3383
|
+
return reject(new Error(`[birpc]: rejected pending call "${method}".`));
|
|
3384
|
+
}
|
|
3385
|
+
return handler({ method, reject });
|
|
3386
|
+
});
|
|
3387
|
+
rpcPromiseMap.clear();
|
|
3388
|
+
return handlerResults;
|
|
3389
|
+
}
|
|
2921
3390
|
async function onMessage(data, ...extra) {
|
|
2922
3391
|
let msg;
|
|
2923
3392
|
try {
|
|
@@ -3017,12 +3486,13 @@ function setupBrowserRpc(globalServer, defaultMockerRegistry) {
|
|
|
3017
3486
|
if (!sessionId || !rpcId || projectName == null) {
|
|
3018
3487
|
return error(new Error(`[vitest] Invalid URL ${request.url}. "projectName", "sessionId" and "rpcId" queries are required.`));
|
|
3019
3488
|
}
|
|
3020
|
-
|
|
3021
|
-
|
|
3489
|
+
const sessions = vitest._browserSessions;
|
|
3490
|
+
if (!sessions.sessionIds.has(sessionId)) {
|
|
3491
|
+
const ids = [...sessions.sessionIds].join(", ");
|
|
3022
3492
|
return error(new Error(`[vitest] Unknown session id "${sessionId}". Expected one of ${ids}.`));
|
|
3023
3493
|
}
|
|
3024
3494
|
if (type === "orchestrator") {
|
|
3025
|
-
const session =
|
|
3495
|
+
const session = sessions.getSession(sessionId);
|
|
3026
3496
|
// it's possible the session was already resolved by the preview provider
|
|
3027
3497
|
session?.connected();
|
|
3028
3498
|
}
|
|
@@ -3042,7 +3512,7 @@ function setupBrowserRpc(globalServer, defaultMockerRegistry) {
|
|
|
3042
3512
|
clients.delete(rpcId);
|
|
3043
3513
|
globalServer.removeCDPHandler(rpcId);
|
|
3044
3514
|
if (type === "orchestrator") {
|
|
3045
|
-
|
|
3515
|
+
sessions.destroySession(sessionId);
|
|
3046
3516
|
}
|
|
3047
3517
|
// this will reject any hanging methods if there are any
|
|
3048
3518
|
rpc.$close(new Error(`[vitest] Browser connection was closed while running tests. Was the page closed unexpectedly?`));
|
|
@@ -3251,11 +3721,8 @@ function setupBrowserRpc(globalServer, defaultMockerRegistry) {
|
|
|
3251
3721
|
on: (fn) => ws.on("message", fn),
|
|
3252
3722
|
eventNames: ["onCancel", "cdpEvent"],
|
|
3253
3723
|
serialize: (data) => stringify(data, stringifyReplace),
|
|
3254
|
-
timeout: -1,
|
|
3255
3724
|
deserialize: parse,
|
|
3256
|
-
|
|
3257
|
-
throw new Error(`[vitest-api]: Timeout calling "${functionName}"`);
|
|
3258
|
-
}
|
|
3725
|
+
timeout: -1
|
|
3259
3726
|
});
|
|
3260
3727
|
vitest.onCancel((reason) => rpc.onCancel(reason));
|
|
3261
3728
|
return rpc;
|
|
@@ -3348,14 +3815,15 @@ function createBrowserPool(vitest) {
|
|
|
3348
3815
|
}
|
|
3349
3816
|
const parallelPools = [];
|
|
3350
3817
|
const nonParallelPools = [];
|
|
3351
|
-
for (const
|
|
3352
|
-
if (!
|
|
3818
|
+
for (const pool of initialisedPools) {
|
|
3819
|
+
if (!pool) {
|
|
3820
|
+
// this means it was cancelled
|
|
3353
3821
|
return;
|
|
3354
3822
|
}
|
|
3355
|
-
if (
|
|
3356
|
-
parallelPools.push(
|
|
3823
|
+
if (pool.provider.mocker && pool.provider.supportsParallelism) {
|
|
3824
|
+
parallelPools.push(pool.runTests);
|
|
3357
3825
|
} else {
|
|
3358
|
-
nonParallelPools.push(
|
|
3826
|
+
nonParallelPools.push(pool.runTests);
|
|
3359
3827
|
}
|
|
3360
3828
|
}
|
|
3361
3829
|
await Promise.all(parallelPools.map((runTests) => runTests()));
|