diff --git a/.github/scripts/resolve-ci-scope.mjs b/.github/scripts/resolve-ci-scope.mjs index 1f5acff3..62d6b5ce 100644 --- a/.github/scripts/resolve-ci-scope.mjs +++ b/.github/scripts/resolve-ci-scope.mjs @@ -52,6 +52,7 @@ const websitePrefixes = [ 'apps/website/', ] const buddyPrefixes = [ + '.playwright/scripts/', 'apps/buddy/', 'packaging/buddy/', 'patches/', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be2c1ab4..06a0ad46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,7 @@ jobs: run: pnpm --filter @uselexora/lexora --filter '@uselexora/lexora-buddy...' install --frozen-lockfile - name: Lint workspace - run: pnpm exec eslint apps/buddy packaging/buddy + run: pnpm exec eslint .playwright/scripts apps/buddy packaging/buddy - name: Type-check workspace run: pnpm --filter @uselexora/lexora-buddy type-check @@ -177,7 +177,7 @@ jobs: fi - name: Test workspace - run: pnpm --filter @uselexora/lexora-buddy test + run: pnpm test native-test-runtime: name: Native test runtime diff --git a/.gitignore b/.gitignore index 07d42dfb..a5e8e4b6 100644 --- a/.gitignore +++ b/.gitignore @@ -151,13 +151,15 @@ components.d.ts .agents .claude .codex -.playwright +__tests__ +.playwright/* +!.playwright/scripts/ +!/.playwright/scripts/** .playwright-cli AGENTS.md CLAUDE.md skills-lock.json todos/ -__tests__ !/apps/buddy/**/__tests__/ !/apps/buddy/**/__tests__/** !/apps/buddy/native/** diff --git a/.playwright/scripts/README.md b/.playwright/scripts/README.md new file mode 100644 index 00000000..e02b98bd --- /dev/null +++ b/.playwright/scripts/README.md @@ -0,0 +1,21 @@ +# Desktop tests + +Install workspace dependencies, then run the tests: + +```sh +pnpm install +pnpm test +pnpm test:e2e +``` + +`pnpm test` runs the Vitest suites without launching a GUI. `pnpm test:e2e` builds the current Electron app and runs Playwright Test. Use `pnpm test:e2e --grep 'graceful restart'` to select a scenario. + +Linux requires `Xvfb` on `PATH`; the launcher allocates an isolated display for each instance. Electron must be able to run with its Chromium sandbox enabled. + +Test data lives under `~/.lexora-test/runs//instances/`. Set `LEXORA_TEST_RUN_ID` to group concurrent callers into one task; each application instance still receives its own directory. Restart the same instance to retain its data. + +For concurrent runners, build once with `pnpm --filter @uselexora/lexora-buddy exec electron-vite build`, then run `pnpm exec playwright test --config .playwright/scripts/playwright.config.mjs` in each runner. Do not rebuild the shared application output while tests are running. + +Successful tests remove only their own instance data. Failed tests retain it for inspection; set `LEXORA_TEST_KEEP_DATA=1` to retain successful test data too. Each invocation writes a separate Playwright report under `.playwright/runs/`, with instance diagnostics, screenshots and traces attached. + +Electron tests import `test` and `expect` from `fixtures/electron.mjs`. Scenarios belong in `__tests__/*.e2e.mjs`; helper unit tests use Vitest in `__tests__/*.spec.mjs`. diff --git a/.playwright/scripts/__tests__/desktop.e2e.mjs b/.playwright/scripts/__tests__/desktop.e2e.mjs new file mode 100644 index 00000000..c86277bc --- /dev/null +++ b/.playwright/scripts/__tests__/desktop.e2e.mjs @@ -0,0 +1,84 @@ +import { once } from 'node:events' +import fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' +import { expect, test } from '../fixtures/electron.mjs' + +test('three concurrent instances isolate their data and survive another instance crashing', async ({ buddy }) => { + const instances = await Promise.all(['first', 'second', 'third'].map(label => buddy.createInstance(label))) + const applications = await Promise.all(instances.map(instance => instance.launch())) + const snapshots = await Promise.all(applications.map(({ app }) => app.evaluate(({ app }) => ({ + name: app.getName(), + userData: app.getPath('userData'), + sessionData: app.getPath('sessionData'), + })))) + expect(new Set(snapshots.map(value => value.userData)).size).toBe(3) + expect(new Set(snapshots.map(value => value.sessionData)).size).toBe(3) + expect(snapshots.map(value => value.name)).toEqual(Array.from({ length: 3 }).fill('Lexora Buddy Test')) + await Promise.all(applications.map(async ({ app, page }, index) => { + await page.evaluate(async (index) => { + await window.lexoraDesktop.settings.update({ desktop: { profile: { userName: `Test ${index}` } } }) + localStorage.setItem('test-instance', String(index)) + }, index) + expect(await (await app.browserWindow(page)).evaluate(window => window.getTitle())).toBe('Lexora Buddy Test') + })) + await applications[0].app.evaluate(({ session }) => session.defaultSession.flushStorageData()) + const crashed = applications[0].app.process() + const exited = once(crashed, 'exit') + crashed.kill('SIGKILL') + await exited + await instances[0].stop() + for (let index = 1; index < 3; index++) { + expect(await applications[index].page.evaluate(() => localStorage.getItem('test-instance'))).toBe(String(index)) + expect((await applications[index].page.evaluate(() => window.lexoraDesktop.settings.get())).desktop.profile.userName).toBe(`Test ${index}`) + expect((await applications[index].page.evaluate(() => window.lexoraDesktop.app.startup.getState())).status).toBe('ready') + } + applications[0] = await instances[0].launch() + expect(await applications[0].page.evaluate(() => localStorage.getItem('test-instance'))).toBe('0') + expect((await applications[0].page.evaluate(() => window.lexoraDesktop.settings.get())).desktop.profile.userName).toBe('Test 0') + for (const { diagnostics } of applications) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) + +test('graceful restart preserves the same instance configuration and browser state', async ({ buddy }) => { + const instance = await buddy.createInstance('restart') + const { page } = await instance.launch() + await page.evaluate(async () => { + await window.lexoraDesktop.settings.update({ desktop: { profile: { userName: 'Restart Test' } } }) + localStorage.setItem('restart-marker', 'persisted') + }) + await instance.stop() + const restarted = await instance.launch() + expect((await restarted.page.evaluate(() => window.lexoraDesktop.settings.get())).desktop.profile.userName).toBe('Restart Test') + expect(await restarted.page.evaluate(() => localStorage.getItem('restart-marker'))).toBe('persisted') +}) + +test('test profile preserves system autostart entries even with the packaged flag', async ({ buddy }) => { + test.skip(process.platform !== 'linux', 'Linux autostart integration') + const instance = await buddy.createInstance('autostart') + const { app, page } = await instance.launch() + const require = createRequire(new URL('../../../apps/buddy/package.json', import.meta.url)) + const { desktopName } = require('./package.json') + const autostart = path.join(instance.home, '.runtime/system-config/autostart', `${desktopName}.desktop`) + await fs.mkdir(path.dirname(autostart), { recursive: true }) + await fs.writeFile(autostart, 'existing-login-entry') + const previous = await app.evaluate(({ app }, configRoot) => { + const previous = { isPackaged: app.isPackaged, configRoot: process.env.XDG_CONFIG_HOME } + app.isPackaged = true + process.env.XDG_CONFIG_HOME = configRoot + return previous + }, path.dirname(path.dirname(autostart))) + try { + await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { launchAtLogin: false } })) + expect(await fs.readFile(autostart, 'utf8')).toBe('existing-login-entry') + } + finally { + await app.evaluate(({ app }, previous) => { + app.isPackaged = previous.isPackaged + if (previous.configRoot === undefined) + delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previous.configRoot + }, previous) + } +}) diff --git a/.playwright/scripts/__tests__/testRun.spec.mjs b/.playwright/scripts/__tests__/testRun.spec.mjs new file mode 100644 index 00000000..0ff9e7e9 --- /dev/null +++ b/.playwright/scripts/__tests__/testRun.spec.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, it } from 'vitest' +import { createBuddyTestRun } from '../buddy-test.mjs' + +const temporary = [] +afterEach(async () => { + await Promise.all(temporary.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))) +}) + +async function options() { + const directory = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'lexora-test-'))) + temporary.push(directory) + return { runId: 'same-task', dataRoot: path.join(directory, 'data'), artifactRoot: path.join(directory, 'artifacts') } +} + +it('concurrent callers share a task directory but own distinct instances and cleanup', async () => { + const settings = await options() + const first = await createBuddyTestRun(settings) + const second = await createBuddyTestRun(settings) + const [a, b, c] = await Promise.all([first.createInstance('ui'), second.createInstance('ui'), second.createInstance('runtime')]) + assert.equal(first.directory, second.directory) + assert.equal(new Set([a.home, b.home, c.home]).size, 3) + await fs.writeFile(path.join(b.home, 'sentinel'), 'preserved') + await first.dispose() + await assert.rejects(fs.access(a.home), { code: 'ENOENT' }) + assert.equal(await fs.readFile(path.join(b.home, 'sentinel'), 'utf8'), 'preserved') + await second.dispose() +}) + +it('retains instance data when disposal requests preservation', async () => { + const run = await createBuddyTestRun(await options()) + const instance = await run.createInstance() + await run.dispose({ preserve: true }) + await fs.access(path.join(instance.home, 'config.toml')) +}) + +it('rejects product data roots and traversal before creating test data', async () => { + for (const name of ['.lexora', '.lexora-dev']) + await assert.rejects(createBuddyTestRun({ dataRoot: path.join(homedir(), name) }), /must not overlap/) + await assert.rejects(createBuddyTestRun({ ...await options(), runId: '../escape' }), /Invalid test run ID/) +}) + +it('cleanup refuses a replaced instance directory', async () => { + const run = await createBuddyTestRun(await options()) + const instance = await run.createInstance() + const backup = `${instance.home}-original` + await fs.rename(instance.home, backup) + await fs.mkdir(instance.home) + await fs.writeFile(path.join(instance.home, 'sentinel'), 'replacement') + await assert.rejects(run.dispose(), /Could not clean up test instances/) + assert.equal(await fs.readFile(path.join(instance.home, 'sentinel'), 'utf8'), 'replacement') + await fs.access(backup) +}) + +it('disposal waits for pending allocations and rejects late allocations and launches', async () => { + const run = await createBuddyTestRun(await options()) + const creating = run.createInstance() + await run.dispose() + const instance = await creating + await assert.rejects(fs.access(instance.home), { code: 'ENOENT' }) + await assert.rejects(run.createInstance(), /disposed/) + await assert.rejects(instance.launch(), /disposed/) +}) diff --git a/.playwright/scripts/buddy-test.mjs b/.playwright/scripts/buddy-test.mjs new file mode 100644 index 00000000..30c799c9 --- /dev/null +++ b/.playwright/scripts/buddy-test.mjs @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import { homedir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { attachPageDiagnostics, ROOT, serializeError } from './shared.mjs' + +const repoRoot = ROOT +const buddyRoot = path.join(repoRoot, 'apps/buddy') +const require = createRequire(path.join(buddyRoot, 'package.json')) +const defaultConfig = '[desktop]\nlanguage = "zh-CN"\ntheme = "light"\nnotifications_enabled = false\nlaunch_at_login = false\n[pet]\nenabled = false\n[proxy]\nmode = "direct"\nserver = ""\n' + +export async function createBuddyTestRun({ runId = process.env.LEXORA_TEST_RUN_ID, dataRoot = path.join(homedir(), '.lexora-test'), artifactRoot = path.join(repoRoot, '.playwright/runs', randomUUID()) } = {}) { + runId ??= `${new Date().toISOString().replaceAll(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}` + assert(/^[\w-]{1,100}$/.test(runId), 'Invalid test run ID') + const root = await canonicalPath(path.resolve(dataRoot)) + for (const reserved of ['.lexora', '.lexora-dev']) { + const protectedRoot = await canonicalPath(path.join(homedir(), reserved)) + assert(!contains(root, protectedRoot) && !contains(protectedRoot, root), 'Test data must not overlap user profiles') + } + const directory = path.join(root, 'runs', runId) + const instancesRoot = path.join(directory, 'instances') + const artifacts = path.resolve(artifactRoot) + assert.equal(await canonicalPath(instancesRoot), instancesRoot, 'Test run directories must not redirect through links') + await fs.mkdir(instancesRoot, { recursive: true, mode: 0o700 }) + await fs.mkdir(artifacts, { recursive: true, mode: 0o700 }) + const instances = [] + const pending = new Set() + const records = [] + let disposed = false + async function createInstance(label) { + assert(/^[\w-]{1,64}$/.test(label), 'Invalid test instance name') + const home = await fs.mkdtemp(path.join(instancesRoot, `${label}-`)) + const identity = await fs.stat(home) + await fs.writeFile(path.join(home, 'config.toml'), defaultConfig, { mode: 0o600 }) + const artifactDirectory = path.join(artifacts, path.basename(home)) + await fs.mkdir(artifactDirectory, { mode: 0o700 }) + const record = { home, artifactDirectory, launches: [] } + records.push(record) + let application + let page + let display + let launchRecord + let launchNumber = 0 + let launching + const instance = { + home, + artifactDirectory, + async launch() { + assert(!disposed, 'Test run has been disposed') + assert(!application && !launching, 'Stop the previous application before restarting this instance') + launching = launch() + try { + return await launching + } + finally { launching = undefined } + }, + async stop() { + await launching?.catch(() => {}) + await stop() + }, + async remove() { + assert(!application && !display && !launching, 'Cannot remove a running test instance') + assert.equal(await fs.realpath(home), home, 'Test instance was replaced with a link') + const current = await fs.stat(home) + assert.equal(current.ino, identity.ino, 'Test instance directory was replaced') + assert.equal(current.dev, identity.dev, 'Test instance volume changed') + await fs.rm(home, { recursive: true }) + record.removed = true + }, + } + instances.push(instance) + return instance + + async function launch() { + const { _electron: electron } = await import('@playwright/test') + const environment = createTestEnvironment(home) + if (process.platform === 'linux') { + display = await startDisplay() + environment.DISPLAY = display.address + } + launchRecord = { startedAt: new Date().toISOString(), diagnostics: undefined, stderr: '' } + record.launches.push(launchRecord) + launchNumber += 1 + try { + application = await electron.launch({ + executablePath: require('electron'), + args: [...(process.platform === 'linux' ? ['--ozone-platform=x11', '--disable-setuid-sandbox'] : []), '--disable-gpu', buddyRoot], + chromiumSandbox: true, + cwd: repoRoot, + env: environment, + timeout: 45000, + }) + launchRecord.pid = application.process().pid + application.process().stderr?.on('data', (chunk) => { + launchRecord.stderr = `${launchRecord.stderr}${chunk}`.slice(-65536) + }) + await application.context().tracing.start({ screenshots: true, snapshots: true }) + page = await application.firstWindow({ timeout: 45000 }) + launchRecord.diagnostics = attachPageDiagnostics(page) + page.setDefaultTimeout(30000) + await page.waitForFunction(() => location.protocol === 'lexora-app:' || location.protocol === 'data:', undefined, { timeout: 45000 }) + assert.equal(new URL(page.url()).protocol, 'lexora-app:', `Application failed to start: ${await page.locator('body').textContent()}`) + await page.waitForFunction(async () => (await window.lexoraDesktop?.app.startup.getState())?.status === 'ready', undefined, { timeout: 45000 }) + const info = await page.evaluate(() => window.lexoraDesktop.app.getInfo()) + assert.equal(info.runtimeProfile, 'test') + assert.equal(await fs.realpath(path.dirname(info.configPath)), home) + const { runtimeProfile, isPackaged, version, platform, configPath } = info + launchRecord.application = { runtimeProfile, isPackaged, version, platform, configPath } + return { app: application, page, diagnostics: launchRecord.diagnostics } + } + catch (error) { + launchRecord.error = serializeError(error) + await stop().catch(() => {}) + throw error + } + } + async function stop() { + if (application) { + const current = application + const child = current.process() + if (child.exitCode === null && child.signalCode === null) { + await page?.screenshot({ path: path.join(artifactDirectory, `launch-${launchNumber}.png`), animations: 'disabled', timeout: 5000 }).catch(() => {}) + await withTimeout(current.context().tracing.stop({ path: path.join(artifactDirectory, `trace-${launchNumber}.zip`) }), 10000, 'Trace export timed out').catch(() => {}) + try { + await withTimeout(current.close(), 15000, 'Electron did not stop') + } + catch (error) { + await terminate(child) + launchRecord.stopError = serializeError(error) + throw error + } + finally { + application = undefined + page = undefined + await display?.stop() + display = undefined + } + } + application = undefined + page = undefined + } + await display?.stop() + display = undefined + } + } + return { + directory, + artifacts, + runId, + records, + async createInstance(label = 'desktop') { + assert(!disposed, 'Test run has been disposed') + const operation = createInstance(label) + pending.add(operation) + try { + return await operation + } + finally { pending.delete(operation) } + }, + async dispose({ preserve = false } = {}) { + disposed = true + await Promise.allSettled(pending) + const stopped = await Promise.allSettled(instances.map(instance => instance.stop())) + const failures = stopped.filter(result => result.status === 'rejected').map(result => result.reason) + if (!failures.length && !preserve) { + for (const instance of instances) { + try { + await instance.remove() + } + catch (failure) { failures.push(failure) } + } + } + if (failures.length) + throw new AggregateError(failures, `Could not clean up test instances in ${directory}`) + }, + } +} + +function createTestEnvironment(home) { + const environment = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== undefined + && !/^(?:LEXORA_|ELECTRON_|PI_|XDG_|WAYLAND_DISPLAY$)/.test(key))) + return { ...environment, LEXORA_BUDDY_PROFILE: 'test', LEXORA_HOME: home } +} + +function contains(root, candidate) { + const relative = path.relative(root, candidate) + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)) +} + +async function canonicalPath(value) { + try { + return await fs.realpath(value) + } + catch (error) { + if (error.code !== 'ENOENT' || path.dirname(value) === value) + throw error + if ((await fs.lstat(value).catch((failure) => { + if (failure.code !== 'ENOENT') + throw failure + }))?.isSymbolicLink()) { + throw new Error('Test paths must not use dangling links') + } + return path.join(await canonicalPath(path.dirname(value)), path.basename(value)) + } +} + +async function startDisplay() { + const child = spawn('Xvfb', ['-displayfd', '3', '-screen', '0', '1500x950x24', '-nolisten', 'tcp'], { stdio: ['ignore', 'ignore', 'pipe', 'pipe'] }) + let stderr = '' + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-4096) + }) + try { + const address = await withTimeout(new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', code => reject(new Error(`Xvfb exited (${code}): ${stderr}`))) + let output = '' + child.stdio[3].on('data', (chunk) => { + output += chunk + if (/^\d+\n$/.test(output)) + resolve(`:${output.trim()}`) + }) + }), 10000, 'Xvfb did not start') + return { address, stop: () => terminate(child) } + } + catch (error) { + await terminate(child) + throw error + } +} + +async function terminate(child) { + if (child.exitCode !== null || child.signalCode !== null || !child.pid) + return + const exited = once(child, 'exit') + child.kill('SIGTERM') + try { + await withTimeout(exited, 3000, 'Process did not stop') + } + catch { + child.kill('SIGKILL') + await withTimeout(exited, 3000, 'Process did not exit after termination') + } +} + +async function withTimeout(promise, milliseconds, message) { + let timer + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), milliseconds) + })]) + } + finally { clearTimeout(timer) } +} diff --git a/.playwright/scripts/fixtures/electron.mjs b/.playwright/scripts/fixtures/electron.mjs new file mode 100644 index 00000000..9ea1613a --- /dev/null +++ b/.playwright/scripts/fixtures/electron.mjs @@ -0,0 +1,37 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { test as base } from '@playwright/test' +import { createBuddyTestRun } from '../buddy-test.mjs' + +export const test = base.extend({ + buddy: [async ({ playwright: _playwright }, use, testInfo) => { + const run = await createBuddyTestRun({ artifactRoot: testInfo.outputPath('desktop') }) + try { + await use(run) + } + finally { + try { + await run.dispose({ preserve: testInfo.status !== 'passed' || process.env.LEXORA_TEST_KEEP_DATA === '1' }) + } + finally { + const metadata = testInfo.outputPath('instances.json') + await fs.writeFile(metadata, JSON.stringify({ runId: run.runId, instances: run.records }, null, 2), { mode: 0o600 }) + await testInfo.attach('instances', { + path: metadata, + contentType: 'application/json', + }) + for (const record of run.records) { + for (const file of await fs.readdir(record.artifactDirectory)) { + await testInfo.attach(`${path.basename(record.home)}-${file}`, { + path: path.join(record.artifactDirectory, file), + contentType: file.endsWith('.png') ? 'image/png' : 'application/zip', + }) + } + } + } + } + }, { timeout: 180000 }], +}) + +export { expect } from '@playwright/test' diff --git a/.playwright/scripts/playwright.config.mjs b/.playwright/scripts/playwright.config.mjs new file mode 100644 index 00000000..072fc510 --- /dev/null +++ b/.playwright/scripts/playwright.config.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import process from 'node:process' +import { defineConfig } from '@playwright/test' +import { ROOT } from './shared.mjs' + +const runId = process.env.LEXORA_TEST_RUN_ID ??= `${new Date().toISOString().replaceAll(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}` +assert(/^[\w-]{1,100}$/.test(runId), 'Invalid test run ID') +const invocationId = process.env.LEXORA_TEST_INVOCATION_ID ??= randomUUID() +assert(/^[\w-]{1,100}$/.test(invocationId), 'Invalid test invocation ID') +const artifacts = path.join(ROOT, '.playwright/runs', runId, invocationId) + +export default defineConfig({ + testDir: './__tests__', + testMatch: '**/*.e2e.mjs', + outputDir: path.join(artifacts, 'results'), + reporter: [['list'], ['html', { outputFolder: path.join(artifacts, 'report'), open: 'never' }]], + forbidOnly: !!process.env.CI, + workers: 1, + retries: 0, + timeout: 120000, + expect: { timeout: 10000 }, + projects: [{ name: 'buddy' }], +}) diff --git a/.playwright/scripts/shared.mjs b/.playwright/scripts/shared.mjs new file mode 100644 index 00000000..6950a528 --- /dev/null +++ b/.playwright/scripts/shared.mjs @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url' + +export const ROOT = fileURLToPath(new URL('../../', import.meta.url)) + +export function attachPageDiagnostics(page) { + const diagnostics = { console: [], requestFailed: [] } + page.on('console', message => diagnostics.console.push({ type: message.type(), text: message.text() })) + page.on('pageerror', error => diagnostics.console.push({ type: 'pageerror', text: error.message })) + page.on('requestfailed', request => diagnostics.requestFailed.push({ method: request.method(), url: request.url(), failure: request.failure()?.errorText ?? null })) + return diagnostics +} + +export function serializeError(error) { + return error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : { message: String(error) } +} diff --git a/apps/buddy/electron/main/__tests__/ipc.spec.ts b/apps/buddy/electron/main/__tests__/ipc.spec.ts index 6739778c..3f1ad535 100644 --- a/apps/buddy/electron/main/__tests__/ipc.spec.ts +++ b/apps/buddy/electron/main/__tests__/ipc.spec.ts @@ -9,7 +9,7 @@ const electron = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ - app: { getVersion: () => '0.1.0' }, + app: { getVersion: () => '0.1.0', isPackaged: false }, clipboard: { writeText: electron.writeText }, ipcMain: { handle: vi.fn((channel, handler) => electron.handlers.set(channel, handler)), @@ -27,6 +27,7 @@ describe('registerDesktopIpc', () => { registerDesktopIpc({ checkForUpdates: vi.fn(), configPath: '/home/example/.lexora/config.toml', + runtimeProfile: 'test', configStore: { read: vi.fn(), update: vi.fn(), @@ -76,6 +77,8 @@ describe('registerDesktopIpc', () => { throw new Error('Get info IPC handler was not registered') const info = await (getInfo(trustedEvent) as Promise) as Record expect(info).toMatchObject({ + runtimeProfile: 'test', + isPackaged: false, configPath: '/home/example/.lexora/config.toml', version: '0.1.0', systemProfile: { diff --git a/apps/buddy/electron/main/__tests__/paths.spec.ts b/apps/buddy/electron/main/__tests__/paths.spec.ts index 5b320222..212052c0 100644 --- a/apps/buddy/electron/main/__tests__/paths.spec.ts +++ b/apps/buddy/electron/main/__tests__/paths.spec.ts @@ -1,5 +1,8 @@ import { Buffer } from 'node:buffer' -import { dirname } from 'node:path' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import process from 'node:process' import { describe, expect, it } from 'vitest' import { isLocalNamedPipe as isWindowsPipe } from '../../../shared/platform/localEndpoint' import { resolveBuddyRuntimePaths } from '../paths' @@ -46,7 +49,6 @@ describe('resolveBuddyRuntimePaths', () => { configPath: '/home/lexora/.lexora-dev/config.toml', crashDumps: '/var/state/user/lexora-buddy-dev/crashes', desktopName: 'site.haohaoxue.LexoraBuddy.Development', - iconVariant: 'development', lexoraHome: '/home/lexora/.lexora-dev', logs: '/var/state/user/lexora-buddy-dev/logs', namespace: 'lexora-buddy-dev', @@ -71,7 +73,6 @@ describe('resolveBuddyRuntimePaths', () => { configPath: '/home/lexora/.lexora/config.toml', crashDumps: '/var/state/user/lexora-buddy/crashes', desktopName: 'site.haohaoxue.LexoraBuddy', - iconVariant: 'stable', lexoraHome: '/home/lexora/.lexora', logs: '/var/state/user/lexora-buddy/logs', namespace: 'lexora-buddy', @@ -115,7 +116,6 @@ describe('resolveBuddyRuntimePaths', () => { configPath: '/tmp/lexora-smoke/home/config.toml', crashDumps: '/tmp/lexora-smoke/home/.runtime/state/crashes', desktopName: 'site.haohaoxue.LexoraBuddy.Test', - iconVariant: 'stable', lexoraHome: '/tmp/lexora-smoke/home', logs: '/tmp/lexora-smoke/home/.runtime/state/logs', namespace: 'lexora-buddy-test', @@ -174,6 +174,37 @@ describe('resolveBuddyRuntimePaths', () => { })).toThrow('LEXORA_HOME is required for the test profile') }) + it('rejects test roots that overlap product profiles, including ancestors and symbolic links', () => { + for (const root of ['/home/lexora/.lexora', '/home/lexora/.lexora-dev/buddy', '/home/lexora', '/var/cache/user/lexora-buddy-dev/chromium']) { + expect(() => resolveBuddyRuntimePaths({ ...BASE_OPTIONS, profileOverride: 'test', lexoraHomeOverride: root })).toThrow('must not overlap') + } + if (process.platform !== 'linux') + return + const temporary = mkdtempSync(join(tmpdir(), 'buddy-profile-')) + try { + mkdirSync(join(temporary, '.lexora')) + symlinkSync(join(temporary, '.lexora'), join(temporary, 'linked'), 'junction') + expect(() => resolveBuddyRuntimePaths({ ...BASE_OPTIONS, userHome: temporary, profileOverride: 'test', lexoraHomeOverride: join(temporary, 'linked') })).toThrow('must not overlap') + const testHome = join(temporary, 'test') + mkdirSync(join(testHome, '.runtime'), { recursive: true }) + symlinkSync(join(temporary, '.lexora'), join(testHome, '.runtime/cache'), 'junction') + expect(() => resolveBuddyRuntimePaths({ ...BASE_OPTIONS, userHome: temporary, profileOverride: 'test', lexoraHomeOverride: testHome })).toThrow('must not overlap') + } + finally { rmSync(temporary, { recursive: true, force: true }) } + }) + + it('isolates three test instances and preserves their paths across restarts', () => { + const options = { ...BASE_OPTIONS, profileOverride: 'test', isPackaged: true } + const roots = ['one', 'two', 'three'].map(name => `/home/lexora/.lexora-test/runs/task/instances/${name}`) + const instances = roots.map(lexoraHomeOverride => resolveBuddyRuntimePaths({ ...options, lexoraHomeOverride })) + for (const key of ['buddyHome', 'configPath', 'userData', 'sessionData', 'browserAdapterSocket', 'nativePetSocket'] as const) + expect(new Set(instances.map(paths => paths[key])).size).toBe(3) + expect(resolveBuddyRuntimePaths({ ...options, lexoraHomeOverride: roots[0] })).toEqual(instances[0]) + expect(() => resolveBuddyRuntimePaths({ ...options, lexoraHomeOverride: roots[0], userDataOverride: '/tmp/shared-browser' })).toThrow('state must stay inside') + expect(() => resolveBuddyRuntimePaths({ ...options, lexoraHomeOverride: roots[0], nativePetSocketOverride: '/run/user/1000/lexora-buddy/native-pet.sock' })).toThrow('must not overlap') + expect(() => resolveBuddyRuntimePaths({ ...options, smokeTest: true, profileOverride: 'stable', lexoraHomeOverride: roots[0] })).toThrow('requires the test profile') + }) + it('isolates Windows profiles and uses named pipes without pet paths', () => { const options = { ...BASE_OPTIONS, diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index b21da088..b57e5935 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -74,7 +74,7 @@ export class DesktopIntegrations { const extensions = registerExtensionIpc({ home: paths.buddyHome, version: buddyVersion.version, - developmentDirectory: !app.isPackaged && process.env.LEXORA_BUDDY_PROFILE === 'test' && process.env.LEXORA_HOME ? process.env.LEXORA_EXTENSION_DEVELOPMENT_PATH : undefined, + developmentDirectory: !app.isPackaged && paths.profile === 'test' ? process.env.LEXORA_EXTENSION_DEVELOPMENT_PATH : undefined, getWindow: () => windows.window, get: runtime.network.get, notificationsEnabled: () => runtime.config?.desktop.notificationsEnabled ?? true, @@ -132,6 +132,7 @@ export class DesktopIntegrations { setupSandbox: () => this.#runtime.setupSandbox(), checkForUpdates: () => checkForDesktopUpdate({ currentVersion: app.getVersion(), fetchRelease: runtime.network.get }), configPath: paths.configPath, + runtimeProfile: paths.profile, configStore: runtime.configStore, executeCommand: this.executeCommand, getWindow: () => windows.window, diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index 9021f935..2f2e2635 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -209,7 +209,7 @@ export class DesktopRuntimeHost { if (previous?.cacheWarming !== config.runtime.cacheWarming) this.#service?.notify(runtimePreferencesRpc.changed, config.runtime) await Promise.all(this.#features.map(feature => feature.applyConfig(config))) - if (app.isPackaged && !this.#environment.isSmokeTest) + if (app.isPackaged && this.#environment.paths.profile === 'stable') await this.#environment.setAutostart(config.desktop.launchAtLogin) } diff --git a/apps/buddy/electron/main/app/desktopIcons.ts b/apps/buddy/electron/main/app/desktopIcons.ts new file mode 100644 index 00000000..b1e1af19 --- /dev/null +++ b/apps/buddy/electron/main/app/desktopIcons.ts @@ -0,0 +1,13 @@ +import type { BuddyRuntimeProfile } from '../../../shared/runtime/profile' +import developmentApp from '../../../resources/icons/app-icon-dev.png?asset' +import testApp from '../../../resources/icons/app-icon-test.png?asset' +import stableApp from '../../../resources/icons/app-icon.png?asset' +import developmentTray from '../../../resources/icons/tray-icon-dev.png?asset' +import testTray from '../../../resources/icons/tray-icon-test.png?asset' +import stableTray from '../../../resources/icons/tray-icon.png?asset' + +export const desktopIcons: Record = { + stable: { app: stableApp, tray: stableTray }, + development: { app: developmentApp, tray: developmentTray }, + test: { app: testApp, tray: testTray }, +} diff --git a/apps/buddy/electron/main/app/environment.ts b/apps/buddy/electron/main/app/environment.ts index 54e8cc17..310083b5 100644 --- a/apps/buddy/electron/main/app/environment.ts +++ b/apps/buddy/electron/main/app/environment.ts @@ -7,9 +7,6 @@ import { app, crashReporter, Menu, protocol } from 'electron' import buddyPackage from '../../../package.json' import { currentPlatform } from '../../../platform/currentPlatform' import { resolveBuddyPrivateDirectories } from '../../../platform/native/nativeHost' -import developmentDesktopIconPath from '../../../resources/icons/app-icon-dev.png?asset' -import stableDesktopIconPath from '../../../resources/icons/app-icon.png?asset' -import developmentTrayIconPath from '../../../resources/icons/tray-icon-dev.png?asset' import { readDiagnosticError } from '../../../shared/diagnostics/applicationDiagnostic' import { ApplicationEvents } from '../../../shared/observability/ApplicationEvents' import { OPERATING_SYSTEM } from '../../../shared/platform/identifiers' @@ -21,6 +18,7 @@ import { desktopHosts } from '../platform/desktopHost' import { rendererSchemePrivileges } from '../rendererProtocol' import { resolveDesktopLaunchIntent } from '../startupIntent' import { bootstrapStep } from './desktopBootstrap' +import { desktopIcons } from './desktopIcons' import { DesktopStartup } from './DesktopStartup' import { checkDesktopDirectories, criticalDesktopDirectories, prepareDesktopPrivateStorage } from './desktopStorage' @@ -61,13 +59,13 @@ export function prepareDesktopEnvironment(): DesktopEnvironment { diagnostics, events, startup, - desktopIconPath: paths.iconVariant === 'development' ? developmentDesktopIconPath : stableDesktopIconPath, + desktopIconPath: desktopIcons[paths.profile].app, initialLaunchIntent: resolveDesktopLaunchIntent(process.argv, currentPlatform.id === OPERATING_SYSTEM.MacOS && app.getLoginItemSettings().wasOpenedAtLogin), isSmokeTest, paths, windowStateAvailable: true, setAutostart: desktopHost.setAutostart, - trayIconPath: paths.iconVariant === 'development' ? developmentTrayIconPath : stableDesktopIconPath, + trayIconPath: desktopIcons[paths.profile].tray, } } diff --git a/apps/buddy/electron/main/ipc.ts b/apps/buddy/electron/main/ipc.ts index dc802e60..34197dd0 100644 --- a/apps/buddy/electron/main/ipc.ts +++ b/apps/buddy/electron/main/ipc.ts @@ -1,4 +1,5 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import type { BuddyRuntimeProfile } from '../../shared/runtime/profile' import type { LexoraConfig } from '../shared/desktopApi' import type { LexoraConfigStore } from './config/LexoraConfigStore' import type { ExecuteDesktopCommand } from './desktopCommands' @@ -25,6 +26,7 @@ export interface RegisterDesktopIpcOptions { getSandboxStatus: () => Promise setupSandbox: () => Promise configPath: string + runtimeProfile: BuddyRuntimeProfile configStore: LexoraConfigStore getWindow: () => BrowserWindow | null onConfigUpdated: (config: LexoraConfig) => Promise | void @@ -53,6 +55,8 @@ export function registerDesktopIpc(options: RegisterDesktopIpcOptions): void { capabilities: describeBuddyCapabilities(currentPlatform), chromiumVersion: process.versions.chrome, configPath: options.configPath, + runtimeProfile: options.runtimeProfile, + isPackaged: app.isPackaged, electronVersion: process.versions.electron, nodeVersion: process.versions.node, platform: currentPlatform.id, diff --git a/apps/buddy/electron/main/paths.ts b/apps/buddy/electron/main/paths.ts index 104f5071..23111d38 100644 --- a/apps/buddy/electron/main/paths.ts +++ b/apps/buddy/electron/main/paths.ts @@ -1,9 +1,12 @@ +import type { BuddyRuntimeProfile } from '../../shared/runtime/profile' +import { lstatSync, realpathSync } from 'node:fs' import process from 'node:process' import { resolveBuddyPlatform, supportsBuddyFeature } from '../../shared/platform' +import { BUDDY_RUNTIME_PROFILES } from '../../shared/runtime/profile' import { runtimePathLayouts } from './platform/runtimePathLayouts' -export type BuddyRuntimeProfile = 'development' | 'stable' | 'test' -type PlatformPath = Pick +export type { BuddyRuntimeProfile } from '../../shared/runtime/profile' +type PlatformPath = Pick export interface BuddyRuntimePathOptions { defaultUserData: string @@ -34,7 +37,6 @@ export interface BuddyRuntimePaths { configPath: string crashDumps: string desktopName: string - iconVariant: 'development' | 'stable' lexoraHome: string logs: string namespace: string @@ -49,17 +51,10 @@ export interface BuddyRuntimePaths { export interface BuddyRuntimeIdentity { appName: string desktopName: string - iconVariant: 'development' | 'stable' namespace: string profile: BuddyRuntimeProfile } -const BUDDY_RUNTIME_PROFILES = new Set([ - 'development', - 'stable', - 'test', -]) - export function resolveBuddyRuntimePaths( options: BuddyRuntimePathOptions, ): BuddyRuntimePaths { @@ -90,13 +85,38 @@ export function resolveBuddyRuntimePaths( path, ) ?? runtimeDirectories.userData const buddyHome = joinPath(lexoraHome, 'buddy') + const configPath = joinPath(lexoraHome, 'config.toml') + + if (identity.profile === 'test') { + const canonical = (value: string) => canonicalTestPath(value, path, platform.id === process.platform) + const statePaths = [buddyHome, configPath, userData, runtimeDirectories.sessionData, runtimeDirectories.stateRoot, nativePetState].filter(value => value !== null) + const socketPaths = platform.id === 'win32' ? [] : [nativePetSocket, runtimeDirectories.browserAdapterSocket].filter(value => value !== null) + const protectedRoots = (['stable', 'development'] as const).flatMap((profile) => { + const protectedIdentity = resolveBuddyRuntimeIdentity({ ...options, smokeTest: false, profileOverride: profile }) + const home = joinPath(options.userHome, profile === 'stable' ? '.lexora' : '.lexora-dev') + return [options, { ...options, xdgCacheHome: undefined, xdgConfigHome: undefined, xdgStateHome: undefined }].flatMap((directoryOptions) => { + const directories = layout.resolveDirectories(protectedIdentity, home, directoryOptions) + const sockets = platform.id === 'win32' ? [] : [directories.nativePetSocket, directories.browserAdapterSocket] + return [home, directories.userData, directories.sessionData, directories.stateRoot, ...sockets].filter(value => value !== null).map(canonical) + }) + }) + for (const candidate of [lexoraHome, ...statePaths, ...socketPaths]) { + const target = canonical(candidate) + if (protectedRoots.some(root => containsPath(root, target, path) || containsPath(target, root, path))) + throw new Error('Test profile paths must not overlap stable or development data') + } + for (const candidate of statePaths) { + if (!containsPath(canonical(lexoraHome), canonical(candidate), path)) + throw new Error('Test profile state must stay inside LEXORA_HOME') + } + } return { ...identity, agentDirectory: joinPath(buddyHome, 'agent'), browserAdapterSocket: runtimeDirectories.browserAdapterSocket, buddyHome, - configPath: joinPath(lexoraHome, 'config.toml'), + configPath, crashDumps: joinPath(runtimeDirectories.stateRoot, 'crashes'), lexoraHome, logs: joinPath(runtimeDirectories.stateRoot, 'logs'), @@ -116,7 +136,6 @@ function resolveBuddyRuntimeIdentity( return { appName: 'Lexora Buddy', desktopName: options.desktopName, - iconVariant: 'stable', namespace: 'lexora-buddy', profile, } @@ -125,7 +144,6 @@ function resolveBuddyRuntimeIdentity( return { appName: 'Lexora Buddy Dev', desktopName: `${options.desktopName}.Development`, - iconVariant: 'development', namespace: 'lexora-buddy-dev', profile, } @@ -133,7 +151,6 @@ function resolveBuddyRuntimeIdentity( return { appName: 'Lexora Buddy Test', desktopName: `${options.desktopName}.Test`, - iconVariant: 'stable', namespace: 'lexora-buddy-test', profile, } @@ -142,15 +159,41 @@ function resolveBuddyRuntimeIdentity( function resolveBuddyRuntimeProfile( options: BuddyRuntimePathOptions, ): BuddyRuntimeProfile { - if (options.smokeTest) + if (options.smokeTest) { + if (options.profileOverride && options.profileOverride !== 'test') + throw new Error('Smoke verification requires the test profile') return 'test' + } if (options.profileOverride === undefined) return options.isPackaged ? 'stable' : 'development' - if (!BUDDY_RUNTIME_PROFILES.has(options.profileOverride as BuddyRuntimeProfile)) + if (!BUDDY_RUNTIME_PROFILES.includes(options.profileOverride as BuddyRuntimeProfile)) throw new Error('LEXORA_BUDDY_PROFILE must be stable, development, or test') return options.profileOverride as BuddyRuntimeProfile } +function containsPath(root: string, candidate: string, path: PlatformPath): boolean { + const relative = path.relative(root, candidate) + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)) +} + +function canonicalTestPath(value: string, path: PlatformPath, nativePlatform: boolean): string { + if (!nativePlatform) + return value + try { + return realpathSync.native(value) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + throw error + if (lstatSync(value, { throwIfNoEntry: false })?.isSymbolicLink()) + throw new Error('Test profile paths must not use dangling symbolic links') + const parent = path.dirname(value) + if (parent === value) + throw error + return path.join(canonicalTestPath(parent, path, true), path.basename(value)) + } +} + function resolveLexoraHome( profile: BuddyRuntimeProfile, options: BuddyRuntimePathOptions, diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index 1c9da186..5e8e1792 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -4,6 +4,7 @@ import type { ApplicationLogApi } from '../../shared/diagnostics/applicationLog' import type { ApplicationStartupState } from '../../shared/diagnostics/applicationStartup' import type { SandboxEnvironmentStatus, SandboxSetupResult } from '../../shared/permissions/shellSandbox' import type { BuddyCapabilities } from '../../shared/platform' +import type { BuddyRuntimeProfile } from '../../shared/runtime/profile' import type { DesktopCommandId, DesktopPlatform } from './desktopCommands' import type { LocalChatApi } from './localChatApi' @@ -92,6 +93,8 @@ export interface DesktopUserProfileConfig { } export interface DesktopAppInfo { + runtimeProfile: BuddyRuntimeProfile + isPackaged: boolean capabilities: BuddyCapabilities chromiumVersion: string configPath: string diff --git a/apps/buddy/resources/icons/app-icon-test.png b/apps/buddy/resources/icons/app-icon-test.png new file mode 100644 index 00000000..cc35f55c Binary files /dev/null and b/apps/buddy/resources/icons/app-icon-test.png differ diff --git a/apps/buddy/resources/icons/tray-icon-test.png b/apps/buddy/resources/icons/tray-icon-test.png new file mode 100644 index 00000000..1654ae35 Binary files /dev/null and b/apps/buddy/resources/icons/tray-icon-test.png differ diff --git a/apps/buddy/resources/icons/tray-icon.png b/apps/buddy/resources/icons/tray-icon.png new file mode 100644 index 00000000..c1d1c12c Binary files /dev/null and b/apps/buddy/resources/icons/tray-icon.png differ diff --git a/apps/buddy/shared/runtime/profile.ts b/apps/buddy/shared/runtime/profile.ts new file mode 100644 index 00000000..90752eee --- /dev/null +++ b/apps/buddy/shared/runtime/profile.ts @@ -0,0 +1,3 @@ +export const BUDDY_RUNTIME_PROFILES = ['stable', 'development', 'test'] as const + +export type BuddyRuntimeProfile = typeof BUDDY_RUNTIME_PROFILES[number] diff --git a/apps/buddy/vite.config.ts b/apps/buddy/vite.config.ts index 9ee21a14..722f193c 100644 --- a/apps/buddy/vite.config.ts +++ b/apps/buddy/vite.config.ts @@ -48,7 +48,10 @@ export default defineConfig({ extends: true, test: { name: 'unit', - include: ['{src,shared,eslint,__tests__}/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + include: [ + '{src,shared,eslint,__tests__}/**/*.{test,spec}.?(c|m)[jt]s?(x)', + '../../.playwright/scripts/__tests__/*.spec.mjs', + ], }, }, { diff --git a/package.json b/package.json index d38029b5..f5acb7eb 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "check:website": "pnpm --filter @uselexora/lexora-website lint && pnpm --filter @uselexora/lexora-website type-check && pnpm --filter @uselexora/lexora-website build", "test": "pnpm --filter @uselexora/lexora-buddy test", "test:watch": "pnpm --filter @uselexora/lexora-buddy test:watch", - "lint": "eslint eslint.config.js .github/scripts apps/buddy apps/website packages/assets packaging infrastructure/scripts", + "test:e2e": "pnpm --filter @uselexora/lexora-buddy exec electron-vite build && playwright test --config .playwright/scripts/playwright.config.mjs", + "lint": "eslint eslint.config.js .github/scripts .playwright/scripts apps/buddy apps/website packages/assets packaging infrastructure/scripts", "lint:fix": "pnpm lint --fix", "type-check": "pnpm --filter @uselexora/lexora-buddy --filter @uselexora/lexora-website type-check", "predev:web-stack": "pnpm dev:env", @@ -50,6 +51,7 @@ }, "devDependencies": { "@antfu/eslint-config": "^8.2.0", + "@playwright/test": "1.60.0", "eslint": "^10.2.1", "typescript": "catalog:", "vitest": "^4.1.11" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed9c8731..d209868a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@antfu/eslint-config': specifier: ^8.2.0 version: 8.3.0(@typescript-eslint/rule-tester@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.60.0(supports-color@8.1.1)(typescript@6.0.3))(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(@vue/compiler-sfc@3.5.43)(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)(vitest@4.1.11(@types/node@26.6.2)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.103.1)(sass@1.103.1)(yaml@2.9.0))) + '@playwright/test': + specifier: 1.60.0 + version: 1.60.0 eslint: specifier: ^10.2.1 version: 10.4.0(jiti@2.7.0)(supports-color@8.1.1) @@ -2587,6 +2590,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -5667,6 +5675,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} @@ -7257,6 +7270,11 @@ packages: engines: {node: '>=18'} hasBin: true + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -11136,6 +11154,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@pondwader/socks5-server@1.0.10': {} @@ -14431,6 +14453,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -16239,6 +16264,12 @@ snapshots: playwright-core@1.60.0: {} + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.15