diff --git a/packages/eui/src/components/combo_box/combo_box.stories.tsx b/packages/eui/src/components/combo_box/combo_box.stories.tsx
index f8b14dc7c672..29490adab43d 100644
--- a/packages/eui/src/components/combo_box/combo_box.stories.tsx
+++ b/packages/eui/src/components/combo_box/combo_box.stories.tsx
@@ -80,6 +80,16 @@ export const Playground: Story = {
render: (args) => ,
};
+export const MultipleInstances: Story = {
+ parameters: { loki: { skip: true } },
+ render: () => (
+ <>
+
+
+ >
+ ),
+};
+
export const WithCustomOptionIds: Story = {
parameters: {
controls: {
diff --git a/packages/test-helpers/.gitignore b/packages/test-helpers/.gitignore
new file mode 100644
index 000000000000..aaa9103e44aa
--- /dev/null
+++ b/packages/test-helpers/.gitignore
@@ -0,0 +1,2 @@
+test-results/
+playwright-report/
diff --git a/packages/test-helpers/README.md b/packages/test-helpers/README.md
index f0a099041efe..c2302042f984 100644
--- a/packages/test-helpers/README.md
+++ b/packages/test-helpers/README.md
@@ -20,6 +20,68 @@ This library's versioning is following `@elastic/eui` and must be kept in sync t
yarn add --dev @elastic/eui-test-helpers
```
+## Playwright Component Objects
+
+For Playwright/Scout consumers, this package ships **Component Objects** —
+semantic wrappers around a single Playwright `Locator` that encapsulate
+user-like interactions for a specific EUI component.
+
+```ts
+import { EuiComboBoxObject } from '@elastic/eui-test-helpers/playwright';
+
+const combo = new EuiComboBoxObject(page, 'dataViewSelector');
+await combo.setSelectedOptions(['logs-*']);
+expect(await combo.getSelectedOptions()).toEqual(['logs-*']);
+```
+
+The constructor's second argument is the `data-test-subj` set by the
+consumer on the `` element (the outer `.euiComboBox` wrapper).
+
+`@playwright/test` is declared as a `peerDependency` — consumers provide
+their own version at runtime.
+
+### Adding a new Component Object
+
+1. Add `data-test-subj` constants to `src/components//selectors.ts`
+ (single source of truth — never inline test-subj strings in helpers).
+2. Add the Component Object class in `src/components//object.ts`,
+ extending `BaseObject` and importing constants from `selectors.ts`.
+3. Add validation tests in `src/components//object.spec.ts` against
+ the component's Storybook story.
+4. Re-export the new class from `src/index.ts`.
+
+### Running validation tests locally
+
+The validation tests run against EUI's Storybook. Start it in a separate
+terminal first:
+
+```shell
+cd packages/eui
+yarn build:workspaces # one-time, builds eui-theme-common + eui-theme-borealis
+yarn start # starts Storybook on http://localhost:6006
+```
+
+Wait until Storybook finishes compiling. Then, from the repository root:
+
+```shell
+yarn workspace @elastic/eui-test-helpers test
+```
+
+This runs `tsc --noEmit` (lint) followed by `playwright test` (the actual
+tests). To run only the tests, use `test-unit`. On a fresh checkout you'll
+also need to install Playwright's browsers once:
+
+```shell
+yarn workspace @elastic/eui-test-helpers exec playwright install chromium
+```
+
+After a failed run, open the HTML report (with traces, screenshots, and
+the full call log) via:
+
+```shell
+yarn workspace @elastic/eui-test-helpers show-report
+```
+
[Cypress]: https://github.com/cypress-io/cypress
[React Testing Library]: https://github.com/testing-library/react-testing-library
[Scout]: https://github.com/elastic/kibana/tree/main/src/platform/packages/shared/kbn-scout
diff --git a/packages/test-helpers/package.json b/packages/test-helpers/package.json
index 9e50cf5cedb6..47c927d6c918 100644
--- a/packages/test-helpers/package.json
+++ b/packages/test-helpers/package.json
@@ -6,5 +6,18 @@
"type": "git",
"url": "https://github.com/elastic/eui.git"
},
- "private": true
+ "private": true,
+ "scripts": {
+ "lint": "tsc --noEmit",
+ "test": "yarn lint && yarn test-unit",
+ "test-unit": "playwright test",
+ "show-report": "playwright show-report"
+ },
+ "peerDependencies": {
+ "@playwright/test": "^1.50.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.59.1",
+ "typescript": "^5.7.3"
+ }
}
diff --git a/packages/test-helpers/playwright.config.ts b/packages/test-helpers/playwright.config.ts
new file mode 100644
index 000000000000..efba2d4318a8
--- /dev/null
+++ b/packages/test-helpers/playwright.config.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { defineConfig, devices } from '@playwright/test';
+
+const STORYBOOK_PORT = 6006;
+const STORYBOOK_URL = `http://localhost:${STORYBOOK_PORT}`;
+
+/**
+ * Tests expect Storybook to already be running on `STORYBOOK_PORT`; see the
+ * package README for the local workflow. Defaults track kbn-scout's config
+ * (test-id attribute, timeouts, no auto-retries) for cross-team consistency.
+ */
+export default defineConfig({
+ testDir: './src',
+ testMatch: /.*\.spec\.ts$/,
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: 0,
+ reporter: process.env.CI ? 'github' : [['list'], ['html', { open: 'never' }]],
+ timeout: 60_000,
+ expect: { timeout: 10_000 },
+ use: {
+ baseURL: STORYBOOK_URL,
+ testIdAttribute: 'data-test-subj',
+ actionTimeout: 10_000,
+ navigationTimeout: 20_000,
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+});
diff --git a/packages/test-helpers/src/components/base_object.ts b/packages/test-helpers/src/components/base_object.ts
new file mode 100644
index 000000000000..a89ef8c68765
--- /dev/null
+++ b/packages/test-helpers/src/components/base_object.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { Locator, Page } from '@playwright/test';
+
+export type ObjectScope = Page | Locator | BaseObject;
+
+/**
+ * Base class for Playwright Component Objects — semantic wrappers around a
+ * single root `Locator` resolved from a `data-test-subj` inside the given
+ * scope. Subclasses compose: pass another Component Object as `scope` to
+ * nest one inside the other's DOM subtree.
+ *
+ * Requires `testIdAttribute: 'data-test-subj'` in the Playwright config.
+ */
+export abstract class BaseObject {
+ /**
+ * Available to subclasses for queries outside `root`'s subtree but within
+ * the original scope (e.g. siblings, related controls).
+ */
+ protected readonly scope: Page | Locator;
+
+ protected readonly root: Locator;
+
+ /**
+ * Retained so subclasses can disambiguate portal-rendered content per
+ * instance — e.g. EUI propagates this as `${testSubj}-optionsList` to a
+ * combo box's options list, letting us scope queries to the right combo
+ * when several exist on a page.
+ */
+ protected readonly testSubj: string;
+
+ constructor(scope: ObjectScope, testSubj: string) {
+ this.scope = scope instanceof BaseObject ? scope.locator : scope;
+ this.root = this.scope.getByTestId(testSubj);
+ this.testSubj = testSubj;
+ }
+
+ /**
+ * Underlying `Locator` — escape hatch for assertions or scoping the
+ * Component Object's API doesn't cover.
+ */
+ get locator(): Locator {
+ return this.root;
+ }
+}
diff --git a/packages/test-helpers/src/components/combo_box/object.spec.ts b/packages/test-helpers/src/components/combo_box/object.spec.ts
new file mode 100644
index 000000000000..b8aff308549c
--- /dev/null
+++ b/packages/test-helpers/src/components/combo_box/object.spec.ts
@@ -0,0 +1,95 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { test, expect } from '@playwright/test';
+
+import { EuiComboBoxObject } from './object';
+
+/**
+ * Validates `EuiComboBoxObject` against the live component in EUI Storybook.
+ *
+ * `data-test-subj` is injected via Storybook's `args` URL parameter so the
+ * helper can scope to the outer `.euiComboBox` wrapper — the clear button is
+ * rendered as a sibling of `comboBoxInput`, not a descendant.
+ */
+
+const TEST_SUBJ = 'testComboBox';
+const PLAYGROUND_STORY_URL =
+ `/iframe.html?id=forms-euicombobox--playground&viewMode=story&args=data-test-subj:${TEST_SUBJ}`;
+
+test.describe('EuiComboBoxObject', () => {
+ let combo: EuiComboBoxObject;
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto(PLAYGROUND_STORY_URL);
+ await page.getByTestId(TEST_SUBJ).waitFor({ state: 'visible' });
+ combo = new EuiComboBoxObject(page, TEST_SUBJ);
+ await combo.clear();
+ });
+
+ test('setSelectedOptions sets the selection to the provided labels', async () => {
+ await combo.setSelectedOptions(['Item 2']);
+
+ expect(await combo.getSelectedOptions()).toEqual(['Item 2']);
+ });
+
+ test('setSelectedOptions replaces the existing selection', async () => {
+ await combo.setSelectedOptions(['Item 1', 'Item 2']);
+ expect(await combo.getSelectedOptions()).toEqual(['Item 1', 'Item 2']);
+
+ // Replace, don't add.
+ await combo.setSelectedOptions(['Item 3']);
+ expect(await combo.getSelectedOptions()).toEqual(['Item 3']);
+ });
+
+ test('setSelectedOptions is idempotent when the selection already matches', async () => {
+ await combo.setSelectedOptions(['Item 1', 'Item 2']);
+
+ await expect(
+ combo.setSelectedOptions(['Item 1', 'Item 2'])
+ ).resolves.not.toThrow();
+ expect(await combo.getSelectedOptions()).toEqual(['Item 1', 'Item 2']);
+ });
+
+ test('clear removes all selected options', async () => {
+ await combo.setSelectedOptions(['Item 1', 'Item 2']);
+
+ await combo.clear();
+
+ expect(await combo.getSelectedOptions()).toEqual([]);
+ });
+
+ test('clear is a no-op when nothing is selected', async () => {
+ await expect(combo.clear()).resolves.not.toThrow();
+ expect(await combo.getSelectedOptions()).toEqual([]);
+ });
+
+ test('two combo boxes on the same page are operated independently', async ({
+ page,
+ }) => {
+ await page.goto('/iframe.html?id=forms-euicombobox--multiple-instances');
+ const combo1 = new EuiComboBoxObject(page, 'combo1');
+ const combo2 = new EuiComboBoxObject(page, 'combo2');
+ await combo1.clear();
+ await combo2.clear();
+
+ // Distinct labels + reverse order so a regression that mis-routed a
+ // selection across combos would surface.
+ await combo2.setSelectedOptions(['Item 5']);
+ await combo1.setSelectedOptions(['Item 2']);
+
+ expect(await combo1.getSelectedOptions()).toEqual(['Item 2']);
+ expect(await combo2.getSelectedOptions()).toEqual(['Item 5']);
+
+ // Clearing combo1 must not affect combo2 — guards the `clearButton`
+ // getter against a page-level scoping regression.
+ await combo1.clear();
+ expect(await combo1.getSelectedOptions()).toEqual([]);
+ expect(await combo2.getSelectedOptions()).toEqual(['Item 5']);
+ });
+});
diff --git a/packages/test-helpers/src/components/combo_box/object.ts b/packages/test-helpers/src/components/combo_box/object.ts
new file mode 100644
index 000000000000..e41baea518fe
--- /dev/null
+++ b/packages/test-helpers/src/components/combo_box/object.ts
@@ -0,0 +1,130 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { Locator } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+import { BaseObject } from '../base_object';
+import { EuiComboBoxSelectors } from './selectors';
+
+/**
+ * Playwright Component Object for {@link
+ * https://eui.elastic.co/docs/components/forms/selection/combo-box/ EuiComboBox}.
+ *
+ * `testSubj` must match the `data-test-subj` set by the consumer on the
+ * `` element (the outer `.euiComboBox` wrapper, not the inner
+ * `comboBoxInput`).
+ */
+export class EuiComboBoxObject extends BaseObject {
+ /**
+ * Replace the current selection with `labels`. Set-semantics: order-
+ * independent — already-selected labels are kept, missing ones are added,
+ * extras are removed. No-op if the current selection already matches.
+ *
+ * Throws with a descriptive message if any label never appears in the
+ * dropdown (catches test/data drift early).
+ */
+ async setSelectedOptions(labels: string[]): Promise {
+ // Dedupe while preserving order.
+ const targetLabels = [...new Set(labels)];
+ // `[...arr].sort()` (not `arr.sort()`) — sort mutates in place; the copy
+ // avoids mutating either the consumer's input or our internal state.
+ const sortedTarget = [...targetLabels].sort();
+
+ const sortedCurrent = [...(await this.getSelectedOptions())].sort();
+
+ // Set-equality short-circuit (any order).
+ if (
+ sortedCurrent.length === sortedTarget.length &&
+ sortedCurrent.every((label, i) => label === sortedTarget[i])
+ ) {
+ return;
+ }
+
+ // Naive replace — clear, then add each. A diff-based approach would do
+ // less DOM work but require a per-pill remove primitive we don't ship yet.
+ await this.clear();
+
+ for (const label of targetLabels) {
+ await this.addOption(label);
+ }
+
+ if (targetLabels.length > 0) {
+ // Close the dropdown so subsequent interactions start clean.
+ await this.root.page().keyboard.press('Escape');
+ }
+
+ await expect
+ .poll(
+ async () => [...(await this.getSelectedOptions())].sort(),
+ {
+ message: `EuiComboBox: selection did not match after setSelectedOptions(${JSON.stringify(labels)})`,
+ }
+ )
+ .toEqual(sortedTarget);
+ }
+
+ /**
+ * Clear all selected options. No-op if nothing is selected.
+ */
+ async clear(): Promise {
+ if ((await this.getSelectedOptions()).length === 0) {
+ return;
+ }
+ await this.clearButton.click();
+ await expect
+ .poll(() => this.getSelectedOptions(), {
+ message: 'EuiComboBox: clear button was clicked but selected options remain',
+ })
+ .toEqual([]);
+ }
+
+ /**
+ * Currently selected option labels — pill texts in multi-select, the input
+ * value wrapped in an array in single-select, `[]` if nothing is selected.
+ */
+ async getSelectedOptions(): Promise {
+ const pillCount = await this.pills.count();
+ if (pillCount > 0) {
+ return this.pills.allInnerTexts();
+ }
+ const inputValue = await this.searchInput.inputValue();
+ return inputValue ? [inputValue] : [];
+ }
+
+ private async addOption(label: string): Promise {
+ // Clicking the outer wrapper does not reliably open the dropdown; the
+ // inner `comboBoxInput` element does.
+ await this.input.click();
+ await this.searchInput.pressSequentially(label, { delay: 50 });
+
+ // Options list is rendered in a portal outside `this.root`, so locate
+ // from page level.
+ const option = this.root
+ .page()
+ .locator(EuiComboBoxSelectors.optionFor(this.testSubj, label));
+ await option.waitFor({ state: 'visible' });
+ await option.click();
+ }
+
+ private get input(): Locator {
+ return this.root.getByTestId(EuiComboBoxSelectors.TEST_SUBJ);
+ }
+
+ private get searchInput(): Locator {
+ return this.root.getByTestId(EuiComboBoxSelectors.SEARCH_INPUT_TEST_SUBJ);
+ }
+
+ private get clearButton(): Locator {
+ return this.root.getByTestId(EuiComboBoxSelectors.CLEAR_BUTTON_TEST_SUBJ);
+ }
+
+ private get pills(): Locator {
+ return this.root.getByTestId(EuiComboBoxSelectors.SELECTED_OPTIONS_TEST_SUBJ);
+ }
+}
diff --git a/packages/test-helpers/src/components/combo_box/selectors.ts b/packages/test-helpers/src/components/combo_box/selectors.ts
index be4dd264051f..9458f709abae 100644
--- a/packages/test-helpers/src/components/combo_box/selectors.ts
+++ b/packages/test-helpers/src/components/combo_box/selectors.ts
@@ -42,21 +42,31 @@ export const EuiComboBoxSelectors = {
SELECTED_OPTIONS_TEST_SUBJ: 'euiComboBoxPill',
/**
- * CSS selector to find all options (currently rendered on the screen).
+ * CSS selector for options in a specific combo box's dropdown — all
+ * options if `label` is omitted, or the option matching that label.
*
- * Note: Because the list of options might be virtualized, when searching
- * for a specific option, type in the searched string into the search input
- * before running any assertions to ensure the option is in DOM.
+ * `testSubj` is the consumer's `data-test-subj` on ``. EUI
+ * propagates this to the options list as `${testSubj}-optionsList`,
+ * letting us disambiguate when multiple combo boxes coexist on one page.
+ *
+ * `title` is set by EUI to the exact label string and avoids
+ * accessible-name mismatches caused by option icons.
+ *
+ * Note: the list may be virtualized — type the search string into the
+ * input before asserting on a specific option to ensure it is in DOM.
*/
- OPTION: '[data-test-subj="comboBoxOptionsList"] button[role="option"]',
+ optionFor: (testSubj: string, label?: string): string => {
+ const base = `[data-test-subj~="${testSubj}-optionsList"] button[role="option"]`;
+ return label ? `${base}[title="${label}"]` : base;
+ },
/**
- * CSS selector to find all selected options (currently rendered on the screen)
- *
- * Note: Because the list of options might be virtualized, when searching
- * for a specific option, type in the searched string into the search input
- * before running any assertions to ensure the option is in DOM.
+ * CSS selector for selected options in a specific combo box's dropdown —
+ * all selected if `label` is omitted, or the selected option matching
+ * that label. See `optionFor` for `testSubj` and `title` rationale.
*/
- SELECTED_OPTION:
- '[data-test-subj="comboBoxOptionsList"] button[role="option"][aria-selected="true"]',
+ selectedOptionFor: (testSubj: string, label?: string): string => {
+ const base = `[data-test-subj~="${testSubj}-optionsList"] button[role="option"][aria-selected="true"]`;
+ return label ? `${base}[title="${label}"]` : base;
+ },
};
diff --git a/packages/test-helpers/src/index.ts b/packages/test-helpers/src/index.ts
new file mode 100644
index 000000000000..6d93d029399c
--- /dev/null
+++ b/packages/test-helpers/src/index.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export { BaseObject, type ObjectScope } from './components/base_object';
+export { EuiComboBoxObject } from './components/combo_box/object';
diff --git a/packages/test-helpers/tsconfig.json b/packages/test-helpers/tsconfig.json
new file mode 100644
index 000000000000..863d821d0cbd
--- /dev/null
+++ b/packages/test-helpers/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "rootDir": ".",
+ "target": "ES2020",
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM"],
+ "moduleResolution": "Node",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src", "playwright.config.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/yarn.lock b/yarn.lock
index 9e1c36c8c28a..2bbefd16793a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7194,6 +7194,11 @@ __metadata:
"@elastic/eui-test-helpers@workspace:packages/test-helpers":
version: 0.0.0-use.local
resolution: "@elastic/eui-test-helpers@workspace:packages/test-helpers"
+ dependencies:
+ "@playwright/test": "npm:1.59.1"
+ typescript: "npm:^5.7.3"
+ peerDependencies:
+ "@playwright/test": ^1.50.0
languageName: unknown
linkType: soft
@@ -9341,6 +9346,17 @@ __metadata:
languageName: node
linkType: hard
+"@playwright/test@npm:1.59.1":
+ version: 1.59.1
+ resolution: "@playwright/test@npm:1.59.1"
+ dependencies:
+ playwright: "npm:1.59.1"
+ bin:
+ playwright: cli.js
+ checksum: 10c0/8c2d94a860d3c254a0b114df2f888ad0a0e9310f45b6059bd5d4da196d965cadf6922267cef0881cfa9784d4bef6d78363d2c2d94caa64be67ff644c41162137
+ languageName: node
+ linkType: hard
+
"@pmmmwh/react-refresh-webpack-plugin@npm:^0.5.3":
version: 0.5.3
resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.5.3"
@@ -22565,6 +22581,16 @@ __metadata:
languageName: node
linkType: hard
+"fsevents@npm:2.3.2":
+ version: 2.3.2
+ resolution: "fsevents@npm:2.3.2"
+ dependencies:
+ node-gyp: "npm:latest"
+ checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b
+ conditions: os=darwin
+ languageName: node
+ linkType: hard
+
"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
@@ -22575,6 +22601,15 @@ __metadata:
languageName: node
linkType: hard
+"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin":
+ version: 2.3.2
+ resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1"
+ dependencies:
+ node-gyp: "npm:latest"
+ conditions: os=darwin
+ languageName: node
+ linkType: hard
+
"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1"
@@ -32292,6 +32327,30 @@ __metadata:
languageName: node
linkType: hard
+"playwright-core@npm:1.59.1":
+ version: 1.59.1
+ resolution: "playwright-core@npm:1.59.1"
+ bin:
+ playwright-core: cli.js
+ checksum: 10c0/d41a74d9681ce3beb3d5239e9ed577710b4ad099a6ca2476219c6599d51e9cb4b80bd72ed82c528da6a5d929c18ae3b872cf02bb83f78fa1c2cb9199c501abee
+ languageName: node
+ linkType: hard
+
+"playwright@npm:1.59.1":
+ version: 1.59.1
+ resolution: "playwright@npm:1.59.1"
+ dependencies:
+ fsevents: "npm:2.3.2"
+ playwright-core: "npm:1.59.1"
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ bin:
+ playwright: cli.js
+ checksum: 10c0/dfe38396e616e5c4f98825ce90037bb96e477c5a2bd9258a24854f8ce72a8a41427b19098863866f85aa0216e70287dd537c4438d761aca93995e31ae099c533
+ languageName: node
+ linkType: hard
+
"pngjs@npm:^3.3.3":
version: 3.4.0
resolution: "pngjs@npm:3.4.0"