mattrguerra/cortexmainPrivate on GitHub, published here in full

app.spec.ts

Language TypeScript6.6 KB150 lines

e2e/ui/app.spec.ts

  1. import AxeBuilder from '@axe-core/playwright'
  2. import { expect, test, type Page } from '@playwright/test'
  3. const sizes = [
  4. { name: 'desktop', width: 1280, height: 800 },
  5. { name: 'small', width: 760, height: 520 },
  6. ]
  7. const themes = ['light', 'dark'] as const
  8. async function openApp(page: Page, theme: 'light' | 'dark', reduce = false) {
  9. await page.emulateMedia({ colorScheme: theme })
  10. if (reduce) {
  11. const cdp = await page.context().newCDPSession(page)
  12. await cdp.send('Emulation.setEmulatedMedia', {
  13. features: [{ name: 'prefers-reduced-transparency', value: 'reduce' }],
  14. })
  15. }
  16. await page.goto('/')
  17. await expect(page.getByTestId('composer-input')).toBeVisible()
  18. }
  19. async function noSidewaysScroll(page: Page) {
  20. const overflow = await page.evaluate(
  21. () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
  22. )
  23. expect(overflow).toBeLessThanOrEqual(0)
  24. }
  25. for (const size of sizes) {
  26. for (const theme of themes) {
  27. test.describe(`${size.name} ${theme}`, () => {
  28. test.use({ viewport: { width: size.width, height: size.height } })
  29. test('opens a chat, sends, streams and shows the cost', async ({ page }) => {
  30. await openApp(page, theme)
  31. await expect(page.locator('html')).toHaveAttribute('data-theme', theme)
  32. await noSidewaysScroll(page)
  33. await page.getByRole('button', { name: /Tidy a bash backup script/ }).click()
  34. await expect(page.getByTestId('msg-assistant').first()).toContainText('rsync')
  35. await expect(page.getByTestId('cost-line').first()).toContainText(/in, .* out, \$/)
  36. await expect(page.getByTestId('chat-total')).toContainText('$')
  37. await page.getByTestId('composer-input').fill('Explain the index I should add in SQL')
  38. await expect(page.getByTestId('estimate')).toContainText('tokens in')
  39. await page.getByTestId('send').click()
  40. await expect(page.getByTestId('msg-assistant').last()).toContainText('CREATE INDEX')
  41. await expect(page.getByTestId('msg-assistant').last().getByTestId('cost-line')).toBeVisible()
  42. await noSidewaysScroll(page)
  43. })
  44. test('model picker shows prices and filters', async ({ page }) => {
  45. await openApp(page, theme)
  46. await page.getByTestId('model-button').click()
  47. const picker = page.getByRole('dialog', { name: 'Choose a model' })
  48. await expect(picker).toContainText('$0.09 / $0.55')
  49. await picker.getByRole('tab', { name: 'Vision' }).click()
  50. await expect(picker.getByRole('option')).toHaveCount(6)
  51. await picker.getByRole('tab', { name: 'Local' }).click()
  52. await expect(picker.getByRole('option').first()).toContainText('llama3.2')
  53. await expect(picker.getByRole('option').first()).toContainText('free')
  54. await picker.getByRole('option').first().click()
  55. await expect(page.getByTestId('model-button')).toContainText('llama3.2')
  56. await noSidewaysScroll(page)
  57. })
  58. })
  59. }
  60. }
  61. test('stop keeps the partial reply', async ({ page }) => {
  62. await openApp(page, 'light')
  63. await page.getByTestId('composer-input').fill('Tell me about caching, at length')
  64. await page.getByTestId('send').click()
  65. await expect(page.getByTestId('stop')).toBeVisible()
  66. await page.waitForTimeout(150)
  67. await page.getByTestId('stop').click()
  68. await expect(page.getByTestId('msg-assistant').last()).toContainText('Stopped.')
  69. await expect(page.getByTestId('msg-assistant').last().getByTestId('cost-line')).toContainText('est.')
  70. })
  71. test('search finds a message and opens its chat', async ({ page }) => {
  72. await openApp(page, 'dark')
  73. await page.keyboard.press('ControlOrMeta+k')
  74. await page.getByTestId('search-input').fill('bm25')
  75. const hit = page.locator('.hits li').first()
  76. await expect(hit).toContainText('How FTS5 bm25 ranking works')
  77. await expect(hit.locator('mark').first()).toHaveText(/bm25/i)
  78. await hit.click()
  79. await expect(page.locator('.chat-title')).toHaveText('How FTS5 bm25 ranking works')
  80. })
  81. test('usage and settings open and read cleanly', async ({ page }) => {
  82. await openApp(page, 'light')
  83. await page.getByTestId('spend-meter').click()
  84. await expect(page.getByRole('dialog', { name: 'Usage' })).toContainText('By model')
  85. await expect(page.getByTestId('usage-total')).toContainText('$')
  86. await page.keyboard.press('Escape')
  87. await page.keyboard.press('ControlOrMeta+,')
  88. const settings = page.getByRole('dialog', { name: 'Settings' })
  89. await expect(settings).toContainText('Monthly budget')
  90. await expect(settings.getByTestId('shortcut')).toBeVisible()
  91. await noSidewaysScroll(page)
  92. })
  93. test('reduced transparency makes the panels opaque', async ({ page }) => {
  94. await openApp(page, 'light', true)
  95. const blur = await page.locator('.composer').evaluate((el) => getComputedStyle(el).backdropFilter)
  96. expect(blur === 'none' || /blur\(0px\)/.test(blur)).toBe(true)
  97. })
  98. test('the quick prompt answers and shows the cost', async ({ page }) => {
  99. await page.setViewportSize({ width: 640, height: 420 })
  100. await page.goto('/quick.html')
  101. await page.getByLabel('Quick prompt').fill('What should I index in SQL?')
  102. await page.getByLabel('Quick prompt').press('Enter')
  103. await expect(page.locator('.quick-body')).toContainText('CREATE INDEX')
  104. await expect(page.getByTestId('cost-line')).toBeVisible()
  105. await noSidewaysScroll(page)
  106. })
  107. test('an Enter pressed before the quick prompt has loaded is not lost', async ({ page }) => {
  108. // Hold back the IPC client, as a slow first start would.
  109. await page.route(/mockClient.*\.js/, async (route) => {
  110. await new Promise((r) => setTimeout(r, 1500))
  111. await route.continue()
  112. })
  113. await page.setViewportSize({ width: 640, height: 420 })
  114. await page.goto('/quick.html')
  115. await page.getByLabel('Quick prompt').fill('What should I index in SQL?')
  116. await page.getByLabel('Quick prompt').press('Enter')
  117. await expect(page.locator('.quick-body')).toContainText('CREATE INDEX', { timeout: 8000 })
  118. })
  119. for (const theme of themes) {
  120. test(`no contrast or labeling problems (${theme})`, async ({ page }) => {
  121. await openApp(page, theme)
  122. await page.getByRole('button', { name: /Compare two pricing tiers/ }).click()
  123. await expect(page.getByTestId('msg-assistant')).toBeVisible()
  124. // axe ships newer Playwright types than the pinned runner; the Page is the same object.
  125. const results = await new AxeBuilder({ page } as unknown as ConstructorParameters<typeof AxeBuilder>[0])
  126. .withTags(['wcag2a', 'wcag2aa'])
  127. .analyze()
  128. const problems = results.violations.map(
  129. (v) =>
  130. `${v.id}: ${v.nodes
  131. .map((n) => n.target.join(' '))
  132. .slice(0, 4)
  133. .join(' | ')}`,
  134. )
  135. expect(problems).toEqual([])
  136. })
  137. }