[UI] Playwright Automated Binary Testing (#12214) (#12386)

* adds playwright

* adds playwright auth setup and kv tests

* removes generated gh action for playwright

* removes testem ignore paths

* consolidates kv e2e workflows into single test

* adds missing ids to key shares and threshold inputs

* updates ariaLabel arg to attribute in enabled and disabled plugin card components

* adds script to start vault with config for playwright tests

* updates playwright setup to initialize and unseal vault and create user for testing rather than using root token

* adds policies for e2e tests

* updates e2e init setup to use web repl for creating token

* moves kv e2e test under superuser directory

* updates playwright config to create projects for multiple user types

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
This commit is contained in:
Vault Automation 2026-02-17 12:09:03 -05:00 committed by GitHub
parent 8f6253cc0b
commit 763be2684d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 397 additions and 28 deletions

7
ui/.gitignore vendored
View file

@ -43,4 +43,9 @@ package-lock.json
vendor/jsondiffpatch.umd.js
vendor/htmlformatter.umd.js
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

View file

@ -8,7 +8,7 @@
@level="base"
@background="neutral-secondary"
@hasBorder={{false}}
@ariaLabel="{{@type.displayName}} - disabled engine type"
aria-label="{{@type.displayName}} - disabled engine type"
tabindex="0"
{{on "click" (fn @handleDisabledPluginClick @type)}}
{{on "keydown" (fn @handleDisabledPluginKeyDown @type)}}

View file

@ -11,7 +11,7 @@
@level={{if isDisabled "base" "mid"}}
@background={{if isDisabled "neutral-secondary" "neutral-primary"}}
@hasBorder={{true}}
@ariaLabel="{{@type.displayName}} - {{if isDisabled 'disabled' 'enabled'}} engine type"
aria-label="{{@type.displayName}} - {{if isDisabled 'disabled' 'enabled'}} engine type"
tabindex="0"
{{on "click" this.handleSelection}}
{{on "keydown" this.handleKeyDown}}

View file

@ -132,7 +132,7 @@
</label>
<div class="control">
<Input
aria-label="Key shares"
id="key-shares"
data-test-key-shares="true"
class="input"
autocomplete="off"
@ -155,7 +155,7 @@
</label>
<div class="control">
<Input
aria-label="Key threshold"
id="key-threshold"
data-test-key-threshold="true"
class="input"
autocomplete="off"

2
ui/e2e/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
!*.hcl
/tmp

69
ui/e2e/init.setup.ts Normal file
View file

@ -0,0 +1,69 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { test as base } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { USER_POLICY_MAP } from './policies';
export type UserSetupOptions = {
userType: string;
};
// use superuser as the default policy if not provided in the config for a project
export const setup = base.extend<UserSetupOptions>({
userType: 'superuser',
});
// setup will run once before all tests
setup('initialize vault and setup user for testing', async ({ page, userType }) => {
// on fresh app load navigating to the root will land us on the initialize page
await page.goto('./');
// initialize vault
await page.getByRole('spinbutton', { name: 'Key shares' }).fill('1');
await page.getByRole('spinbutton', { name: 'Key threshold' }).fill('1');
await page.getByRole('button', { name: 'Initialize' }).click();
// listen for download event so we can get the unseal key and root token
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download keys' }).click();
const download = await downloadPromise;
const keysPath = path.join(__dirname, `/tmp/${userType}-keys.json`);
await download.saveAs(keysPath);
const { keys, root_token } = JSON.parse(fs.readFileSync(keysPath, 'utf-8'));
// unseal vault
await page.getByRole('link', { name: 'Continue to Unseal' }).click();
await page.getByRole('textbox', { name: 'Unseal Key Portion' }).fill(keys[0]);
await page.getByRole('button', { name: 'Unseal' }).click();
// use the root token to login
await page.getByRole('textbox', { name: 'Token' }).fill(root_token);
await page.getByRole('button', { name: 'Sign in' }).click();
// create a policy for a specific user persona
// defaults to superuser but should be passed in via the project config in playwright.config.ts
await page.getByRole('link', { name: 'Access', exact: true }).click();
await page.getByRole('link', { name: 'Create ACL policy' }).click();
await page.getByRole('textbox', { name: 'Policy name' }).fill(userType);
await page.getByRole('radio', { name: 'Code editor' }).check();
await page.getByRole('textbox', { name: 'Policy editor' }).fill(USER_POLICY_MAP[userType]);
await page.getByRole('button', { name: 'Create policy' }).click();
// there is no UI workflow for creating tokens with specific policies
// generate a token using the web REPL and assign the new policy to it
await page.getByRole('button', { name: 'Console toggle' }).click();
await page
.getByRole('textbox', { name: 'web R.E.P.L.' })
.fill(`write -field=client_token auth/token/create policies=${userType} ttl=1d`);
await page.getByRole('textbox', { name: 'web R.E.P.L.' }).press('Enter');
const newToken = await page.locator('.console-ui-output pre').innerText();
await page.getByRole('button', { name: 'Console toggle' }).click();
// log out with the root token and log in with the new token/policy
await page.getByRole('button', { name: 'User menu' }).click();
await page.getByRole('link', { name: 'Log out' }).click();
await page.getByRole('textbox', { name: 'Token' }).fill(newToken);
await page.getByRole('button', { name: 'Sign in' }).click();
// wait for the dashboard to load to ensure login was successful
await page.waitForURL('**/dashboard');
// save the authenticated state to file
// subsequent tests can then reuse this session data
await page.context().storageState({ path: path.join(__dirname, `/tmp/${userType}-session.json`) });
});

15
ui/e2e/policies/index.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import fs from 'fs';
import path from 'path';
const readFile = (filePath: string) => {
return fs.readFileSync(path.join(__dirname, filePath), 'utf-8');
};
export const USER_POLICY_MAP = {
superuser: readFile('./superuser.hcl'),
};

View file

@ -0,0 +1,6 @@
# Copyright IBM Corp. 2016, 2025
# SPDX-License-Identifier: BUSL-1.1
path "*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}

View file

@ -0,0 +1,120 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { test, expect } from '@playwright/test';
test('kvv2 workflow', async ({ page }) => {
await page.goto('dashboard');
// enable kv secrets engine
await page.getByRole('link', { name: 'Secrets Engines' }).click();
await page.getByRole('link', { name: 'Enable new engine' }).click();
await page.locator('div').filter({ hasText: 'KV' }).nth(4).click();
await page.getByRole('textbox', { name: 'Path' }).click();
await page.getByRole('textbox', { name: 'Path' }).fill('kv-test');
await page.getByRole('button', { name: 'Enable engine' }).click();
// once enabled it should navigate to the secrets engine overview page
await expect(page.locator('section')).toContainText('kv-test version 2');
await expect(page.locator('section')).toContainText(
'No secrets yet When created, secrets will be listed here. Create a secret to get started.'
);
// verify that the kv engine appears in the list view
await page.getByRole('link', { name: 'Secrets Engines' }).click();
await page.getByRole('link', { name: 'kv-test/' }).click();
// create a secret
await page.getByRole('link', { name: 'Create secret' }).click();
await page.getByRole('textbox', { name: 'Path for this secret' }).fill('foo');
await page.getByRole('textbox', { name: 'key' }).fill('bar');
await page.getByRole('textbox', { name: 'bar' }).fill('baz');
await page.getByRole('button', { name: 'Save' }).click();
// it should navigate to the overview page for the new secret
await expect(page.locator('section')).toContainText('foo');
await expect(page.locator('section')).toContainText(
'Current version Create new The current version of this secret. 1'
);
// verify secret details
await page.getByRole('link', { name: 'Secret', exact: true }).click();
await expect(page.locator('section')).toContainText('bar');
await page.getByRole('button', { name: 'show value' }).click();
await expect(page.locator('pre')).toContainText('baz');
await page.locator('label').click();
await expect(page.getByRole('code')).toContainText('{ "bar": "baz" }');
// create metadata for the secret
await page.getByRole('link', { name: 'Metadata', exact: true }).click();
await expect(page.locator('#app-main-content')).toContainText(
'No custom metadata This data is version-agnostic and is usually used to describe the secret being stored. Add metadata'
);
await page.getByRole('link', { name: 'Edit metadata' }).click();
await page.getByRole('textbox', { name: 'key' }).fill('meta');
await page.getByRole('textbox', { name: 'value' }).fill('data');
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.locator('#app-main-content')).toContainText('meta data');
// create new version
await page.getByRole('link', { name: 'Version History' }).click();
await expect(page.locator('section')).toContainText('Version 1');
await expect(page.locator('section')).toContainText('Current');
await page.getByRole('button', { name: 'Manage version' }).click();
await page.getByRole('link', { name: 'Create new version from 1', exact: true }).click();
await page.getByRole('textbox', { name: 'key' }).first().fill('bar-v2');
await page.getByRole('textbox', { name: 'bar-v2' }).fill('baz-v2');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('section')).toContainText(
'Current version Create new The current version of this secret. 2'
);
await page.getByRole('link', { name: 'Version History' }).click();
await expect(page.locator('section')).toContainText('Version 2');
await expect(page.locator('section')).toContainText('Current');
await page.getByRole('link', { name: 'Version diff' }).click();
await expect(page.locator('section')).toContainText('bar"baz"bar-v2"baz-v2"');
// delete version 2
await page.goto('secrets-engines/kv-test/kv/foo');
await page.getByRole('link', { name: 'Secret', exact: true }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('radio', { name: 'Delete this version This' }).check();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.locator('section')).toContainText(
'Current version Deleted Create new The current version of this secret was deleted'
);
await page.getByRole('link', { name: 'Secret', exact: true }).click();
await expect(page.locator('section')).toContainText(
'Version 2 of this secret has been deleted This version has been deleted but can be undeleted. View other versions of this secret by clicking the Version History tab above. KV v2 API docs'
);
// undelete version
await page.getByRole('button', { name: 'Undelete' }).click();
await expect(page.locator('section')).toContainText(
'Current version Create new The current version of this secret. 2'
);
// delete latest version
await page.getByRole('link', { name: 'Secret', exact: true }).click();
await page.getByRole('button', { name: 'Version' }).click();
await page.getByRole('link', { name: 'Version 1' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('radio', { name: 'Delete latest version This' }).check();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.locator('section')).toContainText(
'Current version Deleted Create new The current version of this secret was deleted'
);
// destroy version 2
await page.getByRole('link', { name: 'Secret', exact: true }).click();
await page.getByRole('button', { name: 'Destroy' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.locator('section')).toContainText(
'Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. 2'
);
await page.getByRole('link', { name: 'Secret', exact: true }).click();
await expect(page.locator('section')).toContainText(
'Version 2 of this secret has been permanently destroyed A version that has been permanently deleted cannot be restored. You can view other versions of this secret in the Version History tab above. KV v2 API docs'
);
// destroy version 1
await page.getByRole('button', { name: 'Version' }).click();
await page.getByRole('link', { name: 'Version 1' }).click();
await page.getByRole('button', { name: 'Destroy' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByRole('link', { name: 'Secret', exact: true }).click();
await page.getByRole('button', { name: 'Version' }).click();
await page.getByRole('link', { name: 'Version 1' }).click();
await expect(page.locator('section')).toContainText(
'Version 1 of this secret has been permanently destroyed A version that has been permanently deleted cannot be restored. You can view other versions of this secret in the Version History tab above. KV v2 API docs'
);
});

15
ui/e2e/vault-config.json Normal file
View file

@ -0,0 +1,15 @@
{
"ui": true,
"disable_mlock": true,
"storage": {
"inmem": {}
},
"listener": {
"tcp": {
"address": "127.0.0.1:8204",
"tls_disable": 1
}
}
}

View file

@ -42,7 +42,8 @@
"test:server": "node scripts/start-vault.js --server",
"test:dev": "node scripts/start-vault.js",
"vault": "VAULT_REDIRECT_ADDR=http://127.0.0.1:8200 vault server -log-level=error -dev -dev-root-token-id=root -dev-ha -dev-transactional",
"vault:cluster": "VAULT_REDIRECT_ADDR=http://127.0.0.1:8202 vault server -log-level=error -dev -dev-root-token-id=root -dev-listen-address=127.0.0.1:8202 -dev-ha -dev-transactional"
"vault:cluster": "VAULT_REDIRECT_ADDR=http://127.0.0.1:8202 vault server -log-level=error -dev -dev-root-token-id=root -dev-listen-address=127.0.0.1:8202 -dev-ha -dev-transactional",
"vault:e2e": "vault server"
},
"devDependencies": {
"@babel/cli": "~7.27.0",
@ -67,9 +68,11 @@
"@glint/template": "^1.7.3",
"@icholy/duration": "~5.1.0",
"@lineal-viz/lineal": "~0.5.1",
"@playwright/test": "^1.58.0",
"@tsconfig/ember": "~2.0.0",
"@types/d3-array": "~3.2.1",
"@types/ember-data": "~4.4.16",
"@types/node": "^25.1.0",
"@types/qunit": "~2.19.12",
"@types/rsvp": "~4.0.9",
"@types/shell-quote": "~1.7.5",

93
ui/playwright.config.ts Normal file
View file

@ -0,0 +1,93 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
import fs from 'fs';
import { USER_POLICY_MAP } from './e2e/policies';
import type { UserSetupOptions } from './e2e/init.setup';
const userTypes = Object.keys(USER_POLICY_MAP);
// start at port 8204 and increment for each project to allow them to run concurrently
const getURL = (increment: number, server = false) => {
const port = `820${4 + increment}`;
return server ? `127.0.0.1:${port}` : `http://localhost:${port}/ui/vault/`;
};
// create tmp dir if it doesn't exist for storing session, keys and vault config files
const tmpDir = path.join(__dirname, '/e2e/tmp');
fs.mkdirSync(tmpDir, { recursive: true });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig<UserSetupOptions>({
testDir: './e2e',
// opt out of parallel execution with a test file - by default tests will run in the order they are defined
fullyParallel: false,
// fail the build on CI if you accidentally left test.only in the source code.
forbidOnly: !!process.env.CI,
// retry on CI only
retries: process.env.CI ? 2 : 0,
// use a worker for each project so they run concurrently
workers: userTypes.length,
// reporter to use. See https://playwright.dev/docs/test-reporters
reporter: 'html',
// shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
use: {
// collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer
trace: 'on-first-retry',
},
projects: [
// create setup project for each user type
...userTypes.map((userType, index) => ({
name: `setup:${userType}`,
testMatch: /init\.setup\.ts/,
use: {
userType,
baseURL: getURL(index),
},
})),
// create browser projects for each user type
...userTypes.map((userType, index) => {
const sessionFile = path.join(tmpDir, `${userType}-session.json`);
return {
name: `chrome:${userType}`,
dependencies: [`setup:${userType}`],
workers: 1,
// only run tests for this user type
testDir: `./e2e/tests/${userType}`,
use: {
...devices['Desktop Chrome'],
// only use if file has already been created by the setup project
storageState: fs.existsSync(sessionFile) ? sessionFile : undefined,
// start at port 8204 and increment for each project to allow them to run concurrently without conflicts
baseURL: getURL(index),
},
};
}),
],
webServer: [
// start a vault server for each project on a different port to allow them to run concurrently
...userTypes.map((userType, index) => {
// read base config file
const config = JSON.parse(fs.readFileSync(path.join(__dirname, '/e2e/vault-config.json'), 'utf-8'));
// set the listener address with correct port for this project
config.listener.tcp.address = getURL(index, true);
// write the config to a new file for this project
const configPath = path.join(tmpDir, `${userType}-vault-config.json`);
fs.writeFileSync(configPath, JSON.stringify(config));
return {
// start vault server (not dev) with inmem storage
command: `pnpm run vault:e2e -config=${configPath}`,
url: getURL(index),
reuseExistingServer: false,
};
}),
],
});

View file

@ -126,6 +126,9 @@ importers:
'@lineal-viz/lineal':
specifier: ~0.5.1
version: 0.5.1(@babel/core@7.26.10)(@glint/template@1.7.3)(ember-source@5.8.0(@babel/core@7.26.10)(@glimmer/component@1.1.2(@babel/core@7.26.10))(@glint/template@1.7.3)(rsvp@4.8.5)(webpack@5.94.0))
'@playwright/test':
specifier: ^1.58.0
version: 1.58.0
'@tsconfig/ember':
specifier: ~2.0.0
version: 2.0.0
@ -135,6 +138,9 @@ importers:
'@types/ember-data':
specifier: ~4.4.16
version: 4.4.16(@babel/core@7.26.10)
'@types/node':
specifier: ^25.1.0
version: 25.1.0
'@types/qunit':
specifier: ~2.19.12
version: 2.19.12
@ -1745,6 +1751,11 @@ packages:
resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.58.0':
resolution: {integrity: sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==}
engines: {node: '>=18'}
hasBin: true
'@pnpm/constants@7.1.1':
resolution: {integrity: sha512-31pZqMtjwV+Vaq7MaPrT1EoDFSYwye3dp6BiHIGRJmVThCQwySRKM7hCvqqI94epNkqFAAYoWrNynWoRYosGdw==}
engines: {node: '>=16.14'}
@ -2045,8 +2056,8 @@ packages:
'@types/minimatch@5.1.2':
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
'@types/node@22.15.21':
resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==}
'@types/node@25.1.0':
resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==}
'@types/prettier@2.7.3':
resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==}
@ -4891,6 +4902,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -6583,6 +6599,16 @@ packages:
resolution: {integrity: sha512-cjJP/mYuGyMrjJ49jI04khId5Oufd3nFTUYBzQTIIVNI7/oAWdwXEfpwTF8HELFV/gz+WGYUBHCe3KHWD8rYvg==}
engines: {node: '>=6.0.0'}
playwright-core@1.58.0:
resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.0:
resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==}
engines: {node: '>=18'}
hasBin: true
portfinder@1.0.37:
resolution: {integrity: sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw==}
engines: {node: '>= 10.12'}
@ -7825,8 +7851,8 @@ packages:
underscore@1.13.7:
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
@ -10811,6 +10837,10 @@ snapshots:
'@pkgr/core@0.2.4': {}
'@playwright/test@1.58.0':
dependencies:
playwright: 1.58.0
'@pnpm/constants@7.1.1': {}
'@pnpm/error@5.0.3':
@ -10892,7 +10922,7 @@ snapshots:
'@types/body-parser@1.19.5':
dependencies:
'@types/connect': 3.4.38
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/chai-as-promised@7.1.8':
dependencies:
@ -10906,11 +10936,11 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/cors@2.8.18':
dependencies:
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/d3-array@3.2.1': {}
@ -11184,7 +11214,7 @@ snapshots:
'@types/express-serve-static-core@4.19.6':
dependencies:
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 0.17.4
@ -11198,27 +11228,27 @@ snapshots:
'@types/fs-extra@5.1.0':
dependencies:
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/fs-extra@8.1.5':
dependencies:
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/fs-extra@9.0.13':
dependencies:
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/geojson@7946.0.16': {}
'@types/glob@7.2.0':
dependencies:
'@types/minimatch': 5.1.2
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/glob@8.1.0':
dependencies:
'@types/minimatch': 5.1.2
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/http-errors@2.0.4': {}
@ -11247,9 +11277,9 @@ snapshots:
'@types/minimatch@5.1.2': {}
'@types/node@22.15.21':
'@types/node@25.1.0':
dependencies:
undici-types: 6.21.0
undici-types: 7.16.0
'@types/prettier@2.7.3': {}
@ -11262,7 +11292,7 @@ snapshots:
'@types/rimraf@2.0.5':
dependencies:
'@types/glob': 8.1.0
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/rsvp@4.0.9': {}
@ -11271,12 +11301,12 @@ snapshots:
'@types/send@0.17.4':
dependencies:
'@types/mime': 1.3.5
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/serve-static@1.15.7':
dependencies:
'@types/http-errors': 2.0.4
'@types/node': 22.15.21
'@types/node': 25.1.0
'@types/send': 0.17.4
'@types/shell-quote@1.7.5': {}
@ -12380,7 +12410,7 @@ snapshots:
broccoli-rollup@4.0.0:
dependencies:
'@types/node': 22.15.21
'@types/node': 25.1.0
broccoli-plugin: 2.1.0
fs-tree-diff: 2.0.1
heimdalljs: 0.2.6
@ -14624,7 +14654,7 @@ snapshots:
engine.io@6.6.4:
dependencies:
'@types/cors': 2.8.18
'@types/node': 22.15.21
'@types/node': 25.1.0
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@ -15414,6 +15444,9 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -16150,7 +16183,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 22.15.21
'@types/node': 25.1.0
merge-stream: 2.0.0
supports-color: 8.1.1
@ -17211,6 +17244,14 @@ snapshots:
bytestreamjs: 1.1.3
pvutils: 1.1.3
playwright-core@1.58.0: {}
playwright@1.58.0:
dependencies:
playwright-core: 1.58.0
optionalDependencies:
fsevents: 2.3.2
portfinder@1.0.37:
dependencies:
async: 2.6.4
@ -18714,7 +18755,7 @@ snapshots:
underscore@1.13.7: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}