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 @@