[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(experimental): support `browser.locators.exact` option (#10013)

authored by

Vladimir and committed by
GitHub
(Mar 31, 2026, 12:17 PM +0200) 487990a1 d0f042f2

+212 -32
+5 -5
pnpm-lock.yaml
··· 564 564 specifier: 'catalog:' 565 565 version: 3.4.2 566 566 ivya: 567 - specifier: ^1.7.1 568 - version: 1.7.1 567 + specifier: ^1.8.0 568 + version: 1.8.0 569 569 mime: 570 570 specifier: ^4.1.0 571 571 version: 4.1.0 ··· 7815 7815 resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} 7816 7816 engines: {node: '>=8'} 7817 7817 7818 - ivya@1.7.1: 7819 - resolution: {integrity: sha512-glD8yiay9XGwnmUgUY6B1mu+YimiA7Kqc57/4Jgm7HdP/IQJbAKHon68arKpOmmHNOWwTiFEOSf3g5MKzHSyew==} 7818 + ivya@1.8.0: 7819 + resolution: {integrity: sha512-8qFMisQ9Y+rTy4kZZnD/+I5X3R4MMN0bR1Phnguub+MAk5FiMfOSNC45vItoUfkJGhhNbICIVOqxDFpOso1Wjg==} 7820 7820 7821 7821 jackspeak@3.4.0: 7822 7822 resolution: {integrity: sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==} ··· 16769 16769 html-escaper: 2.0.2 16770 16770 istanbul-lib-report: 3.0.1 16771 16771 16772 - ivya@1.7.1: {} 16772 + ivya@1.8.0: {} 16773 16773 16774 16774 jackspeak@3.4.0: 16775 16775 dependencies:
+7
docs/guide/cli-generated.md
··· 429 429 430 430 Enable trace view mode. Supported: "on", "off", "on-first-retry", "on-all-retries", "retain-on-failure". 431 431 432 + ### browser.locators.exact 433 + 434 + - **CLI:** `--browser.locators.exact` 435 + - **Config:** [browser.locators.exact](/config/browser/locators#locators-exact) 436 + 437 + Should locators match the text exactly by default (default: `false`) 438 + 432 439 ### pool 433 440 434 441 - **CLI:** `--pool <pool>`
+1 -1
packages/browser/package.json
··· 87 87 "@vitest/runner": "workspace:*", 88 88 "birpc": "catalog:", 89 89 "flatted": "catalog:", 90 - "ivya": "^1.7.1", 90 + "ivya": "^1.8.0", 91 91 "mime": "^4.1.0", 92 92 "pathe": "catalog:", 93 93 "vitest": "workspace:*"
+14
docs/config/browser/locators.md
··· 13 13 - **Default:** `data-testid` 14 14 15 15 Attribute used to find elements with `getByTestId` locator. 16 + 17 + ## browser.locators.exact <Version type="experimental">4.1.3</Version> {#browser-locators-exact} 18 + 19 + - **Type:** `boolean` 20 + - **Default:** `false` 21 + 22 + When set to `true`, [locators](/api/browser/locators) will match text exactly by default, requiring a full, case-sensitive match. Individual locator calls can override this default via their own `exact` option. 23 + 24 + ```ts 25 + // With exact: false (default), this matches "Hello, World!", "Say Hello, World", etc. 26 + // With exact: true, this only matches the string "Hello, World" exactly. 27 + const locator = page.getByText('Hello, World', { exact: true }) 28 + await locator.click() 29 + ```
+19
test/browser/specs/locators.test.ts
··· 23 23 expect(stdout).toReportSummaryTests({ passed: instances.length * COUNT_TESTS_OVERALL }) 24 24 }) 25 25 26 + test('locators.exact option works', async () => { 27 + const { stderr, stdout } = await runBrowserTests({ 28 + root: './fixtures/locators-exact', 29 + reporters: [['verbose', { isTTY: false }]], 30 + }) 31 + 32 + expect(stderr).toReportNoErrors() 33 + 34 + instances.forEach(({ browser }) => { 35 + expect(stdout).toReportPassedTest('basic.test.ts', browser) 36 + }) 37 + 38 + const COUNT_TEST_FILES = 1 39 + const COUNT_TESTS_OVERALL = 5 40 + 41 + expect(stdout).toReportSummaryTestFiles({ passed: instances.length * COUNT_TEST_FILES }) 42 + expect(stdout).toReportSummaryTests({ passed: instances.length * COUNT_TESTS_OVERALL }) 43 + }) 44 + 26 45 test('custom locators work', async () => { 27 46 const { stderr, stdout } = await runBrowserTests({ 28 47 root: './fixtures/locators-custom',
+9
test/core/test/cli-test.test.ts
··· 289 289 }) 290 290 }) 291 291 292 + test('browser.locators are passed correctly', () => { 293 + expect(getCLIOptions('--browser.locators')).toEqual({ 294 + browser: { locators: {} }, 295 + }) 296 + expect(getCLIOptions('--browser.locators.exact')).toEqual({ 297 + browser: { locators: { exact: true } }, 298 + }) 299 + }) 300 + 292 301 test('clearScreen', async (ctx) => { 293 302 // skip vm since rolldown native modules break due to RegExp instance 294 303 // https://github.com/vitest-dev/vitest/issues/8754#issuecomment-3727583957
+1
packages/vitest/src/runtime/config.ts
··· 105 105 } 106 106 locators: { 107 107 testIdAttribute: string 108 + exact: boolean 108 109 } 109 110 screenshotFailures: boolean 110 111 providerOptions: {
+42
test/browser/fixtures/locators-exact/basic.test.ts
··· 1 + import { page } from 'vitest/browser' 2 + import { afterEach, describe, expect, test } from 'vitest' 3 + 4 + afterEach(() => { 5 + document.body.innerHTML = '' 6 + }) 7 + 8 + describe('exact: true global default', () => { 9 + test('getByText finds an exact text match', () => { 10 + document.body.innerHTML = '<span>Hello</span>' 11 + const screen = page.elementLocator(document.body) 12 + expect(screen.getByText('Hello').element()).toBe(document.querySelector('span')) 13 + }) 14 + 15 + test('getByText does not find a substring match', () => { 16 + document.body.innerHTML = '<span>Hello World</span>' 17 + const screen = page.elementLocator(document.body) 18 + expect(() => screen.getByText('Hello').element()).toThrow( 19 + 'Cannot find element', 20 + ) 21 + }) 22 + 23 + test('getByText is case-sensitive', () => { 24 + document.body.innerHTML = '<span>Hello</span>' 25 + const screen = page.elementLocator(document.body) 26 + expect(() => screen.getByText('hello').element()).toThrow( 27 + 'Cannot find element', 28 + ) 29 + }) 30 + 31 + test('getByText finds a full text match with multiple words', () => { 32 + document.body.innerHTML = '<span>Hello World</span>' 33 + const screen = page.elementLocator(document.body) 34 + expect(screen.getByText('Hello World').element()).toBe(document.querySelector('span')) 35 + }) 36 + 37 + test('per-call exact: false overrides the global default', () => { 38 + document.body.innerHTML = '<span>Hello</span>' 39 + const screen = page.elementLocator(document.body) 40 + expect(screen.getByText('hello', { exact: false }).element()).toBe(document.querySelector('span')) 41 + }) 42 + })
+18
test/browser/fixtures/locators-exact/vitest.config.ts
··· 1 + import { fileURLToPath } from 'node:url' 2 + import { defineConfig } from 'vitest/config' 3 + import { instances, provider } from '../../settings' 4 + 5 + export default defineConfig({ 6 + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 7 + test: { 8 + browser: { 9 + enabled: true, 10 + provider, 11 + headless: true, 12 + instances, 13 + locators: { 14 + exact: true, 15 + }, 16 + }, 17 + }, 18 + })
+70 -24
test/cli/test/config/browser-configs.test.ts
··· 621 621 headless: p.config.browser.headless, 622 622 browser: p.config.browser.enabled, 623 623 ui: p.config.browser.ui, 624 + locators: p.config.browser.locators, 624 625 }) 625 626 console.log(JSON.stringify({ 626 627 browser: { ··· 662 663 } 663 664 664 665 describe('[e2e] workspace configs are affected by the CLI options', () => { 666 + test('nested CLI options correctly override inline workspace options', async () => { 667 + const vitest = await getCliConfig({ 668 + projects: [ 669 + { 670 + test: { 671 + name: 'unit', 672 + }, 673 + }, 674 + { 675 + test: { 676 + name: 'browser', 677 + browser: { 678 + enabled: true, 679 + headless: true, 680 + provider: playwright(), 681 + instances: [ 682 + { 683 + browser: 'chromium', 684 + }, 685 + ], 686 + }, 687 + }, 688 + }, 689 + ], 690 + }, ['--browser.locators.exact']) 691 + 692 + const config = JSON.parse(vitest.stdout) 693 + 694 + expect(config.workspace).toHaveLength(2) 695 + expect(config.workspace[0]).toMatchObject({ 696 + name: 'unit', 697 + headless: false, 698 + browser: false, 699 + ui: true, 700 + parent: null, 701 + }) 702 + 703 + expect(config.workspace[1]).toMatchObject({ 704 + name: 'browser (chromium)', 705 + locators: { 706 + exact: true, 707 + }, 708 + }) 709 + }) 710 + 665 711 test('UI is not enabled by default in headless config', async () => { 666 712 const vitest = await getCliConfig({ 667 713 projects: [ ··· 691 737 const config = JSON.parse(vitest.stdout) 692 738 693 739 expect(config.workspace).toHaveLength(2) 694 - expect(config.workspace[0]).toEqual({ 740 + expect(config.workspace[0]).toMatchObject({ 695 741 name: 'unit', 696 742 headless: false, 697 743 browser: false, ··· 699 745 parent: null, 700 746 }) 701 747 702 - expect(config.workspace[1]).toEqual({ 748 + expect(config.workspace[1]).toMatchObject({ 703 749 name: 'browser (chromium)', 704 750 // headless was set in the config 705 751 headless: true, 706 752 browser: true, 707 753 // UI is false because headless is enabled 708 754 ui: false, 709 - parent: { 755 + parent: expect.objectContaining({ 710 756 name: 'browser', 711 757 headless: true, 712 758 browser: true, 713 759 ui: false, 714 - }, 760 + }), 715 761 }) 716 762 }) 717 763 ··· 744 790 const config = JSON.parse(vitest.stdout) 745 791 746 792 expect(config.workspace).toHaveLength(2) 747 - expect(config.workspace[0]).toEqual({ 793 + expect(config.workspace[0]).toMatchObject({ 748 794 name: 'unit', 749 795 headless: false, 750 796 browser: false, ··· 752 798 parent: null, 753 799 }) 754 800 755 - expect(config.workspace[1]).toEqual({ 801 + expect(config.workspace[1]).toMatchObject({ 756 802 name: 'browser (chromium)', 757 803 // headless was overridden by CLI options 758 804 headless: false, ··· 760 806 // UI should be true because we always set CI to false, 761 807 // if headless was `true`, ui would be `false` 762 808 ui: true, 763 - parent: { 809 + parent: expect.objectContaining({ 764 810 name: 'browser', 765 811 headless: false, 766 812 browser: true, 767 813 ui: true, 768 - }, 814 + }), 769 815 }) 770 816 }) 771 817 ··· 804 850 const config = JSON.parse(vitest.stdout) 805 851 806 852 expect(config.workspace).toHaveLength(2) 807 - expect(config.workspace[0]).toEqual({ 853 + expect(config.workspace[0]).toMatchObject({ 808 854 name: 'unit', 809 855 headless: false, 810 856 browser: false, ··· 812 858 parent: null, 813 859 }) 814 860 815 - expect(config.workspace[1]).toEqual({ 861 + expect(config.workspace[1]).toMatchObject({ 816 862 name: 'browser (chromium)', 817 863 headless: false, 818 864 browser: true, 819 865 ui: true, 820 - parent: { 866 + parent: expect.objectContaining({ 821 867 name: 'browser', 822 868 headless: false, 823 869 browser: true, 824 870 ui: true, 825 - }, 871 + }), 826 872 }) 827 873 }) 828 874 ··· 859 905 const config = JSON.parse(stdout) 860 906 861 907 expect(config.workspace).toHaveLength(2) 862 - expect(config.workspace[0]).toEqual({ 908 + expect(config.workspace[0]).toMatchObject({ 863 909 name: 'node', 864 910 headless: true, 865 911 browser: false, ··· 867 913 parent: null, 868 914 }) 869 915 870 - expect(config.workspace[1]).toEqual({ 916 + expect(config.workspace[1]).toMatchObject({ 871 917 name: 'browser (chromium)', 872 918 headless: true, 873 919 browser: true, 874 920 ui: false, 875 - parent: { 921 + parent: expect.objectContaining({ 876 922 name: 'browser', 877 923 headless: true, 878 924 browser: true, 879 925 ui: false, 880 - }, 926 + }), 881 927 }) 882 928 }) 883 929 ··· 914 960 const config = JSON.parse(stdout) 915 961 916 962 expect(config.workspace).toHaveLength(2) 917 - expect(config.workspace[0]).toEqual({ 963 + expect(config.workspace[0]).toMatchObject({ 918 964 name: 'node', 919 965 headless: false, 920 966 browser: false, ··· 922 968 parent: null, 923 969 }) 924 970 925 - expect(config.workspace[1]).toEqual({ 971 + expect(config.workspace[1]).toMatchObject({ 926 972 name: 'browser (chromium)', 927 973 headless: false, 928 974 browser: true, 929 975 ui: true, 930 - parent: { 976 + parent: expect.objectContaining({ 931 977 name: 'browser', 932 978 headless: false, 933 979 browser: true, 934 980 ui: true, 935 - }, 981 + }), 936 982 }) 937 983 }) 938 984 ··· 956 1002 ui: true, 957 1003 }, 958 1004 workspace: [ 959 - { 1005 + expect.objectContaining({ 960 1006 name: 'chromium', 961 1007 headless: false, 962 1008 browser: true, 963 1009 ui: true, 964 - parent: { 1010 + parent: expect.objectContaining({ 965 1011 name: '', 966 1012 headless: false, 967 1013 browser: true, 968 1014 ui: true, 969 - }, 970 - }, 1015 + }), 1016 + }), 971 1017 ], 972 1018 }) 973 1019 })
+16 -1
packages/vitest/src/node/cli/cli-config.ts
··· 418 418 viewport: null, 419 419 screenshotDirectory: null, 420 420 screenshotFailures: null, 421 - locators: null, 421 + locators: { 422 + description: 'Options for how locators should be handled by default', 423 + argument: '<options>', 424 + subcommands: { 425 + testIdAttribute: null, 426 + exact: { 427 + description: 'Should locators match the text exactly by default (default: `false`)', 428 + }, 429 + }, 430 + transform(val) { 431 + if (typeof val !== 'object' || val == null) { 432 + return {} 433 + } 434 + return val 435 + }, 436 + }, 422 437 testerHtmlPath: null, 423 438 instances: null, 424 439 expect: null,
+1
packages/vitest/src/node/config/resolveConfig.ts
··· 835 835 836 836 resolved.browser.locators ??= {} as any 837 837 resolved.browser.locators.testIdAttribute ??= 'data-testid' 838 + resolved.browser.locators.exact ??= false 838 839 839 840 if (typeof resolved.browser.provider === 'string') { 840 841 const source = `@vitest/browser-${resolved.browser.provider}`
+1
packages/vitest/src/node/config/serializeConfig.ts
··· 113 113 screenshotFailures: browser.screenshotFailures, 114 114 locators: { 115 115 testIdAttribute: browser.locators.testIdAttribute, 116 + exact: browser.locators.exact, 116 117 }, 117 118 providerOptions: provider?.name === 'playwright' 118 119 ? {
+1
packages/vitest/src/node/projects/resolveProjects.ts
··· 293 293 locators: locators 294 294 ? { 295 295 testIdAttribute: locators.testIdAttribute ?? currentConfig.locators.testIdAttribute, 296 + exact: locators.exact ?? currentConfig.locators.exact, 296 297 } 297 298 : project.config.browser.locators, 298 299 viewport: viewport ?? currentConfig.viewport,
+6
packages/vitest/src/node/types/browser.ts
··· 200 200 * @default 'data-testid' 201 201 */ 202 202 testIdAttribute?: string 203 + /** 204 + * Should locators match the text exactly by default 205 + * @default false 206 + */ 207 + exact?: boolean 203 208 } 204 209 205 210 /** ··· 394 399 screenshotFailures: boolean 395 400 locators: { 396 401 testIdAttribute: string 402 + exact: boolean 397 403 } 398 404 trace: { 399 405 mode: BrowserTraceViewMode
+1 -1
packages/browser/src/client/tester/locators/index.ts
··· 52 52 return new Promise(resolve => setTimeout(resolve, ms)) 53 53 } 54 54 55 - // we prefer using playwright locators because they are more powerful and support Shadow DOM 56 55 export const selectorEngine: Ivya = Ivya.create({ 56 + exact: server.config.browser.locators.exact, 57 57 browser: ((name: string) => { 58 58 switch (name) { 59 59 case 'edge':