Files
waggle-os/tests/e2e/waggle-complete.spec.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

1112 lines
48 KiB
TypeScript

/**
* Waggle OS — Complete E2E Test Suite
*
* Covers every major system implemented across M2, M3, and M4 sprints:
* - API health + core endpoints
* - Tier enforcement (requireTier middleware)
* - Stripe integration (checkout session creation)
* - Marketplace (search, install, sources, sync)
* - Connectors (logos, categories, setup guides)
* - Personas (17 personas, suggested skills/connectors/MCP)
* - Plugin system (CRUD, tool files, hot-reload API)
* - Hooks API (deny rules CRUD)
* - Cost routes (tier gate)
* - Admin overview
* - Cloud sync toggle
* - Embedding quota
* - Skills (list, create, search)
* - UI journeys (app loads, sidebar, navigation, settings tabs)
*
* Run: npx playwright test tests/e2e/waggle-complete.spec.ts --reporter=list
* Prerequisites: server on :3333, frontend built (npm run build)
*/
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
const API = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
// ── Minimal response shapes (API JSON is untyped at the boundary) ──────────
interface ConnectorShape { id?: string; name?: string }
interface PersonaShape { id: string }
interface HookRuleShape { pattern: string }
interface SkillShape { name?: string }
// ── Helpers ──────────────────────────────────────────────────────────────
async function waitForApp(page: Page) {
await page.waitForSelector(
'.waggle-app-shell, .waggle-sidebar, [role="navigation"], [class*="onboarding"]',
{ timeout: 15_000 },
).catch(() => {});
await page.waitForTimeout(800);
}
async function skipOnboarding(page: Page) {
await page.evaluate(() => {
localStorage.setItem('waggle-booted', 'true');
localStorage.setItem('waggle:onboarding', JSON.stringify({
completed: true,
step: 7,
tier: 'power',
tooltipsDismissed: true,
}));
});
}
async function dismissLoginBriefing(page: Page) {
const startBtn = page.locator('button', { hasText: 'Start Working' });
if (await startBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
await startBtn.click();
await page.waitForTimeout(500);
}
}
async function navigateTo(page: Page, view: string) {
// Try dock button by aria-label first (desktop OS paradigm), then sidebar fallback
const dockBtn = page.locator(`button[aria-label="${view}"]`);
if (await dockBtn.isVisible().catch(() => false)) {
await dockBtn.click();
await page.waitForTimeout(400);
return;
}
// Fallback: sidebar navigation
const sidebar = page.locator('[role="navigation"]');
const btn = sidebar.locator('button', { hasText: view });
if (await btn.isVisible().catch(() => false)) {
await btn.click();
await page.waitForTimeout(400);
return;
}
const routes: Record<string, string> = {
Chat: '/chat',
Memory: '/memory',
Settings: '/settings',
Cockpit: '/home',
};
const route = routes[view];
if (route) {
await page.goto(`${route}?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 }).catch(() => {});
}
}
// ══════════════════════════════════════════════════════════════════════════
// SECTION 1 — Core API Health
// ══════════════════════════════════════════════════════════════════════════
test.describe('1. Core API Health', () => {
test('1.1 GET /health returns ok with required fields', async ({ request }) => {
const res = await request.get(`${API}/health`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.status).toBeDefined();
expect(data.llm).toBeDefined();
});
test('1.2 GET /api/workspaces returns array', async ({ request }) => {
const res = await request.get(`${API}/api/workspaces`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
});
test('1.3 GET /api/tier returns tier info with teamsServerAvailable', async ({ request }) => {
const res = await request.get(`${API}/api/tier`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.tier).toBeDefined();
expect(['TRIAL','FREE','TEAMS','ENTERPRISE']).toContain(data.tier);
expect(typeof data.teamsServerAvailable).toBe('boolean');
});
test('1.4 GET /api/connectors returns connectors with new metadata fields', async ({ request }) => {
const res = await request.get(`${API}/api/connectors`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.connectors).toBeDefined();
expect(Array.isArray(data.connectors)).toBe(true);
// Verify at least one connector has new metadata fields
if (data.connectors.length > 0) {
const c = data.connectors[0];
expect(c.id).toBeDefined();
expect(c.name).toBeDefined();
}
});
test('1.5 GET /api/personas returns 17 personas', async ({ request }) => {
const res = await request.get(`${API}/api/personas`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.personas)).toBe(true);
expect(data.personas.length).toBeGreaterThanOrEqual(17);
});
test('1.6 GET /api/skills returns skills list', async ({ request }) => {
const res = await request.get(`${API}/api/skills`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.skills)).toBe(true);
});
test('1.7 GET /api/memory/frames returns results', async ({ request }) => {
const res = await request.get(`${API}/api/memory/frames?limit=5&workspace=default`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.results).toBeDefined();
expect(Array.isArray(data.results)).toBe(true);
});
test('1.8 GET /api/fleet returns sessions', async ({ request }) => {
const res = await request.get(`${API}/api/fleet`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.sessions).toBeDefined();
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 2 — Tier Enforcement
// ══════════════════════════════════════════════════════════════════════════
test.describe('2. Tier Enforcement (requireTier middleware)', () => {
test('2.1 POST /api/personas is free (Solo) — never a tier 403', async ({ request }) => {
const res = await request.post(`${API}/api/personas`, {
data: { name: 'Test', description: 'test', systemPrompt: 'test' },
});
// Custom personas are a free (Solo) feature — creation is accessible on any tier.
// 200/201 = created; 400 = validation. A tier 403 must never fire.
expect([200, 201, 400]).toContain(res.status());
});
test('2.2 POST /api/fleet/spawn is free (agents generate memory) — never a tier 403', async ({ request }) => {
const res = await request.post(`${API}/api/fleet/spawn`, {
data: { personaId: 'researcher' },
});
// Spawning agents is free for all tiers — no tier gate.
expect([200, 201, 400, 422]).toContain(res.status());
});
test('2.3 GET /api/costs requires TEAMS', async ({ request }) => {
const res = await request.get(`${API}/api/costs`);
if (res.status() === 403) {
const data = await res.json();
expect(data.error).toBe('TIER_INSUFFICIENT');
expect(data.required).toBe('TEAMS');
} else {
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.today ?? data.allTime).toBeDefined();
}
});
test('2.4 GET /api/cost/by-workspace requires TEAMS', async ({ request }) => {
const res = await request.get(`${API}/api/cost/by-workspace`);
if (res.status() === 403) {
const data = await res.json();
expect(data.error).toBe('TIER_INSUFFICIENT');
expect(data.required).toBe('TEAMS');
} else {
expect(res.ok()).toBe(true);
}
});
test('2.5 POST /api/team/connect requires TEAMS', async ({ request }) => {
const res = await request.post(`${API}/api/team/connect`, {
data: { teamServerUrl: 'http://test.example.com', token: 'test' },
});
if (res.status() === 403) {
const data = await res.json();
expect(data.error).toBe('TIER_INSUFFICIENT');
expect(data.required).toBe('TEAMS');
} else {
expect([200, 201, 400, 422]).toContain(res.status());
}
});
test('2.6 GET /api/admin/overview requires TEAMS', async ({ request }) => {
const res = await request.get(`${API}/api/admin/overview`);
if (res.status() === 403) {
const data = await res.json();
expect(data.error).toBe('TIER_INSUFFICIENT');
expect(data.required).toBe('TEAMS');
} else {
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.workspaces).toBeDefined();
expect(data.usage).toBeDefined();
}
});
test('2.7 403 response has upgradeUrl', async ({ request }) => {
// Force a 403 by hitting a TEAMS route without credentials
const res = await request.get(`${API}/api/cloud-sync/toggle`);
// This route might not exist as GET — but tier gate should still fire
// Just verify if we get 403, the shape is correct
if (res.status() === 403) {
const data = await res.json();
expect(data.upgradeUrl).toBeDefined();
expect(data.upgradeUrl).toMatch(/waggle/);
}
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 3 — Stripe Integration
// ══════════════════════════════════════════════════════════════════════════
test.describe('3. Stripe Integration', () => {
test('3.1 POST /api/stripe/create-checkout-session — TEAMS tier returns URL or 503', async ({ request }) => {
const res = await request.post(`${API}/api/stripe/create-checkout-session`, {
data: { tier: 'TEAMS', billingPeriod: 'monthly' },
});
// 200 = Stripe configured, returns checkout URL
// 503 = STRIPE_NOT_CONFIGURED (env not set)
// 400 = NO_PRICE_CONFIGURED (price env not set)
expect([200, 400, 503]).toContain(res.status());
if (res.status() === 200) {
const data = await res.json();
expect(data.url).toMatch(/stripe\.com|checkout/);
}
if (res.status() === 503) {
const data = await res.json();
expect(data.error).toBe('STRIPE_NOT_CONFIGURED');
}
});
test('3.2 POST /api/stripe/create-checkout-session — FREE tier is invalid', async ({ request }) => {
const res = await request.post(`${API}/api/stripe/create-checkout-session`, {
data: { tier: 'FREE' },
});
expect([400, 503]).toContain(res.status());
if (res.status() === 400) {
const data = await res.json();
expect(data.error).toMatch(/INVALID_TIER|NO_PRICE/);
}
});
test('3.3 POST /api/stripe/webhook — invalid signature returns 400', async ({ request }) => {
const res = await request.post(`${API}/api/stripe/webhook`, {
data: { type: 'checkout.session.completed' },
headers: { 'stripe-signature': 'invalid_sig' },
});
// 400 = invalid signature (expected when Stripe is configured)
// 503 = Stripe not configured
expect([400, 503]).toContain(res.status());
});
test('3.4 POST /api/stripe/create-portal-session is ungated (any authed user)', async ({ request }) => {
const res = await request.post(`${API}/api/stripe/create-portal-session`, {
data: {},
});
// Ungated (requireTier('FREE') no-op floor) so a legacy sub can self-cancel:
// without a Stripe customer → 400 NO_STRIPE_CUSTOMER
// with a Stripe customer → 200
// Stripe not configured → 503
// A tier 403 must never fire here.
expect([200, 400, 503]).toContain(res.status());
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 4 — Marketplace
// ══════════════════════════════════════════════════════════════════════════
test.describe('4. Marketplace', () => {
test('4.1 GET /api/marketplace/search returns packages', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/search?query=&limit=10`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.packages).toBeDefined();
expect(Array.isArray(data.packages)).toBe(true);
expect(data.total).toBeGreaterThanOrEqual(0);
});
test('4.2 GET /api/marketplace/search with type filter works', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/search?type=skill&limit=5`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.packages)).toBe(true);
// All returned packages should be skills
for (const pkg of data.packages) {
expect(pkg.waggle_install_type).toBe('skill');
}
});
test('4.3 GET /api/marketplace/packs returns packs', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/packs`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.packs)).toBe(true);
});
test('4.4 GET /api/marketplace/categories returns category taxonomy', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/categories`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.categories)).toBe(true);
expect(data.categories.length).toBeGreaterThan(10);
// Each category should have id, name, icon
const cat = data.categories[0];
expect(cat.id).toBeDefined();
expect(cat.name).toBeDefined();
});
test('4.5 GET /api/marketplace/sources returns sources list', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/sources`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.sources)).toBe(true);
expect(data.total).toBeGreaterThan(0);
});
test('4.6 GET /api/marketplace/installed returns installations', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/installed`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.installations)).toBe(true);
});
test('4.7 POST /api/marketplace/sources with invalid URL returns 400', async ({ request }) => {
const res = await request.post(`${API}/api/marketplace/sources`, {
data: { name: 'test', url: 'not-a-url' },
});
expect(res.status()).toBe(400);
});
test('4.8 POST /api/marketplace/install is free (Solo) — never a tier 403', async ({ request }) => {
const res = await request.post(`${API}/api/marketplace/install`, {
data: { packageId: 9999 },
});
// Personal marketplace install is a free (Solo) feature — no tier gate.
// 404 = package not found; 400 = bad request. A tier 403 must never fire.
expect([200, 400, 404]).toContain(res.status());
});
test('4.9 POST /api/marketplace/sync returns sync results', async ({ request }) => {
test.setTimeout(75_000); // sync makes real network calls — can take 30-60s under load
const res = await request.post(`${API}/api/marketplace/sync`, {
data: {},
timeout: 65_000,
});
expect(res.ok()).toBe(true);
const data = await res.json();
expect(typeof data.sourcesChecked).toBe('number');
expect(typeof data.packagesAdded).toBe('number');
});
test('4.10 GET /api/marketplace/security-status returns scanner status', async ({ request }) => {
const res = await request.get(`${API}/api/marketplace/security-status`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(typeof data.ciscoScannerAvailable).toBe('boolean');
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 5 — Connectors
// ══════════════════════════════════════════════════════════════════════════
test.describe('5. Connectors', () => {
test('5.1 GET /api/connectors returns 29+ connectors', async ({ request }) => {
const res = await request.get(`${API}/api/connectors`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.connectors)).toBe(true);
expect(data.connectors.length).toBeGreaterThanOrEqual(29);
});
test('5.2 Connectors have required fields (id, name, status, authType)', async ({ request }) => {
const res = await request.get(`${API}/api/connectors`);
const data = await res.json();
for (const c of data.connectors.slice(0, 5)) {
expect(c.id).toBeDefined();
expect(c.name).toBeDefined();
expect(c.status).toBeDefined();
expect(c.authType).toBeDefined();
}
});
test('5.3 Composio connector is present', async ({ request }) => {
const res = await request.get(`${API}/api/connectors`);
const data = await res.json();
const composio = data.connectors.find((c: ConnectorShape) => c.id === 'composio');
expect(composio).toBeDefined();
expect(composio.name).toMatch(/composio/i);
});
test('5.4 All connectors have a valid status field', async ({ request }) => {
const res = await request.get(`${API}/api/connectors`);
const data = await res.json();
for (const c of data.connectors) {
expect(['connected', 'disconnected', 'expired', 'error']).toContain(c.status);
}
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 6 — Personas
// ══════════════════════════════════════════════════════════════════════════
test.describe('6. Personas', () => {
test('6.1 GET /api/personas returns exactly 17 personas', async ({ request }) => {
const res = await request.get(`${API}/api/personas`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.personas.length).toBeGreaterThanOrEqual(17);
});
test('6.2 New personas exist (general-purpose, planner, verifier, coordinator)', async ({ request }) => {
const res = await request.get(`${API}/api/personas`);
const data = await res.json();
const ids = data.personas.map((p: PersonaShape) => p.id);
expect(ids).toContain('general-purpose');
expect(ids).toContain('planner');
expect(ids).toContain('verifier');
expect(ids).toContain('coordinator');
});
test('6.3 Read-only personas exist (planner, verifier)', async ({ request }) => {
const res = await request.get(`${API}/api/personas`);
const data = await res.json();
const ids = data.personas.map((p: PersonaShape) => p.id);
expect(ids).toContain('planner');
expect(ids).toContain('verifier');
// isReadOnly is a backend-only field not serialized to API — verify by name presence
});
test('6.4 Personas have failurePatterns (min 3 each)', async ({ request }) => {
const res = await request.get(`${API}/api/personas`);
const data = await res.json();
for (const p of data.personas.slice(0, 5)) {
if (p.failurePatterns) {
expect(Array.isArray(p.failurePatterns)).toBe(true);
expect(p.failurePatterns.length).toBeGreaterThanOrEqual(3);
}
}
});
test('6.5 POST /api/personas/generate is free (Solo) — never a tier 403', async ({ request }) => {
const res = await request.post(`${API}/api/personas/generate`, {
data: { description: 'A helpful assistant' },
});
// AI-generated personas are a free (Solo) feature — no tier gate.
expect([200, 201, 400, 422]).toContain(res.status());
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 7 — Plugin System
// ══════════════════════════════════════════════════════════════════════════
test.describe('7. Plugin System', () => {
test('7.1 GET /api/plugins returns installed plugins', async ({ request }) => {
const res = await request.get(`${API}/api/plugins`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.plugins)).toBe(true);
expect(typeof data.count).toBe('number');
expect(typeof data.directory).toBe('string');
});
test('7.2 GET /api/plugins/:name/tools — 404 for non-existent plugin', async ({ request }) => {
const res = await request.get(`${API}/api/plugins/nonexistent-plugin-xyz/tools`);
expect(res.status()).toBe(404);
});
test('7.3 Path traversal protection on plugin name', async ({ request }) => {
const res = await request.get(`${API}/api/plugins/../etc/passwd/tools`);
expect([400, 404]).toContain(res.status());
});
test('7.4 POST /api/plugins/install with missing sourceDir returns 400', async ({ request }) => {
const res = await request.post(`${API}/api/plugins/install`, {
data: {},
});
expect(res.status()).toBe(400);
const data = await res.json();
expect(data.error).toMatch(/sourceDir|path/i);
});
test('7.5 DELETE /api/plugins/:name — 400 for path traversal attempt', async ({ request }) => {
const res = await request.delete(`${API}/api/plugins/..%2F..%2Fetc`);
expect([400, 404]).toContain(res.status());
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 8 — Hooks API
// ══════════════════════════════════════════════════════════════════════════
test.describe('8. Hooks API', () => {
test('8.1 GET /api/hooks returns rules array', async ({ request }) => {
const res = await request.get(`${API}/api/hooks`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.rules)).toBe(true);
expect(typeof data.total).toBe('number');
});
test('8.2 POST /api/hooks — adds a deny rule', async ({ request }) => {
const res = await request.post(`${API}/api/hooks`, {
data: { type: 'deny', tools: ['bash'], pattern: 'e2e-test-pattern-xyz' },
});
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.ok).toBe(true);
expect(Array.isArray(data.rules)).toBe(true);
// Find our rule
const added = data.rules.find((r: HookRuleShape) => r.pattern === 'e2e-test-pattern-xyz');
expect(added).toBeDefined();
expect(added.type).toBe('deny');
expect(added.tools).toContain('bash');
});
test('8.3 POST /api/hooks — missing type returns 400', async ({ request }) => {
const res = await request.post(`${API}/api/hooks`, {
data: { tools: ['bash'], pattern: 'test' },
});
expect(res.status()).toBe(400);
});
test('8.4 POST /api/hooks — empty tools returns 400', async ({ request }) => {
const res = await request.post(`${API}/api/hooks`, {
data: { type: 'deny', tools: [], pattern: 'test' },
});
expect(res.status()).toBe(400);
});
test('8.5 DELETE /api/hooks/:index — removes rule', async ({ request }) => {
// First add a rule
await request.post(`${API}/api/hooks`, {
data: { type: 'deny', tools: ['bash'], pattern: 'e2e-delete-test-xyz' },
});
// Get current rules
const listRes = await request.get(`${API}/api/hooks`);
const listData = await listRes.json();
const idx = listData.rules.findIndex((r: HookRuleShape) => r.pattern === 'e2e-delete-test-xyz');
if (idx >= 0) {
const delRes = await request.delete(`${API}/api/hooks/${idx}`);
expect(delRes.ok()).toBe(true);
const delData = await delRes.json();
expect(delData.ok).toBe(true);
// Rule should be gone
const notFound = delData.rules.find((r: HookRuleShape) => r.pattern === 'e2e-delete-test-xyz');
expect(notFound).toBeUndefined();
}
});
test('8.6 DELETE /api/hooks/:index — out of range returns 404', async ({ request }) => {
const res = await request.delete(`${API}/api/hooks/9999`);
expect(res.status()).toBe(404);
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 9 — Cost Dashboard
// ══════════════════════════════════════════════════════════════════════════
test.describe('9. Cost Dashboard', () => {
test('9.1 GET /api/costs — accessible on TEAMS+', async ({ request }) => {
const res = await request.get(`${API}/api/costs`);
if (res.ok()) {
const data = await res.json();
expect(data.today ?? data.allTime).toBeDefined();
expect(Array.isArray(data.daily)).toBe(true);
} else {
expect(res.status()).toBe(403); // below TEAMS
}
});
test('9.2 GET /api/cost/by-workspace — accessible on TEAMS+', async ({ request }) => {
const res = await request.get(`${API}/api/cost/by-workspace`);
if (res.ok()) {
const data = await res.json();
expect(Array.isArray(data.workspaces)).toBe(true);
} else {
expect(res.status()).toBe(403);
}
});
test('9.3 GET /api/costs — returns budget info', async ({ request }) => {
const res = await request.get(`${API}/api/costs`);
if (res.ok()) {
const data = await res.json();
expect(data.budget).toBeDefined();
} else {
expect(res.status()).toBe(403);
}
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 10 — Admin + Cloud Sync
// ══════════════════════════════════════════════════════════════════════════
test.describe('10. Admin & Cloud Sync', () => {
test('10.1 GET /api/admin/overview — TEAMS gate respected', async ({ request }) => {
const res = await request.get(`${API}/api/admin/overview`);
if (res.ok()) {
const data = await res.json();
expect(data.workspaces).toBeDefined();
expect(Array.isArray(data.workspaces)).toBe(true);
expect(data.usage).toBeDefined();
expect(data.connectors).toBeDefined();
expect(data.plugins).toBeDefined();
expect(data.generatedAt).toBeDefined();
} else {
expect(res.status()).toBe(403);
}
});
test('10.2 GET /api/cloud-sync returns status', async ({ request }) => {
const res = await request.get(`${API}/api/cloud-sync`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(typeof data.available).toBe('boolean');
expect(typeof data.enabled).toBe('boolean');
expect(data.tier).toBeDefined();
});
test('10.3 POST /api/cloud-sync/toggle requires TEAMS tier', async ({ request }) => {
const res = await request.post(`${API}/api/cloud-sync/toggle`, {
data: { enabled: true },
});
if (res.status() === 403) {
const data = await res.json();
expect(data.error).toBe('TIER_INSUFFICIENT');
expect(data.required).toBe('TEAMS');
} else if (res.ok()) {
const data = await res.json();
expect(data.ok).toBe(true);
expect(typeof data.enabled).toBe('boolean');
}
});
test('10.4 GET /api/admin/audit-export requires TEAMS tier', async ({ request }) => {
const res = await request.get(`${API}/api/admin/audit-export`);
if (res.status() === 403) {
const data = await res.json();
expect(data.error).toBe('TIER_INSUFFICIENT');
} else if (res.ok()) {
const data = await res.json();
expect(data.records).toBeDefined();
expect(Array.isArray(data.records)).toBe(true);
expect(data.exportedAt).toBeDefined();
}
});
test('10.5 GET /api/admin/audit-export?format=csv returns CSV', async ({ request }) => {
const res = await request.get(`${API}/api/admin/audit-export?format=csv`);
if (res.ok()) {
const contentType = res.headers()['content-type'] ?? '';
expect(contentType).toContain('text/csv');
} else {
expect(res.status()).toBe(403);
}
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 11 — Skills CRUD
// ══════════════════════════════════════════════════════════════════════════
test.describe('11. Skills CRUD', () => {
const TEST_SKILL_NAME = `e2e-test-skill-${Date.now()}`;
test('11.1 GET /api/skills returns skills array', async ({ request }) => {
const res = await request.get(`${API}/api/skills`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data.skills)).toBe(true);
});
test('11.2 POST /api/skills creates a new skill', async ({ request }) => {
const res = await request.post(`${API}/api/skills`, {
data: {
name: TEST_SKILL_NAME,
content: `# ${TEST_SKILL_NAME}\n\nE2E test skill. Safe to delete.`,
},
});
expect([200, 201]).toContain(res.status());
const data = await res.json();
expect(data.ok ?? data.name).toBeTruthy();
});
test('11.3 GET /api/skills after create includes new skill', async ({ request }) => {
const res = await request.get(`${API}/api/skills`);
const data = await res.json();
const found = data.skills.find((s: string | SkillShape) => {
const name = typeof s === 'string' ? s : s.name;
return name === TEST_SKILL_NAME || JSON.stringify(s).includes(TEST_SKILL_NAME);
});
// Note: skill may not be instantly visible depending on directory scan
expect(Array.isArray(data.skills)).toBe(true);
});
test('11.4 GET /api/skills/:name reads skill content', async ({ request }) => {
// Create first
await request.post(`${API}/api/skills`, {
data: { name: 'e2e-read-test', content: '# Read Test\n\nContent here.' },
});
const res = await request.get(`${API}/api/skills/e2e-read-test`);
if (res.ok()) {
const data = await res.json();
expect(data.content ?? data.name).toBeDefined();
} else {
expect([404]).toContain(res.status());
}
});
test('11.5 DELETE /api/skills/:name removes skill', async ({ request }) => {
await request.post(`${API}/api/skills`, {
data: { name: 'e2e-delete-skill', content: '# Delete Me' },
});
const res = await request.delete(`${API}/api/skills/e2e-delete-skill`);
expect([200, 204, 404]).toContain(res.status());
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 12 — Memory + Workspaces
// ══════════════════════════════════════════════════════════════════════════
test.describe('12. Memory + Workspaces', () => {
test('12.1 GET /api/workspaces returns array with default workspace', async ({ request }) => {
const res = await request.get(`${API}/api/workspaces`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
});
test('12.2 GET /api/memory/frames returns paginated results', async ({ request }) => {
const res = await request.get(`${API}/api/memory/frames?limit=10&workspace=default`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.results).toBeDefined();
expect(Array.isArray(data.results)).toBe(true);
expect(typeof data.count).toBe('number');
});
test('12.3 GET /api/memory/frames respects limit param', async ({ request }) => {
const res = await request.get(`${API}/api/memory/frames?limit=3&workspace=default`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.results.length).toBeLessThanOrEqual(3);
});
test('12.4 GET /api/memory/frames with search returns results', async ({ request }) => {
// Memory search uses GET /api/memory/frames with query params
const res = await request.get(`${API}/api/memory/frames?limit=5&workspace=default`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.results).toBeDefined();
expect(Array.isArray(data.results)).toBe(true);
});
test('12.5 workspace context endpoint exists or returns expected status', async ({ request }) => {
const res = await request.get(`${API}/api/workspaces/default/context`);
// 404 is acceptable — endpoint may not be implemented yet
// What matters: no 500 crash
expect(res.status()).not.toBe(500);
expect([200, 404, 405, 429]).toContain(res.status());
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 13 — Plugin Tool Files API
// ══════════════════════════════════════════════════════════════════════════
test.describe('13. Plugin Tool Files API', () => {
const PLUGIN_NAME = 'e2e-test-plugin';
const TOOL_NAME = 'e2e-test-tool';
test('13.1 GET /api/plugins/:name/tools — 404 for missing plugin', async ({ request }) => {
const res = await request.get(`${API}/api/plugins/definitely-not-real-xyz/tools`);
expect(res.status()).toBe(404);
});
test('13.2 Path traversal blocked on tool name', async ({ request }) => {
const res = await request.get(`${API}/api/plugins/my-plugin/tools/..%2F..%2Fetc`);
expect([400, 404]).toContain(res.status());
});
test('13.3 PUT /api/plugins/:name/tools/:toolName — non-existent plugin creates dir or fails gracefully', async ({ request }) => {
const res = await request.put(`${API}/api/plugins/nonexistent-xyz/tools/my-tool`, {
data: { content: 'export async function execute(args) { return "ok"; }' },
});
// May create the dir (200) or fail (400/404/500) — both are acceptable
expect([200, 400, 404, 500]).toContain(res.status());
});
test('13.4 PUT /api/plugins/:name/tools/:toolName — missing execute() returns 400', async ({ request }) => {
// This only validates if plugin exists — skip if not
const listRes = await request.get(`${API}/api/plugins`);
const listData = await listRes.json();
if (listData.plugins.length === 0) {
// No plugins installed — test validation endpoint directly with bad content
const res = await request.put(`${API}/api/plugins/any-plugin/tools/my-tool`, {
data: { content: 'const x = 1; // no execute function' },
});
// Should get 400 (missing execute) or 404 (plugin doesn't exist)
expect([400, 404]).toContain(res.status());
if (res.status() === 400) {
const data = await res.json();
expect(data.error).toMatch(/execute/i);
}
}
});
test('13.5 POST /api/plugins/:name/tools — validation: name must be alphanumeric', async ({ request }) => {
const listRes = await request.get(`${API}/api/plugins`);
const listData = await listRes.json();
if (listData.plugins.length > 0) {
const pluginName = listData.plugins[0].name;
const res = await request.post(`${API}/api/plugins/${pluginName}/tools`, {
data: { name: 'invalid name with spaces!', description: 'test' },
});
expect(res.status()).toBe(400);
const data = await res.json();
expect(data.error).toMatch(/alphanumeric|name/i);
}
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 14 — UI Journey Tests
// ══════════════════════════════════════════════════════════════════════════
test.describe('14. UI Journey Tests', () => {
// These tests require the frontend to be built (dist/) and served by the
// webServer. Skip only if the server returns JSON on '/' instead of HTML.
// NOTE: this guard previously probed :8080 (a dev server that never runs in
// this harness), so the entire UI suite silently skipped. Probe the real
// :3333 baseURL the webServer actually serves.
test.beforeEach(async ({ request, baseURL }, testInfo) => {
const root = baseURL ?? process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
try {
const res = await request.get(`${root}/`, { timeout: 5_000 });
const ct = res.headers()['content-type'] ?? '';
if (!ct.includes('text/html')) {
testInfo.skip(true, `Frontend not built (server returns ${ct || 'non-HTML'} on /) — skipping UI tests`);
}
} catch {
testInfo.skip(true, `Frontend not reachable on ${root} — skipping UI tests`);
}
});
test('14.1 App loads without blank screen or JS crash', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/');
await waitForApp(page);
const bodyText = await page.textContent('body');
expect(bodyText?.trim().length).toBeGreaterThan(10);
// No fatal JS errors that break rendering
const fatalErrors = errors.filter(e =>
e.includes('Cannot read') || e.includes('is not a function') || e.includes('undefined')
);
expect(fatalErrors).toHaveLength(0);
});
test('14.2 App shows onboarding or main shell — no blank state', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
const hasShell = await page.locator('.waggle-app-shell, .waggle-sidebar').isVisible().catch(() => false);
const hasOnboarding = await page.locator('[class*="onboarding"], text=Welcome, text=Get Started').isVisible().catch(() => false);
const hasContent = await page.locator('body').textContent();
expect(hasShell || hasOnboarding || (hasContent?.length ?? 0) > 50).toBe(true);
});
test('14.3 Sidebar navigation items are visible', async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
const nav = page.locator('[role="navigation"]');
const isVisible = await nav.isVisible().catch(() => false);
if (isVisible) {
const buttons = nav.locator('button');
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(5);
}
});
test('14.4 Settings view loads when clicking Settings', async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
await dismissLoginBriefing(page);
await navigateTo(page, 'Settings');
// Wait for settings panel to fully render — under load, 500ms is insufficient
await page.waitForFunction(() =>
(document.body.textContent?.length ?? 0) > 200,
{ timeout: 15000 }
).catch(() => {});
const bodyText = await page.textContent('body') ?? '';
expect(
bodyText.includes('General') || bodyText.includes('Models') ||
bodyText.includes('Keys') || bodyText.includes('Settings')
).toBeTruthy();
});
test('14.5 Settings tabs are present (General, Models, Vault, Team)', async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
await dismissLoginBriefing(page);
await navigateTo(page, 'Settings');
// Wait for tab labels to appear in DOM — not just a fixed timer
await page.waitForFunction(() => {
const t = document.body.textContent ?? '';
return t.includes('General') || t.includes('Models') || t.includes('Keys');
}, { timeout: 10000 }).catch(() => {});
const bodyText = await page.textContent('body') ?? '';
const hasTabs = bodyText.includes('General') || bodyText.includes('Models') || bodyText.includes('Keys');
expect(hasTabs).toBe(true);
});
test('14.6 Capabilities API is accessible', async ({ request }) => {
// Skills & Apps is a zone-child in power/admin tiers only, so test the API directly
const res = await request.get(`${API}/api/skills`);
expect(res.ok()).toBe(true);
const data = await res.json();
expect(data.skills).toBeDefined();
expect(Array.isArray(data.skills)).toBe(true);
});
test('14.7 Memory view loads', async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
await dismissLoginBriefing(page);
await navigateTo(page, 'Memory');
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
});
test('14.8 Chat view shows message input', async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
await dismissLoginBriefing(page);
await navigateTo(page, 'Chat');
// Wait for chat content to render — textarea OR chat text
await page.waitForFunction(() => {
const t = document.body.textContent ?? '';
return document.querySelector('textarea') !== null ||
t.includes('Ask') || t.includes('message') || t.includes('workspace');
}, { timeout: 8000 }).catch(() => {});
const hasInput = await page.locator('textarea').isVisible().catch(() => false);
const body = await page.textContent('body') ?? '';
expect(hasInput || body.includes('Ask') || body.includes('message') || body.includes('workspace')).toBe(true);
});
test('14.9 No 404 errors on static assets', async ({ page }) => {
const failed: string[] = [];
page.on('response', res => {
if (res.status() === 404 && !res.url().includes('/api/')) {
failed.push(res.url());
}
});
await page.goto('/');
await waitForApp(page);
expect(failed).toHaveLength(0);
});
test('14.10 Cockpit view loads without error', async ({ page }) => {
await page.goto('/');
await skipOnboarding(page);
await page.reload();
await waitForApp(page);
await navigateTo(page, 'Cockpit');
await page.waitForTimeout(500);
const body = await page.textContent('body') ?? '';
expect(body.length).toBeGreaterThan(50);
});
});
// ══════════════════════════════════════════════════════════════════════════
// SECTION 15 — Security
// ══════════════════════════════════════════════════════════════════════════
test.describe('15. Security', () => {
test('15.1 Path traversal blocked on skills', async ({ request }) => {
const res = await request.get(`${API}/api/skills/..%2F..%2F..%2Fetc%2Fpasswd`);
expect([400, 404]).toContain(res.status());
if (res.ok()) {
const body = await res.text();
expect(body).not.toContain('root:');
}
});
test('15.2 Path traversal blocked on plugin names', async ({ request }) => {
const attacks = [
'../../../etc/passwd',
'..\\..\\windows\\system32',
'%2e%2e%2f%2e%2e%2f',
];
for (const attack of attacks) {
const res = await request.get(`${API}/api/plugins/${encodeURIComponent(attack)}/tools`);
expect([400, 404]).toContain(res.status());
}
});
test('15.3 SQL injection attempt in memory frames returns safe response', async ({ request }) => {
// Use GET endpoint (POST /api/memory/search doesn't exist)
const injection = encodeURIComponent("'; DROP TABLE memory_frames; --");
const res = await request.get(`${API}/api/memory/frames?limit=5&workspace=${injection}`);
// Should succeed safely or return error — never execute injection
expect([200, 400, 404]).toContain(res.status());
if (res.ok()) {
const data = await res.json();
expect(data.results).toBeDefined();
}
});
test('15.4 Large payload rejected gracefully', async ({ request }) => {
const bigPayload = 'x'.repeat(10 * 1024 * 1024); // 10MB
try {
const res = await request.post(`${API}/api/chat`, {
data: { message: bigPayload, workspace: 'default' },
timeout: 10000,
});
// Server should reject or handle without crashing
expect([200, 400, 413, 500]).toContain(res.status());
} catch {
// ECONNRESET is acceptable — server dropped the oversized connection
}
});
test('15.5 Unknown API routes return 404, not 500', async ({ request }) => {
const res = await request.get(`${API}/api/definitely-does-not-exist-xyz-abc`);
expect(res.status()).toBe(404);
});
test('15.6 Stripe webhook rejects requests without signature', async ({ request }) => {
const res = await request.post(`${API}/api/stripe/webhook`, {
data: { type: 'checkout.session.completed', data: { object: {} } },
});
// No stripe-signature header → should reject
expect([400, 503]).toContain(res.status());
});
test('15.7 Admin audit export path traversal blocked', async ({ request }) => {
const res = await request.get(`${API}/api/admin/audit-export?from=../../../etc&to=passwd`);
// Should either succeed safely or return 403 (tier gate)
expect([200, 400, 403]).toContain(res.status());
if (res.ok()) {
const body = await res.text();
expect(body).not.toContain('root:');
}
});
});