diff --git a/browse/src/welcome.html b/browse/src/welcome.html index 7f03eb77..4849eeff 100644 --- a/browse/src/welcome.html +++ b/browse/src/welcome.html @@ -213,25 +213,14 @@ diff --git a/browse/test/welcome-page.test.ts b/browse/test/welcome-page.test.ts new file mode 100644 index 00000000..5d6b8a4b --- /dev/null +++ b/browse/test/welcome-page.test.ts @@ -0,0 +1,141 @@ +/** + * Welcome page E2E test — verifies the sidebar arrow hint and key elements + * render correctly when the welcome page is served via HTTP. + * + * Spins up a real Bun.serve, fetches the HTML, and parses it to verify + * the sidebar prompt arrow, feature cards, and branding are present. + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const WELCOME_PATH = path.join(import.meta.dir, '../src/welcome.html'); +const welcomeHtml = fs.readFileSync(WELCOME_PATH, 'utf-8'); + +let server: ReturnType; +let baseUrl: string; + +beforeAll(() => { + // Serve the welcome page exactly as the browse server does + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch() { + return new Response(welcomeHtml, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + }, + }); + baseUrl = `http://127.0.0.1:${server.port}`; +}); + +afterAll(() => { + server?.stop(); +}); + +describe('welcome page served via HTTP', () => { + let html: string; + + beforeAll(async () => { + const resp = await fetch(baseUrl); + expect(resp.ok).toBe(true); + expect(resp.headers.get('content-type')).toContain('text/html'); + html = await resp.text(); + }); + + // ─── Sidebar arrow hint (the bug that triggered this test) ──────── + + test('sidebar prompt arrow is present and visible', () => { + // The arrow element with class "arrow-right" must exist + expect(html).toContain('class="arrow-right"'); + // It should contain the right-arrow character (→ = →) + expect(html).toContain('→'); + }); + + test('sidebar prompt container is visible by default (no hidden class)', () => { + // The prompt div should NOT have the "hidden" class on initial load + expect(html).toContain('id="sidebar-prompt"'); + // Check it doesn't start hidden + expect(html).not.toMatch(/class="sidebar-prompt[^"]*hidden/); + }); + + test('sidebar prompt has instruction text', () => { + expect(html).toContain('Open the sidebar to get started'); + expect(html).toContain('puzzle piece'); + }); + + test('sidebar prompt is positioned on the right side', () => { + // CSS should position it on the right + expect(html).toMatch(/\.sidebar-prompt\s*\{[^}]*right:\s*\d+px/); + }); + + test('arrow has nudge animation', () => { + expect(html).toContain('@keyframes nudge'); + expect(html).toMatch(/\.arrow-right\s*\{[^}]*animation:\s*nudge/); + }); + + // ─── Branding ───────────────────────────────────────────────────── + + test('has GStack Browser title and branding', () => { + expect(html).toContain('GStack Browser'); + expect(html).toContain('GStack Browser'); + }); + + test('has amber dot logo', () => { + expect(html).toContain('class="logo-dot"'); + expect(html).toContain('class="logo-text"'); + }); + + // ─── Feature cards ──────────────────────────────────────────────── + + test('has all four feature cards', () => { + expect(html).toContain('Talk to the sidebar'); + expect(html).toContain('Clean up any page'); + expect(html).toContain('Smart screenshots'); + expect(html).toContain('Modify any page'); + }); + + // ─── Try it section ─────────────────────────────────────────────── + + test('has try-it section with example prompts', () => { + expect(html).toContain('Try it now'); + expect(html).toContain('news.ycombinator.com'); + }); + + // ─── Extension auto-hide ────────────────────────────────────────── + + test('hides sidebar prompt when extension is detected', () => { + // Should listen for the extension-ready event + expect(html).toContain("'gstack-extension-ready'"); + // Should add 'hidden' class to sidebar-prompt + expect(html).toContain("classList.add('hidden')"); + }); + + test('does NOT auto-hide based on extension detection alone', () => { + // The arrow should only hide when the sidebar actually opens, + // not when the content script loads (which happens on every page) + expect(html).not.toContain('gstack-status-pill'); + expect(html).not.toContain('checkPill'); + }); + + // ─── Dark theme ─────────────────────────────────────────────────── + + test('uses dark theme colors', () => { + expect(html).toContain('--base: #0C0C0C'); + expect(html).toContain('--surface: #141414'); + }); + + // ─── Left-aligned text ──────────────────────────────────────────── + + test('text is left-aligned, not centered', () => { + expect(html).not.toMatch(/text-align:\s*center/); + }); + + // ─── Footer ─────────────────────────────────────────────────────── + + test('has footer with attribution', () => { + expect(html).toContain('Garry Tan'); + expect(html).toContain('github.com/garrytan/gstack'); + }); +}); diff --git a/extension/background.js b/extension/background.js index 902252d0..7a448790 100644 --- a/extension/background.js +++ b/extension/background.js @@ -286,7 +286,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const ALLOWED_TYPES = new Set([ 'getPort', 'setPort', 'getServerUrl', 'fetchRefs', - 'openSidePanel', 'command', 'sidebar-command', + 'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command', // Inspector message types 'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled', 'applyStyle', 'toggleClass', 'injectCSS', 'resetAll', @@ -332,6 +332,20 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } + // Sidebar opened — tell active tab's content script so the welcome page + // can hide its arrow hint. Only fires when the sidebar actually connects. + if (msg.type === 'sidebarOpened') { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const tabId = tabs?.[0]?.id; + if (tabId) { + chrome.tabs.sendMessage(tabId, { type: 'sidebarOpened' }).catch(() => { + // Expected: tab may not have content script + }); + } + }); + return; + } + // Inspector: inject + start picker if (msg.type === 'startInspector') { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { diff --git a/extension/content.js b/extension/content.js index 1c119d7d..b1f47fc8 100644 --- a/extension/content.js +++ b/extension/content.js @@ -326,11 +326,18 @@ function startBasicPicker() { document.addEventListener('keydown', onBasicKeydown, true); } -// Notify the page that the gstack extension is active (used by welcome page) -document.dispatchEvent(new CustomEvent('gstack-extension-ready')); +// Do NOT dispatch gstack-extension-ready here — the extension being loaded +// does not mean the sidebar is open. The welcome page arrow hint should only +// hide when the sidebar is actually open. We dispatch it when we receive +// a 'sidebarOpened' message from background.js. // Listen for messages from background worker chrome.runtime.onMessage.addListener((msg) => { + // Sidebar actually opened — now hide the welcome page arrow hint + if (msg.type === 'sidebarOpened') { + document.dispatchEvent(new CustomEvent('gstack-extension-ready')); + return; + } if (msg.type === 'startBasicPicker') { startBasicPicker(); return; diff --git a/extension/sidepanel.js b/extension/sidepanel.js index c0f1d8c1..561ba53b 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -1352,6 +1352,9 @@ function updateConnection(url, token) { document.getElementById('footer-port').textContent = `:${port}`; setConnState('connected'); setActionButtonsEnabled(true); + // Tell the active tab's content script the sidebar is open — this hides + // the welcome page arrow hint. Only fires on actual sidebar connection. + chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {}); connectSSE(); connectInspectorSSE(); if (chatPollInterval) clearInterval(chatPollInterval);