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
This commit is contained in:
Submodule
+1
Submodule .claude/worktrees/hungry-sinoussi added at 5414060225
Submodule
+1
Submodule .claude/worktrees/unruffled-carson added at 5414060225
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?://`
|
||||
@@ -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
|
||||
@@ -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 `<strong>`, `\n\n` → `</p><p>`
|
||||
- Meme: imgflip URLs, contextual by topic (bearish, bull, Bitcoin, macro)
|
||||
@@ -36,5 +36,10 @@ pnpm-debug.log*
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# Storybook
|
||||
storybook-static/
|
||||
|
||||
@@ -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');
|
||||
|
||||
}));
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div
|
||||
v-if="contextType === 'film' || contextType === 'song' || contextType === 'podcast' || contextType === 'websites'"
|
||||
v-if="['film','song','podcast','news','websites','magazine'].includes(contextType)"
|
||||
class="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<div
|
||||
@@ -75,7 +75,7 @@ import LoadingFilmGrid from './LoadingFilmGrid.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
contextType?: 'film' | 'song' | 'podcast' | 'websites' | 'generic'
|
||||
contextType?: 'film' | 'song' | 'podcast' | 'news' | 'websites' | 'magazine' | 'generic'
|
||||
}>(),
|
||||
{ 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'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Generated
+38
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user