add playwright

This commit is contained in:
Андрей Бабушкин 2025-05-23 10:22:36 +02:00
parent d3d1a40909
commit 9fbe4a21e9
23 changed files with 321 additions and 107 deletions

View file

@ -31,5 +31,5 @@ export default {
transformIgnorePatterns: [
'/node_modules/(?!syncod)',
],
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
setupFiles: ['<rootDir>/tests/unit/jest.setup.ts'],
};

View file

@ -28,6 +28,7 @@
"@eslint/js": "^9.26.0",
"@medv/finder": "^4.0.2",
"@neodrag/react": "^2.3.0",
"@playwright/test": "^1.52.0",
"@sentry/browser": "^9.18.0",
"@svg-maps/world": "^1.0.1",
"@tanstack/react-query": "^5.76.0",
@ -64,6 +65,7 @@
"mobx": "^6.13.7",
"mobx-persist-store": "^1.1.8",
"mobx-react-lite": "^4.1.0",
"playwright": "^1.52.0",
"prismjs": "^1.30.0",
"rc-time-picker": "^3.7.3",
"react": "^19.1.0",

View file

@ -0,0 +1,37 @@
/* eslint-disable import/no-extraneous-dependencies */
import 'dotenv/config';
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/playwright',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? 'html' : 'list',
use: {
baseURL: 'http://localhost:3333',
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'yarn start',
url: 'http://localhost:3333',
timeout: 120 * 1000,
},
});

View file

@ -0,0 +1,26 @@
import { authStateFile, testUseAuthState } from './helpers';
import { test } from '@playwright/test';
testUseAuthState();
test('authenticate', async ({ page }) => {
await page.goto('/');
try {
const url = page.url();
if (url.includes('login')) {
await page.locator('[data-test-id="login"]').click();
await page.locator('.ant-input-affix-wrapper').first().click();
await page.locator('[data-test-id="login"]').fill('andrei@openreplay.com');
await page.locator('[data-test-id="password"]').click();
await page.locator('[data-test-id="password"]').fill('Andrey123!');
await page.locator('[data-test-id="log-button"]').click();
}
await page.waitForSelector('h1:has-text("Sessions")', { timeout: 10000 });
} catch (e) {}
try {
await page.context().storageState({ path: authStateFile });
} catch {}
});

View file

@ -0,0 +1,18 @@
import { mkdirSync } from "fs";
import { exists } from "i18next";
import { dirname } from "path";
import { test } from "@playwright/test";
export const authStateFile = 'node_modules/playwright/auth-state.json';
mkdirSync(dirname(authStateFile), { recursive: true });
export function testUseAuthState() {
if (exists(authStateFile)) {
test.use({ storageState: authStateFile });
}
test.afterEach(async ({ page }) => {
await page.context().storageState({ path: authStateFile });
})
}

View file

@ -0,0 +1,13 @@
import { test, expect } from '@playwright/test';
import { testUseAuthState } from './helpers';
testUseAuthState();
test('test', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'OpenReplay Documentation Site' }).click();
await page.getByRole('menuitem', { name: 'Android' }).locator('div').click();
await page.getByRole('button', { name: 'Android caret-down' }).click();
await page.getByText('OpenReplay Documentation Site').click();
await page.locator('#session-item').first().click();
});

View file

@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test';
test('Sign in flow', async ({ page }) => {
await page.goto('/');
await page.locator('[data-test-id="login"]').click();
await page.locator('.ant-input-affix-wrapper').first().click();
await page.locator('[data-test-id="login"]').fill('andrei@openreplay.com');
await page.locator('[data-test-id="password"]').click();
await page.locator('[data-test-id="password"]').fill('Andrey123!');
await page.locator('[data-test-id="log-button"]').click();
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible();
});

View file

