mattrguerra/cortexmainPrivate on GitHub, published here in full

app.e2e.mjs

Language JavaScript5.5 KB122 lines

e2e/tauri/app.e2e.mjs

  1. // The real app, end to end: the Tauri binary (WebKitGTK), driven over
  2. // WebDriver through tauri-driver, talking to the stand-in provider.
  3. // Run by scripts/e2e-linux.sh, which starts Xvfb, the stand-in and the driver.
  4. import assert from 'node:assert/strict'
  5. import { readdirSync, readFileSync } from 'node:fs'
  6. import { Builder, By, Key, until } from 'selenium-webdriver'
  7. const app = process.env.CORTEX_BIN
  8. const exportDir = process.env.CORTEX_EXPORT_DIR
  9. const T = 20_000
  10. const driver = await new Builder()
  11. .usingServer('http://127.0.0.1:4444/')
  12. .withCapabilities({ browserName: 'wry', 'tauri:options': { application: app } })
  13. .build()
  14. // Two webviews exist (main and the hidden quick prompt); drive the main one.
  15. for (const h of await driver.getAllWindowHandles()) {
  16. await driver.switchTo().window(h)
  17. if (!(await driver.getCurrentUrl()).includes('quick.html')) break
  18. }
  19. const $ = (css) => driver.wait(until.elementLocated(By.css(css)), T)
  20. // textContent, not getText(): WebKit's driver drops text it considers clipped.
  21. const txt = (el) => driver.executeScript('return arguments[0].textContent', el)
  22. const textOf = async (css) => txt(await $(css))
  23. const lastText = async () => {
  24. const el = await lastAssistant()
  25. return el ? txt(el) : ''
  26. }
  27. const step = (name) => console.log(`- ${name}`)
  28. const lastAssistant = async () => {
  29. const all = await driver.findElements(By.css('[data-testid="msg-assistant"]'))
  30. return all[all.length - 1]
  31. }
  32. try {
  33. step('boots to an empty chat with the composer')
  34. const input = await $('[data-testid="composer-input"]')
  35. assert.match(await textOf('.empty h1'), /What are we working on/)
  36. step('the model list came from the stand-in, with prices')
  37. await driver
  38. .wait(async () => /echo.*\$0\.50/s.test(await textOf('[data-testid="model-button"]')), T, 'model button')
  39. .catch(async (e) => {
  40. throw new Error(`${e.message}; the button reads "${await textOf('[data-testid="model-button"]')}"`)
  41. })
  42. step('a streamed reply, saved with its cost')
  43. await input.sendKeys('hello from the end to end test')
  44. await (await $('[data-testid="send"]')).click()
  45. await driver.wait(async () => /You said: hello from the end to end test/.test(await lastText()), T)
  46. const cost = await driver.wait(
  47. until.elementLocated(By.css('[data-testid="msg-assistant"] [data-testid="cost-line"]')),
  48. T,
  49. )
  50. const costText = await txt(cost)
  51. assert.match(costText, /\d+ in, \d+ out, (\$|<\$)/)
  52. assert.doesNotMatch(costText, /est\./, 'the stand-in reports usage, so the cost is not an estimate')
  53. assert.match(await textOf('[data-testid="chat-total"]'), /^\$|<\$/)
  54. // The sidebar lists the chat, titled from the first message.
  55. await driver.wait(async () => /hello from the end to end test/.test(await textOf('.conv-list')), T)
  56. step('switch to the slow model and stop mid-reply')
  57. await (await $('[data-testid="model-button"]')).click()
  58. const search = await $('.model-picker .search-input')
  59. await search.sendKeys('slow', Key.ENTER)
  60. await driver.wait(async () => /slow/.test(await textOf('[data-testid="model-button"]')), T)
  61. await (await $('[data-testid="composer-input"]')).sendKeys('go')
  62. await (await $('[data-testid="send"]')).click()
  63. const stop = await $('[data-testid="stop"]')
  64. await driver.wait(async () => /word3/.test(await lastText()), T)
  65. await stop.click()
  66. await driver.wait(async () => /Stopped\./.test(await lastText()), T)
  67. const partial = await lastText()
  68. assert.doesNotMatch(partial, /word200/, 'the reply stopped early')
  69. assert.match(partial, /est\./, 'a stopped reply has estimated usage')
  70. step('search finds the first message')
  71. await driver.actions().keyDown(Key.CONTROL).sendKeys('k').keyUp(Key.CONTROL).perform()
  72. await (await $('[data-testid="search-input"]')).sendKeys('hello')
  73. await driver.wait(async () => (await driver.findElements(By.css('.hits mark'))).length > 0, T)
  74. assert.match(await textOf('.hits'), /hello/i)
  75. await driver.actions().sendKeys(Key.ESCAPE).perform()
  76. step('export writes a Markdown note to the vault folder')
  77. await (await $('button[aria-label="More actions"]')).click()
  78. await (await driver.wait(until.elementLocated(By.xpath("//button[normalize-space()='Export to vault']")), T)).click()
  79. // The note appears whole (written aside, then renamed), so its first
  80. // appearance must already have the text.
  81. const noteFile = () => readdirSync(exportDir).find((f) => f.endsWith('.md'))
  82. await driver.wait(() => !!noteFile(), T)
  83. const md = readFileSync(`${exportDir}/${noteFile()}`, 'utf8')
  84. assert.match(md, /^---\ntitle: "hello from the end to end test"/)
  85. assert.match(md, /tags:\n - cortex\n---/)
  86. assert.match(md, /You said: hello from the end to end test/)
  87. assert.match(md, /\*Stopped before it finished\.\*/)
  88. console.log('\nE2E OK: boot, priced models, streamed reply with cost, stop, search, export')
  89. } catch (err) {
  90. console.error('\nE2E FAILED:', err.message)
  91. try {
  92. console.error(
  93. 'page text:',
  94. (await withTimeout(driver.executeScript('return document.body.innerText'), 5000)).slice(0, 600),
  95. )
  96. const png = await withTimeout(driver.takeScreenshot(), 8000)
  97. const { writeFileSync } = await import('node:fs')
  98. writeFileSync(process.env.E2E_SHOT ?? 'e2e-failure.png', Buffer.from(png, 'base64'))
  99. console.error('screenshot saved')
  100. } catch (e) {
  101. console.error('no screenshot:', e.message)
  102. }
  103. process.exitCode = 1
  104. } finally {
  105. await withTimeout(driver.quit(), 10000).catch(() => {})
  106. process.exit()
  107. }
  108. function withTimeout(p, ms) {
  109. return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`timed out after ${ms} ms`)), ms))])
  110. }