From 43ca3a78373dbe24242c98441c7073592b8fcec9 Mon Sep 17 00:00:00 2001
From: Dorian
Date: Mon, 2 Mar 2026 22:02:19 +0000
Subject: [PATCH] feat(playwright): integrate Playwright for end-to-end testing
and update .gitignore
- Added Playwright as a development dependency for end-to-end testing.
- Updated package.json to include test scripts for Playwright.
- Enhanced .gitignore to exclude Playwright test results and cache files.
- Improved content extraction logic in various components to handle new content types.
Made-with: Cursor
---
.claude/worktrees/hungry-sinoussi | 1 +
.claude/worktrees/unruffled-carson | 1 +
.cursor/rules/05-content-surfaces.mdc | 9 ++
.cursor/rules/20-content-films.mdc | 30 ++++
.cursor/rules/21-content-songs.mdc | 31 ++++
.cursor/rules/22-content-podcasts.mdc | 26 +++
.cursor/rules/23-content-news.mdc | 47 ++++++
.cursor/rules/24-content-websites.mdc | 32 ++++
.cursor/rules/25-content-magazine.mdc | 42 +++++
.gitignore | 5 +
packages/app/dev-dist/sw.js | 4 +-
packages/app/e2e/content-surfaces.spec.ts | 98 ++++++++++++
packages/app/e2e/fixtures/test-chats.ts | 148 ++++++++++++++++++
packages/app/e2e/global-setup.ts | 27 ++++
packages/app/e2e/global-teardown.ts | 11 ++
packages/app/e2e/smoke.spec.ts | 20 +++
packages/app/package.json | 3 +
packages/app/playwright.config.ts | 24 +++
.../src/components/content/ArticleDetail.vue | 6 +-
.../src/components/content/ContextLoader.vue | 6 +-
.../app/src/composables/useContentPanel.ts | 55 +++++--
packages/app/src/pages/ChatPage.vue | 2 +
pnpm-lock.yaml | 38 +++++
23 files changed, 653 insertions(+), 13 deletions(-)
create mode 160000 .claude/worktrees/hungry-sinoussi
create mode 160000 .claude/worktrees/unruffled-carson
create mode 100644 .cursor/rules/20-content-films.mdc
create mode 100644 .cursor/rules/21-content-songs.mdc
create mode 100644 .cursor/rules/22-content-podcasts.mdc
create mode 100644 .cursor/rules/23-content-news.mdc
create mode 100644 .cursor/rules/24-content-websites.mdc
create mode 100644 .cursor/rules/25-content-magazine.mdc
create mode 100644 packages/app/e2e/content-surfaces.spec.ts
create mode 100644 packages/app/e2e/fixtures/test-chats.ts
create mode 100644 packages/app/e2e/global-setup.ts
create mode 100644 packages/app/e2e/global-teardown.ts
create mode 100644 packages/app/e2e/smoke.spec.ts
create mode 100644 packages/app/playwright.config.ts
diff --git a/.claude/worktrees/hungry-sinoussi b/.claude/worktrees/hungry-sinoussi
new file mode 160000
index 00000000..54140602
--- /dev/null
+++ b/.claude/worktrees/hungry-sinoussi
@@ -0,0 +1 @@
+Subproject commit 5414060225ac72a64bf22b99e524105d2a52b7c5
diff --git a/.claude/worktrees/unruffled-carson b/.claude/worktrees/unruffled-carson
new file mode 160000
index 00000000..54140602
--- /dev/null
+++ b/.claude/worktrees/unruffled-carson
@@ -0,0 +1 @@
+Subproject commit 5414060225ac72a64bf22b99e524105d2a52b7c5
diff --git a/.cursor/rules/05-content-surfaces.mdc b/.cursor/rules/05-content-surfaces.mdc
index 700b3454..55a51d1c 100644
--- a/.cursor/rules/05-content-surfaces.mdc
+++ b/.cursor/rules/05-content-surfaces.mdc
@@ -80,3 +80,12 @@ interface RendererDefinition {
- Panel surfaces may lazy-load heavy libraries (CodeMirror, pdf.js, etc.)
- Never block the chat scroll with renderer loading
- Use skeleton/placeholder while panel content loads
+
+## Content Type Expert Rules
+For extraction, parsing, and surfacing logic, see:
+- `20-content-films.mdc` — Films
+- `21-content-songs.mdc` — Songs (includes looksLikeSong blocklist)
+- `22-content-podcasts.mdc` — Podcasts (includes looksLikePodcast)
+- `23-content-news.mdc` — News + RSS, ArticleDetail security
+- `24-content-websites.mdc` — Websites vs News, overlay
+- `25-content-magazine.mdc` — Magazine/Brief parsing, hero, meme
diff --git a/.cursor/rules/20-content-films.mdc b/.cursor/rules/20-content-films.mdc
new file mode 100644
index 00000000..f63ef8cf
--- /dev/null
+++ b/.cursor/rules/20-content-films.mdc
@@ -0,0 +1,30 @@
+---
+description: Expert rules for Film content extraction, display, and surfacing
+globs: "**/useContentPanel.ts,**/FilmCard.vue,**/FilmGrid.vue,**/FilmDetail.vue,**/mocks/films*"
+alwaysApply: false
+---
+
+# Films Content Surface
+
+## Extraction Patterns
+
+- **Tagged**: `[[film:f123]]` or `[[film:123]]` → resolved from mock library
+- **External**: `[[film_ext:Title|YYYY|Director]]` → create external film with fallback poster
+
+## Edge Cases
+
+- `normalizeFilmId`: `f123` and `123` both become `f123`
+- Duplicate prevention: key by `title|year` for externals
+- Empty/malformed: skip if title < 2 chars, year invalid
+- Poster: use `generatePosterFallback(title, year)` for externals
+
+## Strip Rules
+
+- `stripFilmTags` removes `[[film:...]]` and `[[film_ext:...]]` before displaying text
+- Preserve `\n{3,}` → `\n\n` to avoid excessive whitespace
+
+## Display
+
+- FilmCard: poster, title, year, director
+- FilmDetail: full metadata, sources, cast
+- Panel: grid of FilmCards, click opens FilmDetail in panel
diff --git a/.cursor/rules/21-content-songs.mdc b/.cursor/rules/21-content-songs.mdc
new file mode 100644
index 00000000..2c27179a
--- /dev/null
+++ b/.cursor/rules/21-content-songs.mdc
@@ -0,0 +1,31 @@
+---
+description: Expert rules for Song content extraction, display, and surfacing
+globs: "**/useContentPanel.ts,**/SongCard.vue,**/SongGrid.vue,**/SongDetail.vue,**/mocks/songs*"
+alwaysApply: false
+---
+
+# Songs Content Surface
+
+## Extraction Priority
+
+1. Tagged: `[[song:s123]]` or `[[song_ext:Title|Artist|YYYY]]`
+2. Library match: title + artist within 120 chars
+3. Patterns: `"Title" by Artist`, `Title – Artist`, `**Title** by Artist`
+
+## looksLikeSong Rejection
+
+Reject when title/artist contains: news phrases, "BIP", "protocol", "web search", "mailing list", "training cutoff", etc. See `looksLikeSong()` blocklist.
+
+- Max length: title 55 chars, artist 40 chars
+
+## Edge Cases
+
+- If `extractFilmIds` or `extractPodcastIds` found → return [] (don't mix film/podcast with song patterns)
+- If `isNewsLikeResponse` → return [] (news bullets often look like "X – Y")
+- Skip if title/artist is 4-digit year
+- Skip if contains `[[film` or `[[song` tags
+- Dedupe by `title|artist` lowercase
+
+## Strip Rules
+
+- `stripSongTags` removes song tags before displaying text
diff --git a/.cursor/rules/22-content-podcasts.mdc b/.cursor/rules/22-content-podcasts.mdc
new file mode 100644
index 00000000..309d6a59
--- /dev/null
+++ b/.cursor/rules/22-content-podcasts.mdc
@@ -0,0 +1,26 @@
+---
+description: Expert rules for Podcast content extraction, display, and surfacing
+globs: "**/useContentPanel.ts,**/PodcastCard.vue,**/PodcastGrid.vue,**/PodcastDetail.vue,**/mocks/podcasts*"
+alwaysApply: false
+---
+
+# Podcasts Content Surface
+
+## Extraction Patterns
+
+- **Tagged**: `[[podcast:p123]]` or `[[podcast_ext:Title|Host|YYYY]]`
+- No pattern fallback (unlike songs) — only tags
+
+## Edge Cases
+
+- Duplicate prevention: key by `title|host` lowercase
+- Empty: skip if title or host < 2 chars
+- Year optional in external format
+
+## looksLikePodcast (when added)
+
+Reject when title/host looks like: news source names, documentation sites, "Bitcoin Mailing List", etc. — same philosophy as `looksLikeSong`.
+
+## Strip Rules
+
+- `stripPodcastTags` removes podcast tags before displaying text
diff --git a/.cursor/rules/23-content-news.mdc b/.cursor/rules/23-content-news.mdc
new file mode 100644
index 00000000..7effc7e9
--- /dev/null
+++ b/.cursor/rules/23-content-news.mdc
@@ -0,0 +1,47 @@
+---
+description: Expert rules for News content extraction, merge, and surfacing
+globs: "**/useContentPanel.ts,**/useRssFetch.ts,**/NewsGrid.vue,**/ArticleDetail.vue,**/vite-rss*"
+alwaysApply: false
+---
+
+# News Content Surface
+
+## Sources
+
+1. **Web search**: `message.webResults` from AI (with imgSrc, content)
+2. **RSS**: Fetched from website URLs only when `newsContext` is true
+
+## newsContext
+
+- `isNewsQuery(userQuery)` — "news", "latest", "what's happening", "what are people saying", etc.
+- `isNewsLikeResponse(text)` — "for instant news", "check these sources", "access to web search", etc.
+
+## Merge Rules
+
+- `mergeNewsResults(web, rss)` — dedupe by URL (normalized: lowercase, no trailing slash)
+- Web results take precedence when URL collision
+
+## RSS Fetch Guard
+
+- **Only fetch RSS when `newsContext` is true and `mergedWebsites.length > 0`** — avoid surfacing irrelevant RSS from docs/resource links when user asked "websites"
+- Max 8 URLs, 15 articles total, 5 sites tried
+- Timeout: 15s client, 5s per feed server-side
+
+## Display
+
+- NewsGrid (variant=news): articles open in **ArticleDetail** (in-panel)
+- Relevance sort when `query` provided
+- Search filter by title, content, url
+- imgSrc: validate with `isSafeImgUrl` (https only)
+
+## Known Limitations
+
+- **RSS language**: Feeds return whatever the site publishes; no query/language filtering — may surface non-English articles
+- **RSS relevance**: No semantic filtering; articles are shown as published
+
+## ArticleDetail Security
+
+- `sanitizeHtml`: allow only safe tags (p, br, a, strong, em, ul, ol, li, blockquote, h1-h4)
+- Strip script, style, iframe, object, embed
+- Links: `href` must be `https?://`, reject `javascript:`
+- Images: `src` must be `https?://`
diff --git a/.cursor/rules/24-content-websites.mdc b/.cursor/rules/24-content-websites.mdc
new file mode 100644
index 00000000..b9df3f1b
--- /dev/null
+++ b/.cursor/rules/24-content-websites.mdc
@@ -0,0 +1,32 @@
+---
+description: Expert rules for Websites content extraction and surfacing
+globs: "**/useContentPanel.ts,**/NewsGrid.vue,**/articleOverlay*"
+alwaysApply: false
+---
+
+# Websites Content Surface
+
+## Extraction
+
+1. **Markdown links**: `[Title](https://...)` — extract all with `extractMarkdownLinks`
+2. **Bold domains**: `**Name** (domain.tld)` — extract with `extractBoldDomainLinks`
+3. Merge with `mergeNewsResults` (dedupe by URL)
+
+## URLs Validation
+
+- Scheme: `https?://` only
+- `new URL(raw)` must not throw
+- Min length: title 2, url 10 chars
+- Normalize for dedupe: lowercase, no trailing slash
+
+## Display
+
+- NewsGrid (variant=websites): card with favicon/globe icon
+- Click → **overlay iframe** (not ArticleDetail)
+- Use `articleOverlayStore.open(url, title, undefined, imgSrc)`
+
+## Distinction from News
+
+- News = articles (web search + RSS) → ArticleDetail in panel
+- Websites = plain links from response → overlay iframe
+- Same NewsGrid component, different `variant` and click handler
diff --git a/.cursor/rules/25-content-magazine.mdc b/.cursor/rules/25-content-magazine.mdc
new file mode 100644
index 00000000..31fb5b9d
--- /dev/null
+++ b/.cursor/rules/25-content-magazine.mdc
@@ -0,0 +1,42 @@
+---
+description: Expert rules for Magazine/Brief content extraction and surfacing
+globs: "**/useContentPanel.ts,**/MagazineGrid.vue"
+alwaysApply: false
+---
+
+# Magazine Content Surface
+
+## Detection
+
+- `hasMagazine` = sections ≥ 1 AND (newsQuery OR newsLikeResponse OR context keywords)
+- Context keywords: sentiment, bearish, bull case, macro, %, BTC, bitcoin, BIP, protocol, debate, what's happening
+
+## Section Extraction Order
+
+1. `## Heading` blocks — content until next ## or **Section**
+2. `**Pro/Anti camp**` blocks with emoji
+3. Bullets: `- **Title**: Content` or `- **Title** — Content` (em/en dash)
+4. Attributed: `- **Name** (Role) description`
+5. Intro paragraph (before first ##)
+6. "Key takeaway" / "This is being called..."
+7. "For deeper analysis" / further reading
+
+## Section Rules
+
+- Min: title 2 chars, content 15 chars
+- Max content: 2000 chars per section
+- Dedupe by title prefix (first 50 chars)
+- Skip bullets already inside ## blocks (`blockContents`)
+- `addSection` extracts: url, author, imageUrl from content
+
+## Hero Image
+
+1. First markdown image in text
+2. First `.jpg|.png|.gif|.webp` URL
+3. `webResults[0]?.imgSrc`
+4. Picsum fallback seeded by query
+
+## Format & Security
+
+- `formatContent`: escape `&<>`, preserve `**bold**` as ``, `\n\n` → `
`
+- Meme: imgflip URLs, contextual by topic (bearish, bull, Bitcoin, macro)
diff --git a/.gitignore b/.gitignore
index e11cb978..48ce15ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,5 +36,10 @@ pnpm-debug.log*
# Test coverage
coverage/
+# Playwright
+test-results/
+playwright-report/
+playwright/.cache/
+
# Storybook
storybook-static/
diff --git a/packages/app/dev-dist/sw.js b/packages/app/dev-dist/sw.js
index fb4065c4..f69ca72e 100644
--- a/packages/app/dev-dist/sw.js
+++ b/packages/app/dev-dist/sw.js
@@ -82,7 +82,7 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
- "revision": "0.hcs39iqdme4"
+ "revision": "0.h2t4rjv3m6"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
@@ -90,5 +90,7 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
}));
workbox.registerRoute(/^https:\/\/api\.anthropic\.com\/.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/^https:\/\/openrouter\.ai\/.*/i, new workbox.NetworkOnly(), 'GET');
+ workbox.registerRoute(/\/api\/web-search\?.*/i, new workbox.NetworkOnly(), 'GET');
+ workbox.registerRoute(/\/api\/rss-articles\?.*/i, new workbox.NetworkOnly(), 'GET');
}));
diff --git a/packages/app/e2e/content-surfaces.spec.ts b/packages/app/e2e/content-surfaces.spec.ts
new file mode 100644
index 00000000..40043341
--- /dev/null
+++ b/packages/app/e2e/content-surfaces.spec.ts
@@ -0,0 +1,98 @@
+import { test, expect } from '@playwright/test'
+
+test.describe('Content surfaces', () => {
+ test('empty state shows when no conversation selected', async ({ page }) => {
+ await page.goto('/')
+ const main = page.locator('main.path-glass-card')
+ await expect(main).toBeVisible()
+ })
+
+ test('chat can receive input', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ const input = page.getByPlaceholder(/Message AIUI/)
+ await input.click()
+ await input.pressSequentially('Recommend some films')
+ await expect(input).toHaveValue('Recommend some films', { timeout: 3000 })
+ })
+
+ test('films surface: films conversation loads and shows film cards', async ({ page }) => {
+ await Promise.all([
+ page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
+ page.goto('/'),
+ ])
+ await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
+ await page.getByRole('button', { name: /View all \d+ films/i }).click()
+ await expect(page.locator('main').getByRole('button', { name: 'Films' })).toBeVisible({ timeout: 5000 })
+ })
+
+ test('films surface: clicking assistant bubble opens panel', async ({ page }) => {
+ await Promise.all([
+ page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
+ page.goto('/'),
+ ])
+ await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
+ await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click()
+ await expect(page.locator('main').getByRole('button', { name: 'Films' })).toBeVisible({ timeout: 5000 })
+ })
+
+ test('magazine surface: BIP brief shows sections', async ({ page }) => {
+ await Promise.all([
+ page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
+ page.goto('/'),
+ ])
+ await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
+ await page.getByRole('button', { name: 'BIP 110 brief' }).click()
+ await expect(page.getByText(/BIP 110|Pro camp|Summary/i).first()).toBeVisible({ timeout: 8000 })
+ await page.getByRole('button', { name: 'View brief' }).click()
+ await expect(page.getByText(/AI Brief|Summary|Pro camp/i).first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('songs surface: songs conversation shows song cards', async ({ page }) => {
+ await Promise.all([
+ page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
+ page.goto('/'),
+ ])
+ await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
+ await page.getByRole('button', { name: 'Music recommendations' }).click()
+ await expect(page.getByText('Never Meant').first()).toBeVisible({ timeout: 8000 })
+ await page.getByRole('button', { name: /View all \d+ songs/i }).click()
+ await expect(page.locator('main').getByRole('button', { name: 'Songs' })).toBeVisible({ timeout: 5000 })
+ })
+
+ test('podcasts surface: podcasts conversation shows podcast cards', async ({ page }) => {
+ await Promise.all([
+ page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
+ page.goto('/'),
+ ])
+ await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
+ await page.getByRole('button', { name: 'Bitcoin podcasts' }).click()
+ await expect(page.getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 8000 })
+ await page.getByRole('button', { name: /View all \d+ podcasts/i }).click()
+ await expect(page.locator('main').getByRole('button', { name: 'Podcasts' })).toBeVisible({ timeout: 5000 })
+ })
+
+ test('websites surface: websites tab shows link cards', async ({ page }) => {
+ await Promise.all([
+ page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
+ page.goto('/'),
+ ])
+ await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
+ await page.getByRole('button', { name: 'Bitcoin resources' }).click()
+ await expect(page.getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 8000 })
+ await page.getByRole('button', { name: /View all \d+ websites/i }).click()
+ await expect(page.locator('main').getByRole('button', { name: 'Websites' })).toBeVisible({ timeout: 5000 })
+ })
+
+ test('news surface: news conversation shows articles', async ({ page }) => {
+ await Promise.all([
+ page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
+ page.goto('/'),
+ ])
+ await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
+ await page.getByRole('button', { name: 'Latest Bitcoin news' }).click()
+ await expect(page.getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 8000 })
+ await page.getByRole('button', { name: /View all \d+ articles/i }).click()
+ await expect(page.locator('main').getByRole('button', { name: 'News' })).toBeVisible({ timeout: 5000 })
+ })
+})
diff --git a/packages/app/e2e/fixtures/test-chats.ts b/packages/app/e2e/fixtures/test-chats.ts
new file mode 100644
index 00000000..89958dba
--- /dev/null
+++ b/packages/app/e2e/fixtures/test-chats.ts
@@ -0,0 +1,148 @@
+import type { Conversation } from '@aiui/core/types/message'
+
+const now = Date.now()
+
+/** Films: user asks for films, assistant responds with [[film:f1]] etc */
+export const filmsConversation: Conversation = {
+ id: 'e2e-films',
+ title: 'Film recommendations',
+ messages: [
+ {
+ id: 'm1',
+ role: 'user',
+ content: 'Recommend some sci-fi films',
+ timestamp: now - 60000,
+ },
+ {
+ id: 'm2',
+ role: 'assistant',
+ content: `Here are some great sci-fi films:\n\n- [[film:f1]] - Blade Runner 2049\n- [[film:f2]] - Arrival\n- [[film:f3]] - Dune\n\nAll from Denis Villeneuve.`,
+ timestamp: now - 30000,
+ },
+ ],
+ createdAt: now - 120000,
+ updatedAt: now,
+}
+
+/** Magazine: news-like query + bullet sections (BIP/debate context) */
+export const magazineConversation: Conversation = {
+ id: 'e2e-magazine',
+ title: 'BIP 110 brief',
+ messages: [
+ {
+ id: 'm1',
+ role: 'user',
+ content: "What's the latest on BIP 110? What are people saying?",
+ timestamp: now - 60000,
+ },
+ {
+ id: 'm2',
+ role: 'assistant',
+ content: `## Summary\n\nBIP 110 is being debated. Macro sentiment is bearish. BTC holding.\n\n- **Pro camp** — Technical improvement, faster.\n- **Anti camp** — Too risky, prefer status quo.\n\n**Henrik Zeberg** (analyst) says this could be bullish long-term.\n\nFor deeper analysis: check **Bitcoin Mailing List** (gnusha.org).`,
+ timestamp: now - 30000,
+ },
+ ],
+ createdAt: now - 120000,
+ updatedAt: now,
+}
+
+/** Websites: user asks for resources, assistant gives markdown links */
+export const websitesConversation: Conversation = {
+ id: 'e2e-websites',
+ title: 'Bitcoin resources',
+ messages: [
+ {
+ id: 'm1',
+ role: 'user',
+ content: 'Best websites to check for Bitcoin news?',
+ timestamp: now - 60000,
+ },
+ {
+ id: 'm2',
+ role: 'assistant',
+ content: `Here are the best places to check:\n\n- [Bitcoin Magazine](https://bitcoinmagazine.com)\n- [Bitcoin.org](https://bitcoin.org)\n- [Mempool.space](https://mempool.space)`,
+ timestamp: now - 30000,
+ },
+ ],
+ createdAt: now - 120000,
+ updatedAt: now,
+}
+
+/** News: web search results + news-like response */
+export const newsConversation: Conversation = {
+ id: 'e2e-news',
+ title: 'Latest Bitcoin news',
+ messages: [
+ {
+ id: 'm1',
+ role: 'user',
+ content: "What's the latest Bitcoin news?",
+ timestamp: now - 60000,
+ },
+ {
+ id: 'm2',
+ role: 'assistant',
+ content: `Here's what's happening. For the latest news check these sources:\n\n- [Bitcoin hits new high](https://example.com/btc-high)\n- [ETF inflows surge](https://example.com/etf-inflows)`,
+ timestamp: now - 30000,
+ webResults: [
+ { title: 'Bitcoin hits new high', url: 'https://example.com/btc-high', content: 'BTC reached...' },
+ { title: 'ETF inflows surge', url: 'https://example.com/etf-inflows', content: 'Spot ETF...' },
+ ],
+ },
+ ],
+ createdAt: now - 120000,
+ updatedAt: now,
+}
+
+/** Songs: user asks for music, assistant responds with [[song:s1]] */
+export const songsConversation: Conversation = {
+ id: 'e2e-songs',
+ title: 'Music recommendations',
+ messages: [
+ {
+ id: 'm1',
+ role: 'user',
+ content: 'Recommend some math rock',
+ timestamp: now - 60000,
+ },
+ {
+ id: 'm2',
+ role: 'assistant',
+ content: `Here are great math rock tracks:\n\n- [[song:s1]] Never Meant by American Football\n- [[song:s2]] The Kill by Toe`,
+ timestamp: now - 30000,
+ },
+ ],
+ createdAt: now - 120000,
+ updatedAt: now,
+}
+
+/** Podcasts */
+export const podcastsConversation: Conversation = {
+ id: 'e2e-podcasts',
+ title: 'Bitcoin podcasts',
+ messages: [
+ {
+ id: 'm1',
+ role: 'user',
+ content: 'Best Bitcoin podcasts?',
+ timestamp: now - 60000,
+ },
+ {
+ id: 'm2',
+ role: 'assistant',
+ content: `Check these:\n\n- [[podcast:p1]] What Bitcoin Did\n- [[podcast:p2]] The Audacity to Podcast`,
+ timestamp: now - 30000,
+ },
+ ],
+ createdAt: now - 120000,
+ updatedAt: now,
+}
+
+export const allTestConversations = {
+ [filmsConversation.id]: filmsConversation,
+ [magazineConversation.id]: magazineConversation,
+ [websitesConversation.id]: websitesConversation,
+ [newsConversation.id]: newsConversation,
+ [songsConversation.id]: songsConversation,
+ [podcastsConversation.id]: podcastsConversation,
+}
diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts
new file mode 100644
index 00000000..f7018273
--- /dev/null
+++ b/packages/app/e2e/global-setup.ts
@@ -0,0 +1,27 @@
+import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs'
+import { resolve } from 'path'
+import { allTestConversations } from './fixtures/test-chats'
+
+const CHATS_PATH = resolve(process.cwd(), '.dev', 'chats.json')
+
+export default async function globalSetup() {
+ const dir = resolve(process.cwd(), '.dev')
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
+
+ // Backup existing chats if present (for local dev)
+ let backup: string | null = null
+ if (existsSync(CHATS_PATH)) {
+ backup = readFileSync(CHATS_PATH, 'utf-8')
+ }
+
+ const payload = {
+ conversations: allTestConversations,
+ activeConversationId: 'e2e-films',
+ }
+ writeFileSync(CHATS_PATH, JSON.stringify(payload, null, 2), 'utf-8')
+
+ // Store backup path for teardown (we pass via env since globalSetup/Teardown don't share scope easily)
+ if (backup) {
+ process.env.AIUI_E2E_CHATS_BACKUP = backup
+ }
+}
diff --git a/packages/app/e2e/global-teardown.ts b/packages/app/e2e/global-teardown.ts
new file mode 100644
index 00000000..d7ffea98
--- /dev/null
+++ b/packages/app/e2e/global-teardown.ts
@@ -0,0 +1,11 @@
+import { writeFileSync } from 'fs'
+import { resolve } from 'path'
+
+const CHATS_PATH = resolve(process.cwd(), '.dev', 'chats.json')
+
+export default async function globalTeardown() {
+ const backup = process.env.AIUI_E2E_CHATS_BACKUP
+ if (backup) {
+ writeFileSync(CHATS_PATH, backup, 'utf-8')
+ }
+}
diff --git a/packages/app/e2e/smoke.spec.ts b/packages/app/e2e/smoke.spec.ts
new file mode 100644
index 00000000..39409221
--- /dev/null
+++ b/packages/app/e2e/smoke.spec.ts
@@ -0,0 +1,20 @@
+import { test, expect } from '@playwright/test'
+
+test.describe('AIUI smoke tests', () => {
+ test('app loads and shows chat interface', async ({ page }) => {
+ await page.goto('/')
+ await expect(page).toHaveTitle(/AIUI/)
+ })
+
+ test('chat input is visible and focusable', async ({ page }) => {
+ await page.goto('/')
+ const input = page.getByPlaceholder(/Message AIUI|Waiting for/)
+ await expect(input).toBeVisible()
+ })
+
+ test('content panel area exists', async ({ page }) => {
+ await page.goto('/')
+ const main = page.locator('main.path-glass-card')
+ await expect(main).toBeVisible()
+ })
+})
diff --git a/packages/app/package.json b/packages/app/package.json
index 93bdb15b..acaddeb9 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -12,6 +12,8 @@
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
"lint": "eslint src/",
"typecheck": "vue-tsc --noEmit",
"clean": "rm -rf dist"
@@ -34,6 +36,7 @@
"typescript": "~5.8.0",
"vite": "latest",
"vite-plugin-pwa": "^1.2.0",
+ "@playwright/test": "^1.49.0",
"vitest": "latest",
"vue-tsc": "latest"
}
diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts
new file mode 100644
index 00000000..f9d8b249
--- /dev/null
+++ b/packages/app/playwright.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: 1,
+ globalSetup: './e2e/global-setup.ts',
+ globalTeardown: './e2e/global-teardown.ts',
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:5173',
+ trace: 'on-first-retry',
+ },
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ ],
+ webServer: {
+ command: 'pnpm run dev:vite',
+ url: 'http://localhost:5173',
+ reuseExistingServer: true,
+ },
+})
diff --git a/packages/app/src/components/content/ArticleDetail.vue b/packages/app/src/components/content/ArticleDetail.vue
index fced578d..af6ee1cf 100644
--- a/packages/app/src/components/content/ArticleDetail.vue
+++ b/packages/app/src/components/content/ArticleDetail.vue
@@ -74,8 +74,12 @@ defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const articleDomain = computed(() => {
+ const url = props.article?.url
+ if (!url || typeof url !== 'string') return ''
try {
- return new URL(props.article.url).hostname.replace(/^www\./, '')
+ const u = new URL(url)
+ if (!/^https?:$/i.test(u.protocol)) return ''
+ return u.hostname.replace(/^www\./, '')
} catch {
return ''
}
diff --git a/packages/app/src/components/content/ContextLoader.vue b/packages/app/src/components/content/ContextLoader.vue
index 976e9757..4165de20 100644
--- a/packages/app/src/components/content/ContextLoader.vue
+++ b/packages/app/src/components/content/ContextLoader.vue
@@ -1,7 +1,7 @@
(),
{ contextType: 'film' }
)
@@ -86,7 +86,9 @@ const contextLabel = computed(() => {
if (props.contextType === 'film') return 'Film recommendations'
if (props.contextType === 'song') return 'Song recommendations'
if (props.contextType === 'podcast') return 'Podcast recommendations'
+ if (props.contextType === 'news') return 'Articles'
if (props.contextType === 'websites') return 'Websites'
+ if (props.contextType === 'magazine') return 'Brief'
return 'Content'
})
diff --git a/packages/app/src/composables/useContentPanel.ts b/packages/app/src/composables/useContentPanel.ts
index b2d7a4c4..a3304d85 100644
--- a/packages/app/src/composables/useContentPanel.ts
+++ b/packages/app/src/composables/useContentPanel.ts
@@ -66,9 +66,15 @@ function isWebsitesLikeResponse(text: string): boolean {
function extractUrlFromText(text: string): string | undefined {
const mdLink = /\[([^\]]*)\]\((https?:\/\/[^)]+)\)/.exec(text)
- if (mdLink) return mdLink[2]
- const bare = /(https?:\/\/[^\s)\]\"'<>]+)/.exec(text)
- return bare ? bare[1] : undefined
+ const raw = mdLink ? mdLink[2] : (/(https?:\/\/[^\s)\]\"'<>]+)/.exec(text)?.[1])
+ if (!raw?.trim()) return undefined
+ try {
+ const u = new URL(raw.trim())
+ if (!/^https?:$/i.test(u.protocol)) return undefined
+ return u.href
+ } catch {
+ return undefined
+ }
}
function extractAuthorFromText(text: string): string | undefined {
@@ -90,9 +96,15 @@ function extractAuthorFromText(text: string): string | undefined {
function extractFirstImageFromText(text: string): string | undefined {
const mdImg = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(text)
- if (mdImg) return mdImg[1]
- const ext = /(https?:\/\/[^\s)\]\"'<>]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s)\]]*)?)/i.exec(text)
- return ext ? ext[1] : undefined
+ const raw = mdImg ? mdImg[1] : (/(https?:\/\/[^\s)\]\"'<>]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s)\]]*)?)/i.exec(text)?.[1])
+ if (!raw?.trim()) return undefined
+ try {
+ const u = new URL(raw.trim())
+ if (!/^https?:$/i.test(u.protocol)) return undefined
+ return u.href
+ } catch {
+ return undefined
+ }
}
const MAGAZINE_CONTENT_MAX = 2000
@@ -109,12 +121,19 @@ function addSection(
const key = `${t.slice(0, 50)}`
if (seen.has(key)) return
seen.add(key)
+ const imgMatch = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(content)?.[1]
+ const imageUrl = imgMatch ? (() => {
+ try {
+ const u = new URL(imgMatch.trim())
+ return /^https?:$/i.test(u.protocol) ? u.href : undefined
+ } catch { return undefined }
+ })() : undefined
sections.push({
title: t,
content: c,
url: extractUrlFromText(content),
author: extractAuthorFromText(content),
- imageUrl: /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(content)?.[1],
+ imageUrl,
})
}
@@ -315,6 +334,23 @@ function looksLikeSong(title: string, artist: string): boolean {
}
const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
+
+/** Reject obvious non-podcast phrases (documentation, mailing lists, etc.) */
+function looksLikePodcast(title: string, host: string): boolean {
+ const t = title.toLowerCase()
+ const h = host.toLowerCase()
+ const bad = [
+ 'bitcoin mailing list', 'mailing list', 'developer mailing list', 'gnusha.org',
+ 'canonical source', 'formal dev', 'github', 'stackexchange', 'reddit', 'twitter',
+ 'latest news', 'protocol updates', 'web search', 'training cutoff',
+ 'documentation', 'bip discussion', 'bip 110', 'bitcoin bips',
+ ]
+ for (const phrase of bad) {
+ if (t.includes(phrase) || h.includes(phrase)) return false
+ }
+ if (t.length > 80 || h.length > 50) return false
+ return true
+}
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
const SAFE_URL_SCHEME = /^https?:\/\//i
@@ -573,6 +609,7 @@ export function useContentPanel() {
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const host = match[2].trim()
+ if (!looksLikePodcast(title, host)) continue
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = `${title.toLowerCase()}|${host.toLowerCase()}`
if (seen.has(key)) continue
@@ -620,8 +657,8 @@ export function useContentPanel() {
const hasNews = (webResults.length > 0 || mergedWebsites.length > 0) && newsContext
const mergedNews = hasNews ? mergeNewsResults(webResults, panelRssArticles.value) : []
- // Fetch RSS from website URLs to surface actual articles in News
- if (mergedWebsites.length > 0) {
+ // Fetch RSS from website URLs only when news context — avoid surfacing RSS from docs/resource links
+ if (mergedWebsites.length > 0 && newsContext) {
const urls = mergedWebsites.map((w) => w.url)
fetchRssFromUrls(urls).then((articles) => {
if (articles.length === 0) return
diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue
index 7597398d..0b7c9139 100644
--- a/packages/app/src/pages/ChatPage.vue
+++ b/packages/app/src/pages/ChatPage.vue
@@ -340,6 +340,8 @@ const loaderContextType = computed(() => {
const q = (lastUser?.content ?? '').toLowerCase()
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
+ if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news'
+ if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine'
if (/\b(website|websites|where to check|best places|check online|resources?|sources?)\b/.test(q)) return 'websites'
return contentType.value
})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c3beb0dd..bb840836 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -33,6 +33,9 @@ importers:
specifier: latest
version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3)))(vue@3.5.29(typescript@5.8.3))
devDependencies:
+ '@playwright/test':
+ specifier: ^1.49.0
+ version: 1.58.2
'@tailwindcss/vite':
specifier: latest
version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@@ -822,6 +825,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@playwright/test@1.58.2':
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@rolldown/pluginutils@1.0.0-rc.2':
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
@@ -1617,6 +1625,11 @@ packages:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
+ 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}
@@ -2149,6 +2162,16 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ engines: {node: '>=18'}
+ hasBin: true
+
plyr@3.8.4:
resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==}
@@ -3583,6 +3606,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@playwright/test@1.58.2':
+ dependencies:
+ playwright: 1.58.2
+
'@rolldown/pluginutils@1.0.0-rc.2': {}
'@rollup/plugin-babel@5.3.1(@babel/core@7.29.0)(rollup@2.80.0)':
@@ -4437,6 +4464,9 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -4921,6 +4951,14 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ playwright-core@1.58.2: {}
+
+ playwright@1.58.2:
+ dependencies:
+ playwright-core: 1.58.2
+ optionalDependencies:
+ fsevents: 2.3.2
+
plyr@3.8.4:
dependencies:
core-js: 3.48.0