@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/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, isFileServingAllowed, createDebugger, isValidApiRequest, createViteLogger, createViteServer } from 'vitest/node';
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-KA1WiV0q.js';
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.3";
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._browserLastPort++;
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 path = options.path ? resolve(dirname(context.testPath), options.path) : resolveScreenshotPath(context.testPath, name, context.project.config);
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 returnResult(options, path, buffer);
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 returnResult(options, path, buffer);
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
- const buffer = await element.saveScreenshot(savePath);
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(savePath, { force: true });
2434
+ await rm(savePathWithExtension, { force: true });
2387
2435
  }
2388
- return returnResult(options, path, buffer);
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 undefined;
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 = undefined;
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(error) {
3366
+ function close(customError) {
2914
3367
  closed = true;
2915
3368
  rpcPromiseMap.forEach(({ reject, method }) => {
2916
- reject(error || new Error(`[birpc] rpc is closed, cannot call "${method}"`));
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
- if (!vitest._browserSessions.sessionIds.has(sessionId)) {
3021
- const ids = [...vitest._browserSessions.sessionIds].join(", ");
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 = vitest._browserSessions.getSession(sessionId);
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
- vitest._browserSessions.destroySession(sessionId);
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
- onTimeoutError(functionName) {
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 result of initialisedPools) {
3352
- if (!result) {
3818
+ for (const pool of initialisedPools) {
3819
+ if (!pool) {
3820
+ // this means it was cancelled
3353
3821
  return;
3354
3822
  }
3355
- if (result.provider.mocker && result.provider.supportsParallelism) {
3356
- parallelPools.push(result.runTests);
3823
+ if (pool.provider.mocker && pool.provider.supportsParallelism) {
3824
+ parallelPools.push(pool.runTests);
3357
3825
  } else {
3358
- nonParallelPools.push(result.runTests);
3826
+ nonParallelPools.push(pool.runTests);
3359
3827
  }
3360
3828
  }
3361
3829
  await Promise.all(parallelPools.map((runTests) => runTests()));