app.e2e.mjs
Language JavaScript5.5 KB122 lines
// The real app, end to end: the Tauri binary (WebKitGTK), driven over// WebDriver through tauri-driver, talking to the stand-in provider.// Run by scripts/e2e-linux.sh, which starts Xvfb, the stand-in and the driver.import assert from 'node:assert/strict'import { readdirSync, readFileSync } from 'node:fs'import { Builder, By, Key, until } from 'selenium-webdriver'const app = process.env.CORTEX_BINconst exportDir = process.env.CORTEX_EXPORT_DIRconst T = 20_000const driver = await new Builder().usingServer('http://127.0.0.1:4444/').withCapabilities({ browserName: 'wry', 'tauri:options': { application: app } }).build()// Two webviews exist (main and the hidden quick prompt); drive the main one.for (const h of await driver.getAllWindowHandles()) {await driver.switchTo().window(h)if (!(await driver.getCurrentUrl()).includes('quick.html')) break}const $ = (css) => driver.wait(until.elementLocated(By.css(css)), T)// textContent, not getText(): WebKit's driver drops text it considers clipped.const txt = (el) => driver.executeScript('return arguments[0].textContent', el)const textOf = async (css) => txt(await $(css))const lastText = async () => {const el = await lastAssistant()return el ? txt(el) : ''}const step = (name) => console.log(`- ${name}`)const lastAssistant = async () => {const all = await driver.findElements(By.css('[data-testid="msg-assistant"]'))return all[all.length - 1]}try {step('boots to an empty chat with the composer')const input = await $('[data-testid="composer-input"]')assert.match(await textOf('.empty h1'), /What are we working on/)step('the model list came from the stand-in, with prices')await driver.wait(async () => /echo.*\$0\.50/s.test(await textOf('[data-testid="model-button"]')), T, 'model button').catch(async (e) => {throw new Error(`${e.message}; the button reads "${await textOf('[data-testid="model-button"]')}"`)})step('a streamed reply, saved with its cost')await input.sendKeys('hello from the end to end test')await (await $('[data-testid="send"]')).click()await driver.wait(async () => /You said: hello from the end to end test/.test(await lastText()), T)const cost = await driver.wait(until.elementLocated(By.css('[data-testid="msg-assistant"] [data-testid="cost-line"]')),T,)const costText = await txt(cost)assert.match(costText, /\d+ in, \d+ out, (\$|<\$)/)assert.doesNotMatch(costText, /est\./, 'the stand-in reports usage, so the cost is not an estimate')assert.match(await textOf('[data-testid="chat-total"]'), /^\$|<\$/)// The sidebar lists the chat, titled from the first message.await driver.wait(async () => /hello from the end to end test/.test(await textOf('.conv-list')), T)step('switch to the slow model and stop mid-reply')await (await $('[data-testid="model-button"]')).click()const search = await $('.model-picker .search-input')await search.sendKeys('slow', Key.ENTER)await driver.wait(async () => /slow/.test(await textOf('[data-testid="model-button"]')), T)await (await $('[data-testid="composer-input"]')).sendKeys('go')await (await $('[data-testid="send"]')).click()const stop = await $('[data-testid="stop"]')await driver.wait(async () => /word3/.test(await lastText()), T)await stop.click()await driver.wait(async () => /Stopped\./.test(await lastText()), T)const partial = await lastText()assert.doesNotMatch(partial, /word200/, 'the reply stopped early')assert.match(partial, /est\./, 'a stopped reply has estimated usage')step('search finds the first message')await driver.actions().keyDown(Key.CONTROL).sendKeys('k').keyUp(Key.CONTROL).perform()await (await $('[data-testid="search-input"]')).sendKeys('hello')await driver.wait(async () => (await driver.findElements(By.css('.hits mark'))).length > 0, T)assert.match(await textOf('.hits'), /hello/i)await driver.actions().sendKeys(Key.ESCAPE).perform()step('export writes a Markdown note to the vault folder')await (await $('button[aria-label="More actions"]')).click()await (await driver.wait(until.elementLocated(By.xpath("//button[normalize-space()='Export to vault']")), T)).click()// The note appears whole (written aside, then renamed), so its first// appearance must already have the text.const noteFile = () => readdirSync(exportDir).find((f) => f.endsWith('.md'))await driver.wait(() => !!noteFile(), T)const md = readFileSync(`${exportDir}/${noteFile()}`, 'utf8')assert.match(md, /^---\ntitle: "hello from the end to end test"/)assert.match(md, /tags:\n - cortex\n---/)assert.match(md, /You said: hello from the end to end test/)assert.match(md, /\*Stopped before it finished\.\*/)console.log('\nE2E OK: boot, priced models, streamed reply with cost, stop, search, export')} catch (err) {console.error('\nE2E FAILED:', err.message)try {console.error('page text:',(await withTimeout(driver.executeScript('return document.body.innerText'), 5000)).slice(0, 600),)const png = await withTimeout(driver.takeScreenshot(), 8000)const { writeFileSync } = await import('node:fs')writeFileSync(process.env.E2E_SHOT ?? 'e2e-failure.png', Buffer.from(png, 'base64'))console.error('screenshot saved')} catch (e) {console.error('no screenshot:', e.message)}process.exitCode = 1} finally {await withTimeout(driver.quit(), 10000).catch(() => {})process.exit()}function withTimeout(p, ms) {return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`timed out after ${ms} ms`)), ms))])}