964 lines
40 KiB
TypeScript
964 lines
40 KiB
TypeScript
/**
|
|
* E2E User Journey Tests — current AppShell browser journeys.
|
|
*
|
|
* These tests exercise the live single-canvas shell as a fresh returning user:
|
|
* navigation, shortcuts, chat entry, command search, settings, theme, home,
|
|
* keyboard help, and status-bar context.
|
|
*/
|
|
|
|
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
const SKIP_PARAMS = 'skipOnboarding=true&skipBoot=true&skipBriefing=true&tier=power';
|
|
|
|
function routeWithSkip(route: string) {
|
|
const sep = route.includes('?') ? '&' : '?';
|
|
return `${route}${sep}${SKIP_PARAMS}`;
|
|
}
|
|
|
|
async function gotoApp(page: Page, route = '/home') {
|
|
await page.goto(routeWithSkip(route), { waitUntil: 'domcontentloaded' });
|
|
await waitForShell(page);
|
|
}
|
|
|
|
async function waitForShell(page: Page) {
|
|
await page.waitForSelector('.waggle-sidebar, [role="navigation"], main', { timeout: 15_000 });
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
async function openViaNav(page: Page, testId: string, routePattern: RegExp) {
|
|
await page.getByTestId(testId).click();
|
|
await page.waitForURL(routePattern, { timeout: 10_000 });
|
|
await waitForShell(page);
|
|
}
|
|
|
|
async function pressCtrlShiftDigit(page: Page, digit: string) {
|
|
await page.evaluate((d) => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: d,
|
|
code: `Digit${d}`,
|
|
ctrlKey: true,
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
}));
|
|
}, digit);
|
|
}
|
|
|
|
async function visibleHorizontalOverflow(page: Page, selector: string) {
|
|
return page.locator(selector).evaluateAll(elements => elements
|
|
.map(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
return {
|
|
text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.tagName).trim().slice(0, 80),
|
|
left: Math.floor(rect.left),
|
|
right: Math.ceil(rect.right),
|
|
width: Math.ceil(rect.width),
|
|
};
|
|
})
|
|
.filter(item => item.width > 0 && (item.left < -1 || item.right > window.innerWidth + 1)));
|
|
}
|
|
|
|
test.describe('User Journey Tests', () => {
|
|
test('J1: app loads successfully — no blank screen', async ({ page }) => {
|
|
await gotoApp(page, '/home');
|
|
|
|
await expect(page.locator('body')).not.toBeEmpty();
|
|
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
|
|
await expect(page.getByText('Waggle AI')).toBeVisible();
|
|
});
|
|
|
|
test('J2: sidebar shows current navigation items', async ({ page }) => {
|
|
await gotoApp(page);
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Primary' });
|
|
await expect(sidebar).toBeVisible();
|
|
|
|
for (const label of ['Home', 'Chat', 'Memory', 'Agents', 'Library']) {
|
|
await expect(sidebar.getByRole('button', { name: label })).toBeVisible();
|
|
}
|
|
await expect(page.getByTestId('sidebar-command')).toBeVisible();
|
|
await expect(page.getByTestId('nav-spawn-agent')).toBeVisible();
|
|
});
|
|
|
|
test('J3: workspace switcher opens and closes', async ({ page }) => {
|
|
await gotoApp(page);
|
|
|
|
await page.getByTestId('sidebar-workspace').click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
await expect(dialog).toContainText(/workspace/i);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('J3b: notification and create-workspace overlays have dialog close contracts', async ({ page }) => {
|
|
await gotoApp(page);
|
|
|
|
await page.getByRole('button', { name: /^Notifications/ }).click();
|
|
const notifications = page.getByRole('dialog', { name: /notifications/i });
|
|
await expect(notifications).toBeVisible({ timeout: 5_000 });
|
|
await expect(notifications.getByRole('button', { name: /close notifications/i })).toBeVisible();
|
|
await page.keyboard.press('Escape');
|
|
await expect(notifications).not.toBeVisible({ timeout: 5_000 });
|
|
|
|
await page.getByTestId('sidebar-workspace').click();
|
|
const switcher = page.getByRole('dialog', { name: /switch workspace/i });
|
|
await expect(switcher).toBeVisible({ timeout: 5_000 });
|
|
await switcher.getByRole('button', { name: /new workspace/i }).click();
|
|
const createWorkspace = page.getByRole('dialog', { name: /create workspace/i });
|
|
await expect(createWorkspace).toBeVisible({ timeout: 5_000 });
|
|
await expect(createWorkspace.getByRole('button', { name: /close create workspace/i })).toBeVisible();
|
|
await page.keyboard.press('Escape');
|
|
await expect(createWorkspace).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('J3c: tier interruption modal exposes a named close contract', async ({ page }) => {
|
|
await gotoApp(page);
|
|
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(new CustomEvent('waggle:tier-insufficient', {
|
|
detail: {
|
|
required: 'TEAMS',
|
|
actual: 'FREE',
|
|
message: 'Team workspaces require a Team plan.',
|
|
},
|
|
}));
|
|
});
|
|
|
|
const upgrade = page.getByRole('dialog', { name: /upgrade to unlock/i });
|
|
await expect(upgrade).toBeVisible({ timeout: 5_000 });
|
|
await upgrade.getByRole('button', { name: /close upgrade dialog/i }).click();
|
|
await expect(upgrade).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('J3d: approvals revoke-all uses an in-app confirmation', async ({ page }) => {
|
|
const nativeDialogs: string[] = [];
|
|
let clearCalled = false;
|
|
|
|
await page.route('**/api/approval/pending', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ pending: [], count: 0 }),
|
|
}));
|
|
await page.route('**/api/approval/grants', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
grants: [{
|
|
id: 'grant-send-email',
|
|
toolName: 'send_email',
|
|
targetKey: 'client@example.com',
|
|
sourceWorkspaceId: 'workspace-1',
|
|
description: 'Always allow send_email to client@example.com',
|
|
grantedAt: new Date().toISOString(),
|
|
expiresAt: null,
|
|
}],
|
|
count: 1,
|
|
}),
|
|
}));
|
|
await page.route('**/api/approval/grants/clear', route => {
|
|
clearCalled = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ ok: true }),
|
|
});
|
|
});
|
|
page.on('dialog', async dialog => {
|
|
nativeDialogs.push(dialog.message());
|
|
await dialog.dismiss();
|
|
});
|
|
|
|
await gotoApp(page, '/approvals');
|
|
await page.getByRole('button', { name: /grants/i }).click();
|
|
await expect(page.getByText('1 active grant')).toBeVisible({ timeout: 5_000 });
|
|
|
|
await page.getByRole('button', { name: /revoke all/i }).click();
|
|
expect(nativeDialogs).toEqual([]);
|
|
|
|
const modal = page.getByTestId('approval-modal');
|
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
|
await expect(modal).toContainText(/revoke all saved approval grants/i);
|
|
await expect(modal).toContainText(/1 saved grant/i);
|
|
|
|
await page.getByTestId('approval-modal-approve').click();
|
|
await expect.poll(() => clearCalled).toBe(true);
|
|
await expect(page.getByText('No saved grants')).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('J3e: artifact delete uses an in-app confirmation', async ({ page }) => {
|
|
const nativeDialogs: string[] = [];
|
|
let deleteCalled = false;
|
|
const artifact = {
|
|
id: 'artifact-brief',
|
|
title: 'Quarterly Research Brief',
|
|
kind: 'document',
|
|
workspaceId: 'workspace-research',
|
|
createdBy: 'agent-researcher',
|
|
source: 'agent',
|
|
status: 'ready',
|
|
storagePath: '/artifacts/quarterly-brief.md',
|
|
tags: ['research'],
|
|
createdAt: '2026-07-08T08:00:00.000Z',
|
|
updatedAt: '2026-07-08T09:00:00.000Z',
|
|
};
|
|
|
|
await page.route('**/api/artifacts**', route => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
if (url.pathname === '/api/artifacts/search-related') {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ memories: [], sessions: [], tasks: [], agents: [], artifacts: [] }),
|
|
});
|
|
}
|
|
if (url.pathname === '/api/artifacts' && request.method() === 'GET') {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ results: [artifact], count: 1 }),
|
|
});
|
|
}
|
|
if (url.pathname === '/api/artifacts/artifact-brief' && request.method() === 'DELETE') {
|
|
deleteCalled = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ ok: true }),
|
|
});
|
|
}
|
|
return route.continue();
|
|
});
|
|
page.on('dialog', async dialog => {
|
|
nativeDialogs.push(dialog.message());
|
|
await dialog.dismiss();
|
|
});
|
|
|
|
await gotoApp(page, '/artifacts');
|
|
await page.getByRole('button', { name: /quarterly research brief/i }).click();
|
|
await expect(page.getByRole('dialog')).toContainText(/quarterly research brief/i);
|
|
|
|
await page.getByRole('button', { name: /^delete$/i }).click();
|
|
expect(nativeDialogs).toEqual([]);
|
|
|
|
const modal = page.getByTestId('approval-modal');
|
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
|
await expect(modal).toContainText(/delete artifact permanently/i);
|
|
await expect(modal).toContainText(/backing file/i);
|
|
|
|
await page.getByTestId('approval-modal-approve').click();
|
|
await expect.poll(() => deleteCalled).toBe(true);
|
|
});
|
|
|
|
test('J3f: memory destructive trust actions use in-app confirmations', async ({ page }) => {
|
|
const nativeDialogs: string[] = [];
|
|
let deleteCalled = false;
|
|
let eraseCalled = false;
|
|
let allowCalled = false;
|
|
const memory = {
|
|
id: 'memory-research-note',
|
|
kind: 'fact',
|
|
title: 'Research Note',
|
|
content: 'The supplier review belongs in the Q3 diligence packet.',
|
|
scope: 'personal',
|
|
workspaceId: null,
|
|
source: 'user_stated',
|
|
sourceId: null,
|
|
sourceUrl: null,
|
|
importance: 'normal',
|
|
status: 'active',
|
|
confidence: 91,
|
|
tags: ['research'],
|
|
evidence: [],
|
|
hasOriginalSource: false,
|
|
createdAt: '2026-07-08T08:00:00.000Z',
|
|
updatedAt: '2026-07-08T09:00:00.000Z',
|
|
};
|
|
const suppression = {
|
|
source: 'chatgpt',
|
|
sourceRef: 'research-export.json',
|
|
erasedAt: '2026-07-08T09:30:00.000Z',
|
|
reason: 'GDPR Art.17 erasure',
|
|
};
|
|
|
|
await page.route('**/api/memory**', route => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
if (url.pathname === '/api/memory' && request.method() === 'GET') {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ results: [memory], count: 1 }),
|
|
});
|
|
}
|
|
if (url.pathname === '/api/memory/memory-research-note' && request.method() === 'DELETE') {
|
|
deleteCalled = true;
|
|
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
|
}
|
|
if (url.pathname === '/api/memory/erase' && request.method() === 'POST') {
|
|
eraseCalled = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
erased: true,
|
|
mind: 'personal',
|
|
result: {
|
|
framesDeleted: 1,
|
|
archiveRedacted: 1,
|
|
chunkVectorsPurged: 0,
|
|
entitiesErased: 1,
|
|
relationsErased: 0,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
if (url.pathname === '/api/memory/suppression' && request.method() === 'GET') {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ mind: 'personal', suppressed: [suppression] }),
|
|
});
|
|
}
|
|
if (url.pathname === '/api/memory/suppression/allow' && request.method() === 'POST') {
|
|
allowCalled = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ removed: true, mind: 'personal' }),
|
|
});
|
|
}
|
|
return route.continue();
|
|
});
|
|
page.on('dialog', async dialog => {
|
|
nativeDialogs.push(dialog.message());
|
|
await dialog.dismiss();
|
|
});
|
|
|
|
await gotoApp(page, '/memory?tab=memories');
|
|
const memoryPanel = page.getByTestId('memory-view-panel');
|
|
const memoryCard = memoryPanel.getByLabel('Research Note', { exact: true });
|
|
await memoryCard.click();
|
|
await expect(page.getByRole('dialog')).toContainText(/research note/i);
|
|
|
|
await page.getByRole('button', { name: /^delete$/i }).click();
|
|
expect(nativeDialogs).toEqual([]);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/delete memory permanently/i);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/archive/i);
|
|
await page.getByTestId('approval-modal-approve').click();
|
|
await expect.poll(() => deleteCalled).toBe(true);
|
|
|
|
await memoryCard.click();
|
|
await page.getByRole('button', { name: /^erase$/i }).click();
|
|
expect(nativeDialogs).toEqual([]);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/erase memory and derived data/i);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/cannot be undone/i);
|
|
await page.getByTestId('approval-modal-approve').click();
|
|
await expect.poll(() => eraseCalled).toBe(true);
|
|
await expect(page.getByText(/erased "research note"/i)).toBeVisible({ timeout: 5_000 });
|
|
|
|
await page.getByRole('button', { name: /erased sources/i }).click();
|
|
await expect(page.getByText('research-export.json')).toBeVisible({ timeout: 5_000 });
|
|
await page.getByRole('button', { name: /allow re-import/i }).click();
|
|
expect(nativeDialogs).toEqual([]);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/allow source to be re-imported/i);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/re-consented/i);
|
|
await page.getByTestId('approval-modal-approve').click();
|
|
await expect.poll(() => allowCalled).toBe(true);
|
|
});
|
|
|
|
test('J3g: wiki export destinations use in-app forms', async ({ page }) => {
|
|
const nativeDialogs: string[] = [];
|
|
let obsidianCalled = false;
|
|
let notionCalled = false;
|
|
let obsidianBody: unknown = null;
|
|
let notionBody: unknown = null;
|
|
const wikiPage = {
|
|
slug: 'research-guide',
|
|
pageType: 'entity',
|
|
name: 'Research Guide',
|
|
contentHash: 'hash-research-guide',
|
|
markdown: '# Research Guide',
|
|
frameIds: 'memory-1',
|
|
compiledAt: '2026-07-08T09:00:00.000Z',
|
|
sourceCount: 3,
|
|
};
|
|
|
|
await page.route('**/api/wiki/**', async route => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
if (url.pathname === '/api/wiki/pages' && request.method() === 'GET') {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([wikiPage]),
|
|
});
|
|
}
|
|
if (url.pathname === '/api/wiki/export/obsidian' && request.method() === 'POST') {
|
|
obsidianCalled = true;
|
|
obsidianBody = request.postDataJSON();
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
outDir: 'C:/Research Vault',
|
|
filesWritten: 3,
|
|
indexPath: 'C:/Research Vault/_index.md',
|
|
byType: { entity: 1, concept: 2 },
|
|
}),
|
|
});
|
|
}
|
|
if (url.pathname === '/api/wiki/export/notion' && request.method() === 'POST') {
|
|
notionCalled = true;
|
|
notionBody = request.postDataJSON();
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
pagesCreated: 1,
|
|
pagesUpdated: 2,
|
|
pagesUnchanged: 0,
|
|
pagesFailed: 0,
|
|
byType: { entity: 1 },
|
|
errors: [],
|
|
}),
|
|
});
|
|
}
|
|
return route.continue();
|
|
});
|
|
page.on('dialog', async dialog => {
|
|
nativeDialogs.push(dialog.message());
|
|
await dialog.dismiss();
|
|
});
|
|
|
|
await gotoApp(page, '/memory?tab=wiki');
|
|
await expect(page.getByText('Research Guide')).toBeVisible({ timeout: 5_000 });
|
|
|
|
await page.getByRole('button', { name: /export to obsidian vault/i }).click();
|
|
expect(nativeDialogs).toEqual([]);
|
|
await expect(page.getByTestId('wiki-export-dialog')).toContainText(/export to obsidian/i);
|
|
await page.getByLabel(/obsidian vault directory/i).fill(' C:/Research Vault ');
|
|
await page.getByRole('button', { name: /^export to obsidian$/i }).click();
|
|
await expect.poll(() => obsidianCalled).toBe(true);
|
|
expect(obsidianBody).toEqual({ outDir: 'C:/Research Vault' });
|
|
await expect(page.getByText(/obsidian export complete/i)).toBeVisible({ timeout: 5_000 });
|
|
|
|
await page.getByRole('button', { name: /export to notion workspace/i }).click();
|
|
expect(nativeDialogs).toEqual([]);
|
|
await expect(page.getByTestId('wiki-export-dialog')).toContainText(/export to notion/i);
|
|
await page.getByLabel(/notion root page url/i).fill(' https://www.notion.so/root ');
|
|
await page.getByRole('button', { name: /^export to notion$/i }).click();
|
|
await expect.poll(() => notionCalled).toBe(true);
|
|
expect(notionBody).toEqual({ rootPageUrl: 'https://www.notion.so/root' });
|
|
await expect(page.getByText(/notion export complete/i)).toBeVisible({ timeout: 5_000 });
|
|
expect(nativeDialogs).toEqual([]);
|
|
});
|
|
|
|
test('J3h: settings backup and restore trust actions stay in-app', async ({ page }) => {
|
|
const nativeDialogs: string[] = [];
|
|
let restoreCalled = false;
|
|
|
|
await page.route('**/api/backup', route => route.fulfill({
|
|
status: 503,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'Vault key missing' }),
|
|
}));
|
|
await page.route('**/api/restore', route => {
|
|
restoreCalled = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ ok: true }),
|
|
});
|
|
});
|
|
page.on('dialog', async dialog => {
|
|
nativeDialogs.push(dialog.message());
|
|
await dialog.dismiss();
|
|
});
|
|
|
|
await gotoApp(page, '/settings?tab=backup');
|
|
await expect(page.getByRole('heading', { name: /encrypted backup/i })).toBeVisible({ timeout: 5_000 });
|
|
|
|
await page.getByRole('button', { name: /create backup/i }).click();
|
|
expect(nativeDialogs).toEqual([]);
|
|
await expect(page.getByRole('alert')).toContainText(/vault key missing/i);
|
|
|
|
await page.getByLabel(/restore backup file/i).setInputFiles({
|
|
name: 'research.waggle-backup',
|
|
mimeType: 'application/octet-stream',
|
|
buffer: Buffer.from('backup-data'),
|
|
});
|
|
expect(nativeDialogs).toEqual([]);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/restore backup/i);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/research\.waggle-backup/i);
|
|
await expect(page.getByTestId('approval-modal')).toContainText(/overwrite current data/i);
|
|
|
|
await page.getByTestId('approval-modal-approve').click();
|
|
await expect.poll(() => restoreCalled).toBe(true);
|
|
await expect(page.getByRole('status').filter({ hasText: /backup restored successfully/i })).toBeVisible({ timeout: 5_000 });
|
|
expect(nativeDialogs).toEqual([]);
|
|
});
|
|
|
|
test('J4: navigate between primary surfaces using sidebar', async ({ page }) => {
|
|
await gotoApp(page);
|
|
|
|
await openViaNav(page, 'nav-memory', /\/memory/);
|
|
await expect(page.locator('body')).toContainText(/memory/i);
|
|
|
|
await openViaNav(page, 'nav-agents', /\/agents/);
|
|
await expect(page.locator('body')).toContainText(/agent|task|template/i);
|
|
|
|
await openViaNav(page, 'nav-library', /\/artifacts/);
|
|
await expect(page.locator('body')).toContainText(/artifact|library|file|workspace/i);
|
|
|
|
await openViaNav(page, 'nav-home', /\/home/);
|
|
await expect(page.locator('body')).toContainText(/workspace|today|continue|create/i);
|
|
});
|
|
|
|
test('J5: navigate views with keyboard shortcuts', async ({ page }) => {
|
|
await gotoApp(page);
|
|
|
|
await pressCtrlShiftDigit(page, '7');
|
|
await page.waitForURL(/\/settings/, { timeout: 10_000 });
|
|
await expect(page.getByRole('tablist', { name: 'Settings sections' })).toBeVisible();
|
|
|
|
await pressCtrlShiftDigit(page, '5');
|
|
await page.waitForURL(/\/memory/, { timeout: 10_000 });
|
|
await expect(page.locator('body')).toContainText(/memory/i);
|
|
|
|
await pressCtrlShiftDigit(page, '2');
|
|
await page.waitForURL(/\/agents/, { timeout: 10_000 });
|
|
await expect(page.locator('body')).toContainText(/agent|task|template/i);
|
|
});
|
|
|
|
test('J6: chat textarea accepts input', async ({ page, request }) => {
|
|
const workspacesRes = await request.get('/api/workspaces');
|
|
const workspaces = await workspacesRes.json();
|
|
let workspaceId = Array.isArray(workspaces) ? workspaces[0]?.id : undefined;
|
|
if (!workspaceId) {
|
|
const createRes = await request.post('/api/workspaces', {
|
|
data: { name: `Journey Chat ${Date.now()}`, group: 'Workspaces', description: 'Chat journey workspace' },
|
|
});
|
|
expect([200, 201, 403, 409]).toContain(createRes.status());
|
|
if (createRes.ok()) {
|
|
const created = await createRes.json();
|
|
const workspace = created.workspace ?? created.data ?? created;
|
|
workspaceId = workspace.id ?? workspace.name;
|
|
} else {
|
|
workspaceId = 'default';
|
|
}
|
|
}
|
|
expect(workspaceId).toBeTruthy();
|
|
|
|
await gotoApp(page, `/workspaces/${workspaceId}/chat`);
|
|
const textarea = page.getByRole('textbox').first();
|
|
await expect(textarea).toBeVisible({ timeout: 10_000 });
|
|
|
|
await textarea.fill('Hello Waggle, this is a test message');
|
|
await expect(textarea).toHaveValue('Hello Waggle, this is a test message');
|
|
|
|
await textarea.fill('/help');
|
|
await expect(textarea).toHaveValue('/help');
|
|
});
|
|
|
|
test('J7: global search opens, searches, selects, and closes', async ({ page }) => {
|
|
await gotoApp(page);
|
|
|
|
await page.keyboard.press('Control+k');
|
|
const dialog = page.getByTestId('command-center-dialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
|
|
const searchInput = dialog.locator('input, [data-slot="command-input"]').first();
|
|
await expect(searchInput).toBeVisible();
|
|
await searchInput.fill('memory');
|
|
await expect(dialog).toContainText(/memory/i);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('J-mobile: Command Center is described and fits at 390px', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await gotoApp(page);
|
|
|
|
await page.keyboard.press('Control+k');
|
|
const dialog = page.getByTestId('command-center-dialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
await expect(dialog).toHaveAttribute('aria-describedby', /.+/);
|
|
|
|
const describedBy = await dialog.getAttribute('aria-describedby');
|
|
const description = await page.evaluate((id) => document.getElementById(id ?? '')?.textContent ?? '', describedBy);
|
|
expect(description).toMatch(/search and run commands/i);
|
|
|
|
const overflow = await visibleHorizontalOverflow(
|
|
page,
|
|
'[data-testid="command-center-dialog"] [cmdk-item]:visible, [data-testid="command-center-dialog"] [cmdk-item] *:visible, [data-testid="command-center-dialog"] input:visible, [data-testid="command-center-dialog"] kbd:visible',
|
|
);
|
|
expect(overflow, 'command center mobile overflow').toEqual([]);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('J8: settings view shows tabs and tab content', async ({ page }) => {
|
|
await gotoApp(page, '/settings');
|
|
|
|
const tablist = page.getByRole('tablist', { name: 'Settings sections' });
|
|
await expect(tablist).toBeVisible();
|
|
|
|
await expect(tablist.getByRole('tab', { name: /Models/i })).toBeVisible();
|
|
await expect(tablist.getByRole('tab', { name: /General/i })).toBeVisible();
|
|
|
|
const panel = page.getByRole('tabpanel').first();
|
|
await tablist.getByRole('tab', { name: /General/i }).click();
|
|
await expect(panel).toContainText(/Theme|Local-first/i);
|
|
|
|
await tablist.getByRole('tab', { name: /Models/i }).click();
|
|
await expect(panel).toContainText(/Model|provider|local/i);
|
|
});
|
|
|
|
test('J-mobile: Settings is usable at 390px width', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
|
|
const routes = ['/settings', '/settings?tab=models', '/settings?tab=billing', '/settings/profile'];
|
|
for (const route of routes) {
|
|
await gotoApp(page, route);
|
|
if (route !== '/settings/profile') {
|
|
await expect(page.getByRole('tablist', { name: 'Settings sections' })).toBeVisible();
|
|
await expect(page.getByRole('tabpanel').first()).toBeVisible();
|
|
} else {
|
|
await expect(page.locator('body')).toContainText(/profile|identity|save|writing style/i);
|
|
}
|
|
|
|
const documentOverflow = await page.evaluate(
|
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
|
);
|
|
expect(documentOverflow, `${route} document overflow`).toBe(false);
|
|
|
|
const overflow = await visibleHorizontalOverflow(
|
|
page,
|
|
'button:visible, [role="tab"]:visible, [role="tabpanel"]:visible, input:visible, select:visible, textarea:visible',
|
|
);
|
|
expect(overflow, `${route} visible control overflow`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test('J-mobile: create workspace prioritizes primary setup at 390px width', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await gotoApp(page);
|
|
|
|
await page.getByTestId('sidebar-workspace').click();
|
|
const switcher = page.getByRole('dialog', { name: /switch workspace/i });
|
|
await expect(switcher).toBeVisible({ timeout: 5_000 });
|
|
await switcher.getByRole('button', { name: /new workspace/i }).click();
|
|
|
|
const dialog = page.getByRole('dialog', { name: /create workspace/i });
|
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
|
|
const nameInput = dialog.getByRole('textbox', { name: /what project or area/i });
|
|
const createButton = dialog.getByRole('button', { name: /^create workspace$/i });
|
|
const templateButton = dialog.getByRole('button', { name: /start from template/i });
|
|
|
|
await expect(nameInput).toBeVisible();
|
|
await expect(createButton).toBeVisible();
|
|
await expect(templateButton).toBeVisible();
|
|
await expect(dialog.getByPlaceholder(/search templates/i)).toHaveCount(0);
|
|
|
|
const rects = await Promise.all([
|
|
nameInput.evaluate(el => {
|
|
const r = el.getBoundingClientRect();
|
|
return { top: Math.floor(r.top), bottom: Math.ceil(r.bottom), viewport: window.innerHeight };
|
|
}),
|
|
createButton.evaluate(el => {
|
|
const r = el.getBoundingClientRect();
|
|
return { top: Math.floor(r.top), bottom: Math.ceil(r.bottom), viewport: window.innerHeight };
|
|
}),
|
|
]);
|
|
expect(rects[0].bottom, `workspace name initially reachable: ${JSON.stringify(rects[0])}`).toBeLessThanOrEqual(rects[0].viewport);
|
|
expect(rects[1].bottom, `create action initially reachable: ${JSON.stringify(rects[1])}`).toBeLessThanOrEqual(rects[1].viewport);
|
|
|
|
await templateButton.click();
|
|
await expect(dialog.getByPlaceholder(/search templates/i)).toBeVisible();
|
|
|
|
const agentButton = dialog.getByRole('button', { name: /choose an agent/i });
|
|
await expect(agentButton).toHaveAttribute('aria-expanded', 'false');
|
|
await expect(dialog.getByText('Agent (optional)', { exact: true })).toHaveCount(0);
|
|
|
|
await agentButton.click();
|
|
await expect(dialog.getByRole('button', { name: /hide agent assignment/i })).toHaveAttribute('aria-expanded', 'true');
|
|
await expect(dialog.getByText('Agent (optional)', { exact: true })).toBeVisible();
|
|
|
|
expect(await visibleHorizontalOverflow(
|
|
page,
|
|
'[role="dialog"]:visible, [role="dialog"] button:visible, [role="dialog"] input:visible, [role="dialog"] textarea:visible',
|
|
), 'create workspace mobile overflow').toEqual([]);
|
|
});
|
|
|
|
test('J-mobile: first-run onboarding keeps primary actions reachable at 390px width', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
const consoleErrors: string[] = [];
|
|
const pageErrors: string[] = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
|
});
|
|
page.on('pageerror', err => pageErrors.push(err.message));
|
|
await page.addInitScript(() => {
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
});
|
|
|
|
await page.goto('/?forceWizard=true', { waitUntil: 'domcontentloaded' });
|
|
const onboarding = page.getByRole('region', { name: /waggle onboarding/i });
|
|
await expect(onboarding).toBeVisible({ timeout: 20_000 });
|
|
|
|
expect(await visibleHorizontalOverflow(
|
|
page,
|
|
'[aria-label="Waggle onboarding"]:visible, [aria-label="Waggle onboarding"] button:visible, [aria-label="Waggle onboarding"] input:visible, [aria-label="Waggle onboarding"] [role="combobox"]:visible',
|
|
), 'welcome overflow').toEqual([]);
|
|
|
|
await onboarding.getByRole('button', { name: /continue/i }).click();
|
|
await expect(onboarding.getByText(/tell us who you are/i)).toBeVisible({ timeout: 10_000 });
|
|
await page.waitForTimeout(300);
|
|
|
|
const profileContinue = onboarding.getByRole('button', { name: /continue/i });
|
|
await expect(profileContinue).toBeVisible();
|
|
const rect = await profileContinue.evaluate(el => {
|
|
const r = el.getBoundingClientRect();
|
|
return { top: Math.floor(r.top), bottom: Math.ceil(r.bottom), viewport: window.innerHeight };
|
|
});
|
|
expect(rect.bottom, `Profile Continue should be initially reachable: ${JSON.stringify(rect)}`).toBeLessThanOrEqual(rect.viewport);
|
|
|
|
expect(await visibleHorizontalOverflow(
|
|
page,
|
|
'[aria-label="Waggle onboarding"]:visible, [aria-label="Waggle onboarding"] button:visible, [aria-label="Waggle onboarding"] input:visible, [aria-label="Waggle onboarding"] [role="combobox"]:visible',
|
|
), 'profile overflow').toEqual([]);
|
|
expect(pageErrors).toHaveLength(0);
|
|
expect(consoleErrors.filter(e => /clerk|content security policy|csp/i.test(e))).toHaveLength(0);
|
|
});
|
|
|
|
test('J-model: onboarding API-key setup reaches a saved, continuable state', async ({ page }) => {
|
|
let keySaved = false;
|
|
const settingsPayloads: Record<string, unknown>[] = [];
|
|
const providers = {
|
|
providers: [
|
|
{
|
|
id: 'anthropic', name: 'Anthropic', hasKey: false, badge: null,
|
|
keyUrl: 'https://console.anthropic.com/settings/keys', requiresKey: true,
|
|
models: [{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', cost: '$$', speed: 'medium' }],
|
|
},
|
|
{
|
|
id: 'openai', name: 'OpenAI', hasKey: false, badge: null,
|
|
keyUrl: 'https://platform.openai.com/api-keys', requiresKey: true,
|
|
models: [{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', cost: '$', speed: 'fast' }],
|
|
},
|
|
],
|
|
search: [{ id: 'duckduckgo', name: 'DuckDuckGo', hasKey: true, priority: 4 }],
|
|
activeSearch: 'duckduckgo',
|
|
};
|
|
|
|
await page.route('**/api/providers', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
...providers,
|
|
providers: providers.providers.map(provider => ({ ...provider, hasKey: provider.id === 'anthropic' ? keySaved : provider.hasKey })),
|
|
}),
|
|
}));
|
|
await page.route('**/api/local-inference/status', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ servers: [], ollamaInstalled: false, totalLocalModels: 0 }),
|
|
}));
|
|
await page.route('**/api/settings/probe-model', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ model: null, configured: false, verified: false }),
|
|
}));
|
|
await page.route('**/api/settings/probe-provider', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ configured: false, valid: false, verified: false }),
|
|
}));
|
|
await page.route('**/api/settings', async route => {
|
|
if (route.request().method() !== 'PUT') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
const payload = route.request().postDataJSON() as Record<string, unknown>;
|
|
settingsPayloads.push(payload);
|
|
const providerUpdate = payload.providers as Record<string, { apiKey?: string }> | undefined;
|
|
if (providerUpdate?.anthropic?.apiKey) keySaved = true;
|
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ defaultModel: 'claude-sonnet-4-6', providers: {} }) });
|
|
});
|
|
await page.route('**/api/settings/test-key', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ valid: true, verified: false }),
|
|
}));
|
|
|
|
await page.addInitScript(() => {
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
});
|
|
await page.goto('/?forceWizard=true', { waitUntil: 'domcontentloaded' });
|
|
|
|
const onboarding = page.getByRole('region', { name: /waggle onboarding/i });
|
|
await expect(onboarding).toBeVisible({ timeout: 20_000 });
|
|
await onboarding.getByRole('button', { name: /continue/i }).click();
|
|
await expect(onboarding.getByText(/tell us who you are/i)).toBeVisible({ timeout: 10_000 });
|
|
await onboarding.getByRole('button', { name: /continue/i }).click();
|
|
await expect(onboarding.getByText(/connect a model/i)).toBeVisible({ timeout: 10_000 });
|
|
|
|
await onboarding.getByRole('button', { name: /anthropic/i }).click();
|
|
const keyInput = onboarding.getByLabel(/api key for anthropic/i);
|
|
await expect(keyInput).toBeFocused();
|
|
await keyInput.fill('sk-ant-browser-contract');
|
|
const saveButton = onboarding.getByRole('button', { name: /validate & save/i });
|
|
await expect(saveButton).toBeEnabled();
|
|
await saveButton.click();
|
|
|
|
await expect(onboarding.getByRole('status').filter({ hasText: /saved/i })).toBeVisible({ timeout: 10_000 });
|
|
await expect.poll(() => keySaved).toBe(true);
|
|
const keyWriteIndex = settingsPayloads.findIndex(payload => 'providers' in payload);
|
|
const modelWriteIndex = settingsPayloads.findIndex(payload => payload.defaultModel === 'claude-sonnet-4-6');
|
|
expect(settingsPayloads[keyWriteIndex]).toMatchObject({ providers: { anthropic: { apiKey: 'sk-ant-browser-contract' } } });
|
|
expect(modelWriteIndex).toBeGreaterThan(keyWriteIndex);
|
|
await expect(onboarding.getByRole('button', { name: /^continue/i })).toBeEnabled({ timeout: 10_000 });
|
|
});
|
|
|
|
test('J-model: Settings API-key setup preserves the same save contract', async ({ page }) => {
|
|
let keySaved = false;
|
|
await page.route('**/api/providers', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
providers: [{
|
|
id: 'anthropic', name: 'Anthropic', hasKey: false, badge: null,
|
|
keyUrl: 'https://console.anthropic.com/settings/keys', requiresKey: true,
|
|
models: [{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', cost: '$$', speed: 'medium' }],
|
|
}],
|
|
search: [{ id: 'duckduckgo', name: 'DuckDuckGo', hasKey: true, priority: 4 }],
|
|
activeSearch: 'duckduckgo',
|
|
}),
|
|
}));
|
|
await page.route('**/api/local-inference/status', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ servers: [], ollamaInstalled: false, totalLocalModels: 0 }),
|
|
}));
|
|
await page.route('**/api/settings/probe-model', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ model: null, configured: false, verified: false }),
|
|
}));
|
|
await page.route('**/api/settings/probe-provider', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ configured: false, valid: false, verified: false }),
|
|
}));
|
|
await page.route('**/api/settings/test-key', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ valid: true, verified: false }),
|
|
}));
|
|
await page.route('**/api/settings', async route => {
|
|
if (route.request().method() !== 'PUT') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
keySaved = true;
|
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ providers: {} }) });
|
|
});
|
|
|
|
await gotoApp(page, '/settings?tab=models');
|
|
const panel = page.getByRole('tabpanel').first();
|
|
await expect(panel.getByText(/bring your own key/i)).toBeVisible({ timeout: 10_000 });
|
|
await panel.getByRole('button', { name: /anthropic/i }).click();
|
|
const keyInput = panel.getByLabel(/api key for anthropic/i);
|
|
await keyInput.fill('sk-ant-settings-contract');
|
|
await panel.getByRole('button', { name: /validate & save/i }).click();
|
|
|
|
await expect(panel.getByRole('status').filter({ hasText: /saved/i })).toBeVisible({ timeout: 10_000 });
|
|
await expect.poll(() => keySaved).toBe(true);
|
|
});
|
|
|
|
test('J-route-coverage: thin utility routes render or redirect clearly', async ({ page }) => {
|
|
await gotoApp(page, '/benchmarks');
|
|
await expect(page.locator('body')).toContainText(/benchmark|capability|score|memory/i);
|
|
|
|
await gotoApp(page, '/platform');
|
|
await expect(page.locator('body')).toContainText(/platform|local|governance|memory|agent/i);
|
|
|
|
await page.goto(routeWithSkip('/payment-cancelled'), { waitUntil: 'domcontentloaded' });
|
|
await page.waitForURL(/\/settings\?tab=billing/, { timeout: 10_000 });
|
|
await waitForShell(page);
|
|
await expect(page.locator('body')).toContainText(/billing|plan|team|solo|checkout/i);
|
|
});
|
|
|
|
test('J-route-coverage: priority thin routes render meaningful shells', async ({ page }) => {
|
|
const routeChecks: Array<[string, RegExp]> = [
|
|
['/launcher', /tool launcher|optional prompt|detecting installed tools|launch/i],
|
|
['/launcher?watch=1', /tool launcher|optional prompt|detecting installed tools|launch/i],
|
|
['/waggle-dance', /waggle dance|signals|discovery|handoff/i],
|
|
['/artifacts', /artifact|library|document|presentation/i],
|
|
['/settings/profile', /who are you|identity|writing style|save/i],
|
|
['/settings/timeline', /timeline|workspace|activity/i],
|
|
['/payment-success', /checkout|paid|plans|nothing to confirm/i],
|
|
['/automations', /automation|schedule|trigger|history|logs/i],
|
|
['/mcps', /mcp hub|installed|catalog|custom/i],
|
|
['/settings/usage', /usage|cost|tokens|budget|upgrade/i],
|
|
['/files', /storage|files|workspace|local/i],
|
|
];
|
|
|
|
for (const [route, bodyPattern] of routeChecks) {
|
|
await gotoApp(page, route);
|
|
await expect(page.locator('body')).toContainText(bodyPattern);
|
|
|
|
if (route === '/files') {
|
|
await expect(page.getByRole('region', { name: /workspace storage overview/i })).toHaveAttribute('tabindex', '0');
|
|
await page.getByRole('tab', { name: /^Files$/ }).click();
|
|
await expect(page.getByRole('region', { name: /files in/i })).toHaveAttribute('tabindex', '0');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('J9: theme cards switch dark/light mode', async ({ page }) => {
|
|
await gotoApp(page, '/settings?tab=general');
|
|
|
|
const panel = page.getByRole('tabpanel');
|
|
await panel.getByRole('button', { name: /Light/i }).click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
|
|
|
await panel.getByRole('button', { name: /Dark/i }).click();
|
|
await expect(page.locator('html')).not.toHaveAttribute('data-theme', 'light');
|
|
});
|
|
|
|
test('J10: home view shows dashboard workspace affordances', async ({ page }) => {
|
|
await gotoApp(page, '/home');
|
|
|
|
const home = page.locator('[data-testid="home-cockpit"], [data-testid="home-cockpit-empty"]').first();
|
|
await expect(home).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.locator('body')).toContainText(/workspace|today|create|continue/i);
|
|
});
|
|
|
|
test('J11: keyboard shortcuts help overlay opens and closes', async ({ page }) => {
|
|
await gotoApp(page);
|
|
|
|
await page.keyboard.press('Control+/');
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toContainText(/Keyboard Shortcuts/i);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('J12: status bar displays product and active surface context', async ({ page }) => {
|
|
await gotoApp(page, '/memory');
|
|
|
|
await expect(page.getByText('Waggle AI')).toBeVisible();
|
|
await expect(page.getByTestId('statusbar-focused-window')).toContainText(/memory/i);
|
|
await expect(page.getByRole('button', { name: 'Search', exact: true })).toBeVisible();
|
|
});
|
|
});
|