@ -1,6 +1,6 @@
import { describe, test, expect, beforeEach, jest } from '@jest/globals';
import ListWalker from '../app/player/common/ListWalker';
import type { Timed } from '../app/player/common/types';
import ListWalker from '../../app/player/common/ListWalker';
import type { Timed } from '../../app/player/common/types';
interface Item extends Timed {
value?: string;
@ -17,24 +17,24 @@ describe('ListWalker', () => {
test('append maintains order and prevents out of order inserts', () => {
walker.append({ time: 1 });
walker.append({ time: 3 });
expect(walker.list.map(i => i.time)).toEqual([1, 3]);
expect(walker.list.map((i) => i.time)).toEqual([1, 3]);
walker.append({ time: 2 });
expect(walker.list.map(i => i.time)).toEqual([1, 3]);
expect(walker.list.map((i) => i.time)).toEqual([1, 3]);
expect((console.error as jest.Mock).mock.calls.length).toBe(1);
});
test('unshift prepends items', () => {
walker.append({ time: 2 });
walker.unshift({ time: 1 });
expect(walker.list.map(i => i.time)).toEqual([1, 2]);
expect(walker.list.map((i) => i.time)).toEqual([1, 2]);
});
test('insert places item according to time', () => {
walker.append({ time: 1 });
walker.append({ time: 3 });
walker.insert({ time: 2 });
expect(walker.list.map(i => i.time)).toEqual([1, 2, 3]);
expect(walker.list.map((i) => i.time)).toEqual([1, 2, 3]);
});
test('moveGetLast advances pointer and returns item', () => {
@ -80,4 +80,4 @@ describe('ListWalker', () => {
expect(collected).toEqual([1, 2, 1]);
expect(walker.countNow).toBe(1);
});
});
});

View file

@ -1,6 +1,6 @@
import { describe, test, expect } from '@jest/globals';
import MFileReader from '../app/player/web/messages/MFileReader';
import { MType } from '../app/player/web/messages/raw.gen';
import MFileReader from '../../app/player/web/messages/MFileReader';
import { MType } from '../../app/player/web/messages/raw.gen';
function encodeUint(value: number): Uint8Array {
const bytes: number[] = [];

View file

@ -1,13 +1,13 @@
import { describe, expect, test, jest, beforeEach } from '@jest/globals';
import MessageLoader from '../app/player/web/MessageLoader';
import { MType } from '../app/player/web/messages';
import MessageLoader from '../../app/player/web/MessageLoader';
import { MType } from '../../app/player/web/messages';
import fs from 'fs';
import path from 'path';
import { TextDecoder } from 'util';
const loadFilesMock = jest.fn(async () => {});
jest.mock('../app/player/web/network/loadFiles', () => ({
jest.mock('../../app/player/web/network/loadFiles', () => ({
__esModule: true,
loadFiles: jest.fn(async () => {}),
requestTarball: jest.fn(),
@ -17,7 +17,7 @@ jest.mock('../app/player/web/network/loadFiles', () => ({
const decryptSessionBytesMock = jest.fn((b: Uint8Array) => Promise.resolve(b));
jest.mock('../app/player/web/network/crypto', () => ({
jest.mock('../../app/player/web/network/crypto', () => ({
__esModule: true,
decryptSessionBytes: jest.fn((b: Uint8Array) => Promise.resolve(b)),
}));
@ -32,11 +32,11 @@ jest.mock('Player/common/tarball', () => ({
default: jest.fn((b: Uint8Array) => b),
}));
import MFileReader from '../app/player/web/messages/MFileReader';
import MFileReader from '../../app/player/web/messages/MFileReader';
const readNextMock = jest.fn();
jest.mock('../app/player/web/messages/MFileReader', () => {
jest.mock('../../app/player/web/messages/MFileReader', () => {
return {
__esModule: true,
default: jest.fn().mockImplementation(() => {
@ -49,7 +49,7 @@ jest.mock('../app/player/web/messages/MFileReader', () => {
};
});
import { mockSession } from './mocks/sessionData';
import { mockSession } from '../mocks/sessionData';
const createStore = () => {
const state: Record<string, any> = {};

View file

@ -1,20 +1,24 @@
import { it, expect, beforeEach, jest } from '@jest/globals';
import TabSessionManager from '../app/player/web/TabManager';
import SimpleStore from '../app/player/common/SimpleStore';
import { TYPES as EVENT_TYPES } from '../app/types/session/event';
import { MType } from '../app/player/web/messages/raw.gen';
import TabSessionManager from '../../app/player/web/TabManager';
import SimpleStore from '../../app/player/common/SimpleStore';
import { TYPES as EVENT_TYPES } from '../../app/types/session/event';
import { MType } from '../../app/player/web/messages/raw.gen';
jest.mock('@medv/finder', () => ({ default: jest.fn(() => 'mocked network-proxy content') }));
jest.mock('@medv/finder', () => ({
default: jest.fn(() => 'mocked network-proxy content'),
}));
jest.mock('syncod', () => {
return {
Decoder: jest.fn().mockImplementation(() => ({ decode: jest.fn(), set: jest.fn() })),
Decoder: jest
.fn()
.mockImplementation(() => ({ decode: jest.fn(), set: jest.fn() })),
};
});
jest.mock('js-untar', () => ({
__esModule: true,
default: jest.fn(),
}));
__esModule: true,
default: jest.fn(),
}));
class FakeScreen {
displayFrame = jest.fn();
@ -35,7 +39,14 @@ beforeEach(() => {
tabNames: {},
eventCount: 0,
});
manager = new TabSessionManager(session, store as any, new FakeScreen() as any, 'tab1', setSize, 0);
manager = new TabSessionManager(
session,
store as any,
new FakeScreen() as any,
'tab1',
setSize,
0,
);
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
@ -65,14 +76,21 @@ it('resetMessageManagers should clear managers', () => {
});
it('onFileReadSuccess should update store with lists and performance data', () => {
(manager as any).performanceTrackManager['chart'] = [{ time: 1, usedHeap: 0, totalHeap: 0, fps: null, cpu: null, nodesCount: 0 }];
(manager as any).performanceTrackManager['chart'] = [
{ time: 1, usedHeap: 0, totalHeap: 0, fps: null, cpu: null, nodesCount: 0 },
];
(manager as any).performanceTrackManager['cpuAvailable'] = true;
(manager as any).performanceTrackManager['fpsAvailable'] = true;
manager.locationManager.append({ time: 2, url: 'http://example.com' } as any);
manager.onFileReadSuccess();
const state = store.get().tabStates['tab1'];
expect(state.performanceChartData.length).toBe(1);
expect(state.performanceAvailability).toEqual({ cpu: true, fps: true, heap: false, nodes: true });
expect(state.performanceAvailability).toEqual({
cpu: true,
fps: true,
heap: false,
nodes: true,
});
expect(state.urlsList[0].url).toBe('http://example.com');
});
@ -98,4 +116,4 @@ it('sortDomRemoveMessages comparator should prioritize head nodes', () => {
expect(comparator(msgs[2], msgs[0])).toBe(1);
expect(comparator(msgs[0], msgs[1])).toBe(-1);
expect(comparator(msgs[1], msgs[0])).toBe(1);
});
});

View file

@ -33,17 +33,17 @@ class MockWebLivePlayer {
) {}
}
jest.mock('../app/player/mobile/IOSPlayer', () => ({
jest.mock('../../app/player/mobile/IOSPlayer', () => ({
__esModule: true,
default: MockIOSPlayer,
}));
jest.mock('../app/player/web/WebPlayer', () => ({
jest.mock('../../app/player/web/WebPlayer', () => ({
__esModule: true,
default: MockWebPlayer,
}));
jest.mock('../app/player/web/WebLivePlayer', () => ({
jest.mock('../../app/player/web/WebLivePlayer', () => ({
__esModule: true,
default: MockWebLivePlayer,
}));
@ -54,7 +54,7 @@ import {
createClickMapPlayer,
createLiveWebPlayer,
createClipPlayer,
} from '../app/player/create';
} from '../../app/player/create';
const session = { id: 1 } as any;
const errorHandler = { error: jest.fn() };

View file

@ -1,28 +1,30 @@
import FeatureFlag, { Conditions, Variant } from '../app/mstore/types/FeatureFlag';
import FeatureFlag, {
Conditions,
Variant,
} from '../../app/mstore/types/FeatureFlag';
import { jest, test, expect, describe } from '@jest/globals';
jest.mock('App/mstore/types/filter', () => {
let filterData = { filters: [] }
let filterData = { filters: [] };
class MockFilter {
ID_KEY = "filterId"
filterId = ''
name = ''
filters = []
eventsOrder = 'then'
eventsOrderSupport = ['then', 'or', 'and']
startTimestamp = 0
endTimestamp = 0
ID_KEY = 'filterId';
filterId = '';
name = '';
filters = [];
eventsOrder = 'then';
eventsOrderSupport = ['then', 'or', 'and'];
startTimestamp = 0;
endTimestamp = 0;
fromJson(json) {
this.name = json.name
this.filters = json.filters.map((i) => i)
this.eventsOrder = json.eventsOrder
return this
this.name = json.name;
this.filters = json.filters.map((i) => i);
this.eventsOrder = json.eventsOrder;
return this;
}
}
return MockFilter
})
return MockFilter;
});
describe('Feature flag type test', () => {
// Test cases for Conditions class

View file

@ -37,9 +37,27 @@ jest.mock('Types/filter/newFilter', () => {
const { FilterKey, FilterCategory } = require('Types/filter/filterType');
return {
filtersMap: {
[FilterKey.USERID]: { key: FilterKey.USERID, type: FilterKey.USERID, category: FilterCategory.USER, operator: 'is', value: [''] },
[FilterKey.DURATION]: { key: FilterKey.DURATION, type: FilterKey.DURATION, category: FilterCategory.SESSION, operator: 'is', value: [0, 0] },
[FilterKey.ISSUE]: { key: FilterKey.ISSUE, type: FilterKey.ISSUE, category: FilterCategory.ISSUE, operator: 'is', value: [] },
[FilterKey.USERID]: {
key: FilterKey.USERID,
type: FilterKey.USERID,
category: FilterCategory.USER,
operator: 'is',
value: [''],
},
[FilterKey.DURATION]: {
key: FilterKey.DURATION,
type: FilterKey.DURATION,
category: FilterCategory.SESSION,
operator: 'is',
value: [0, 0],
},
[FilterKey.ISSUE]: {
key: FilterKey.ISSUE,
type: FilterKey.ISSUE,
category: FilterCategory.ISSUE,
operator: 'is',
value: [],
},
},
conditionalFiltersMap: {},
generateFilterOptions: jest.fn(() => []),
@ -51,27 +69,31 @@ jest.mock('Types/filter/newFilter', () => {
const mockSessionFetch = jest.fn().mockResolvedValue({});
const mockSessionStore = {
fetchSessions: mockSessionFetch,
total: 0,
clearList: jest.fn(),
};
const mockSettingsStore = {
sessionSettings: { durationFilter: { count: 0 } },
};
fetchSessions: mockSessionFetch,
total: 0,
clearList: jest.fn(),
};
const mockSettingsStore = {
sessionSettings: { durationFilter: { count: 0 } },
};
jest.mock('App/services', () => ({
searchService: { fetchSavedSearch: jest.fn() },
sessionService: { getSessions: jest.fn().mockResolvedValue({ sessions: [], total: 0 }) },
sessionService: {
getSessions: jest.fn().mockResolvedValue({ sessions: [], total: 0 }),
},
}));
jest.mock('App/mstore', () => ({
sessionStore: mockSessionStore,
settingsStore: mockSettingsStore,
}));
import SearchStore, { checkValues, filterMap } from '../app/mstore/searchStore';
import SavedSearch from '../app/mstore/types/savedSearch';
import { FilterCategory, FilterKey } from '../app/types/filter/filterType';
import SearchStore, {
checkValues,
filterMap,
} from '../../app/mstore/searchStore';
import SavedSearch from '../../app/mstore/types/savedSearch';
import { FilterCategory, FilterKey } from '../../app/types/filter/filterType';
describe('searchStore utilities', () => {
it('checkValues handles duration', () => {
@ -113,7 +135,9 @@ describe('SearchStore class', () => {
it('applySavedSearch sets filters', () => {
const saved = new SavedSearch({
name: 'test',
filter: { filters: [{ key: FilterKey.USERID, value: ['123'], operator: 'is' }] },
filter: {
filters: [{ key: FilterKey.USERID, value: ['123'], operator: 'is' }],
},
});
store.applySavedSearch(saved);
expect(store.savedSearch).toBe(saved);
@ -129,11 +153,17 @@ describe('SearchStore class', () => {
});
it('fetchSessions applies duration filter from settings', async () => {
mockSettingsStore.sessionSettings.durationFilter = { operator: '<', count: 1, countType: 'sec' };
mockSettingsStore.sessionSettings.durationFilter = {
operator: '<',
count: 1,
countType: 'sec',
};
await store.fetchSessions();
const call = mockSessionFetch.mock.calls[0][0];
const duration = call.filters.find((f: any) => f.type === FilterKey.DURATION);
const duration = call.filters.find(
(f: any) => f.type === FilterKey.DURATION,
);
expect(duration).toBeTruthy();
expect(duration.value).toEqual([1000, 0]);
});
});
});

View file

@ -1,10 +1,10 @@
import { describe, expect, test } from '@jest/globals';
import Session from '../app/types/session';
import { Click, Location } from '../app/types/session/event';
import Issue from '../app/types/session/issue';
import { session } from './mocks/sessionResponse';
import { issues, events } from "./mocks/sessionData";
import Session from '../../app/types/session';
import { Click, Location } from '../../app/types/session/event';
import Issue from '../../app/types/session/issue';
import { session } from '../mocks/sessionResponse';
import { issues, events } from '../mocks/sessionData';
describe('Testing Session class', () => {
const sessionInfo = new Session(session.data);
@ -27,6 +27,6 @@ describe('Testing Session class', () => {
expect([...sessionInfo.issues]).toMatchObject(issues);
});
test('checking events mapping', () => {
expect([...sessionInfo.events.slice(0, 10)]).toMatchObject(events)
})
expect([...sessionInfo.events.slice(0, 10)]).toMatchObject(events);
});
});

View file

@ -1,20 +1,20 @@
import { sessionService } from '../app/services';
import Session from '../app/types/session'
import { sessionService } from '../../app/services';
import Session from '../../app/types/session';
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import SessionStore from '../app/mstore/sessionStore';
import { searchStore } from '../app/mstore/index';
import { checkEventWithFilters } from '../app/components/Session_/Player/Controls/checkEventWithFilters';
import { mockSession } from './mocks/sessionData';
import SessionStore from '../../app/mstore/sessionStore';
import { searchStore } from '../../app/mstore/index';
import { checkEventWithFilters } from '../../app/components/Session_/Player/Controls/checkEventWithFilters';
import { mockSession } from '../mocks/sessionData';
jest.mock('../app/player', () => ({
jest.mock('../../app/player', () => ({
createWebPlayer: jest.fn(),
createIOSPlayer: jest.fn(),
createClickMapPlayer: jest.fn(),
createLiveWebPlayer: jest.fn(),
createClipPlayer: jest.fn()
createClipPlayer: jest.fn(),
}));
jest.mock('../app/services', () => ({
jest.mock('../../app/services', () => ({
sessionService: {
getSessions: jest.fn(),
getLiveSessions: jest.fn(),
@ -41,7 +41,7 @@ jest.mock(
}),
);
jest.mock('../app/mstore/index', () => ({
jest.mock('../../app/mstore/index', () => ({
searchStore: {
instance: {
filters: [],

View file

@ -2,7 +2,7 @@ import { jest, beforeEach, describe, expect, it } from '@jest/globals';
import spotPlayerStore, {
PANELS,
} from '../app/components/Spots/SpotPlayer/spotPlayerStore';
} from '../../app/components/Spots/SpotPlayer/spotPlayerStore';
jest.mock('App/player', () => ({
getResourceFromNetworkRequest: jest.fn(),

View file

@ -6,11 +6,11 @@ import {
Resource,
getResourceFromResourceTiming,
getResourceFromNetworkRequest,
} from '../app/player/web/types/resource';
} from '../../app/player/web/types/resource';
import type {
ResourceTiming,
NetworkRequest,
} from '../app/player/web/messages';
} from '../../app/player/web/messages';
import { test, describe, expect } from '@jest/globals';
describe('getURLExtention', () => {

View file

@ -1,10 +1,10 @@
import { test, describe, expect } from "@jest/globals";
import { resolveCSS } from '../app/player/web/messages/rewriter/urlResolve';
import { test, describe, expect } from '@jest/globals';
import { resolveCSS } from '../../app/player/web/messages/rewriter/urlResolve';
const strings = [
`@import "custom.css";`,
`@import url("chrome://communicator/skin/");`,
`@import '../app/custom.css';`,
`@import '../../app/custom.css';`,
`@import "styles/common.css";`,
`@import "/css/commonheader.css";`,
`@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;700;900&display=swap');`,
@ -13,26 +13,26 @@ const strings = [
#login-required {
color: #fff;
};`,
`@import url("style.css") screen and (max-width: 600px);`
`@import url("style.css") screen and (max-width: 600px);`,
];
const testStrings = [
`@import url("https://example.com/custom.css");`,
`@import url("chrome://communicator/skin/");`,
`@import url('https://example.com/app/custom.css');`,
`@import url("https://example.com/styles/common.css");`,
`@import url("https://example.com/css/commonheader.css");`,
`@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;700;900&display=swap');`,
`@import url('https://example.com/css/onboardcustom.css');
`@import url("https://example.com/custom.css");`,
`@import url("chrome://communicator/skin/");`,
`@import url('https://example.com/app/custom.css');`,
`@import url("https://example.com/styles/common.css");`,
`@import url("https://example.com/css/commonheader.css");`,
`@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;700;900&display=swap');`,
`@import url('https://example.com/css/onboardcustom.css');
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;700;900&display=swap');
#login-required {
color: #fff;
};`,
`@import url("https://example.com/style.css") screen and (max-width: 600px);`
]
`@import url("https://example.com/style.css") screen and (max-width: 600px);`,
];
describe('resolveCSS', () => {
test('should rewrite the CSS with the correct URLs', () => {
strings.forEach((string, i) => {
expect(resolveCSS('https://example.com', string)).toBe(testStrings[i]);
})
})
})
});
});
});

View file

@ -39,6 +39,6 @@
"app/**/*.jsx",
"window.d.ts",
"cypress/snapshots/sessionStore.test.ts",
"tests/create.test.ts"
"tests/unit/create.test.ts"
]
}

View file

@ -2889,6 +2889,17 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:^1.52.0":
version: 1.52.0
resolution: "@playwright/test@npm:1.52.0"
dependencies:
playwright: "npm:1.52.0"
bin:
playwright: cli.js
checksum: 10c1/b7a57ce045e246d927cc8cd2091864313be6e778cc6e9f2484e6273ce091fd6eb8a68c177cf6cbd484dda68a4fa85dfaa8109ae2618475c0f07f6447b3988379
languageName: node
linkType: hard
"@rc-component/async-validator@npm:^5.0.3":
version: 5.0.4
resolution: "@rc-component/async-validator@npm:5.0.4"
@ -8669,6 +8680,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: 10c1/39f892d6e26b3d01f7e18fac9dd334d2c2a250ac2d534066e033dc5081c669b6c8b9e61f8ceb396fd9aafc210a2a3f56d8c9064768db1c1d2f34ad985c6d5b02
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"
@ -8679,6 +8700,15 @@ __metadata:
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::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<compat/fsevents>, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
@ -12885,6 +12915,7 @@ __metadata:
"@medv/finder": "npm:^4.0.2"
"@neodrag/react": "npm:^2.3.0"
"@openreplay/sourcemap-uploader": "npm:^3.0.10"
"@playwright/test": "npm:^1.52.0"
"@sentry/browser": "npm:^9.18.0"
"@svg-maps/world": "npm:^1.0.1"
"@tanstack/react-query": "npm:^5.76.0"
@ -12957,6 +12988,7 @@ __metadata:
mobx-persist-store: "npm:^1.1.8"
mobx-react-lite: "npm:^4.1.0"
node-gyp: "npm:^9.0.0"
playwright: "npm:^1.52.0"
postcss: "npm:^8.5.3"
postcss-import: "npm:^16.1.0"
postcss-loader: "npm:^8.1.1"
@ -13416,6 +13448,30 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.52.0":
version: 1.52.0
resolution: "playwright-core@npm:1.52.0"
bin:
playwright-core: cli.js
checksum: 10c1/86d3a13d6a5e426757bfd620f87f52696cec42f1e6e39e40d595b8cbab602447d1e309ad67f51e03ced147677de7824518abb9587254fb14c8b4252b7b1bae2a
languageName: node
linkType: hard
"playwright@npm:1.52.0, playwright@npm:^1.52.0":
version: 1.52.0
resolution: "playwright@npm:1.52.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.52.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c1/58f5fa976f733fd64fba716f2e605cfd1fcdffc9f874f4baa657330ff9c4e9f8b1d49c02d33fbcea48c40472cc5ed07dec8d0291dac38cc2ed579449c0053ee5
languageName: node
linkType: hard
"plist@npm:^3.0.1":
version: 3.1.0
resolution: "plist@npm:3.1.0"