Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/scripts/resolve-ci-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const websitePrefixes = [
'apps/website/',
]
const buddyPrefixes = [
'.playwright/scripts/',
'apps/buddy/',
'packaging/buddy/',
'patches/',
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand Down
21 changes: 21 additions & 0 deletions .playwright/scripts/README.md
Original file line number Diff line number Diff line change
@@ -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/<runId>/instances/<instance>`. 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/<runId>`, 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`.
84 changes: 84 additions & 0 deletions .playwright/scripts/__tests__/desktop.e2e.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
})
66 changes: 66 additions & 0 deletions .playwright/scripts/__tests__/testRun.spec.mjs
Original file line number Diff line number Diff line change
@@ -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/)
})
Loading
Loading