Addresses two interrelated UX complaints:
1. Browser-default controls leaking through the dark theme
(native <select> chevrons, OS checkbox/radio look, date-picker
icons, <details> triangle marker, number input spinners).
2. Subroute pages dropping the main app's tab bar in favor of a back
link, breaking navigation continuity across users/files/queues/
dead-letters/queues-[name].
Design tokens (dashboard/src/routes/+layout.svelte):
- Token vocabulary expanded with 18 new variables covering
text-strong, accent + accent-fg, danger/success/warning bg/fg/border
triplets, bg-elevated-hover, radii (sm/md/lg/pill), shadow-elev-2,
and z scale (popover/modal/toast). 7 alias tokens (--muted,
--link, --text, --color-error, --color-border, --chip-bg,
--code-bg) absorb the orphan references the F-U-004 remediation
partially renamed.
- Global :global(...) resets for <select>, <input type='checkbox'>,
<input type='radio'>, <input type='number'>, <input type='date'>,
and <details>/<summary> ensure native controls track the dark
palette out of the box. No per-page edits needed.
Tab consistency:
- New dashboard/src/lib/AppTabBar.svelte renders all 11 per-app
tabs (Scripts, Domains, Members, Triggers, Topics, Secrets,
Settings, Users, Files, Queues, Dead letters) as <a> links with
an active highlight derived from the URL. Tabs that switch
in-page panels go to ?tab=<id>; tabs that switch routes go to
the subroute. Admin-only tabs are hidden when canAdmin is false.
- New dashboard/src/routes/apps/[slug]/+layout.svelte loads the
app once, handles the historical-slug redirect, exposes the
shared app + canAdmin + canWrite + dead-letter-count state via
Svelte context, and renders the breadcrumb + AppTabBar above
every per-app page. The 5 subroute pages drop their own "← back"
headers since the layout owns them now.
- apps/[slug]/+page.svelte's local-state activeTab becomes URL-
driven via $page.url.searchParams.get('tab'). Defense-in-depth
redirect for non-admin viewers landing on admin-only tabs uses
goto({replaceState:true}) instead of mutating state.
Light-theme leftovers swept on 5 subroute pages:
- dead-letters: error banner, badge, pre/code blocks all swap to
--color-danger-*, --bg-elevated, --text-primary
- files: button.danger, var(--muted,#666) → token-only
- queues + queues/[name]: bare hex fallbacks removed; .toolbar and
.auto-refresh styled with tokens; data-testid for the queues
empty state (already added by previous commit, reaffirmed here)
- users + users/invitations: badge-ok/badge-pending now use
--color-success-bg/fg and --color-warning-bg/fg; chips use
--bg-elevated + --text-strong; .create-form gets a token-styled
surface; row-action buttons gain explicit dark-theme styling
E2E selector updates:
- members.spec.ts, integration.spec.ts, apps.spec.ts — the tab bar
is now <a> elements (role=link) without a count suffix. Test
selectors swap from getByRole('button', name: /^Scripts \(\d+\)$/)
to getByRole('link', { name: 'Scripts' }), etc.
- New navigation/tabs.spec.ts still passes; existing 60+ tests
unchanged except for the selector swap. Two pre-existing failures
(routing.spec.ts:79, integration.spec.ts:89) untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
6.3 KiB
TypeScript
159 lines
6.3 KiB
TypeScript
import { expect, request, type Page } from '@playwright/test';
|
||
import { test } from '../fixtures/ids';
|
||
import { CleanupRegistry } from '../fixtures/cleanup';
|
||
import { adminApi } from '../fixtures/api';
|
||
|
||
// Full-stack integration scenarios. Unlike the per-page B1–B8 specs,
|
||
// these drive a complete user journey across multiple pages and then
|
||
// verify the data plane / API surface behaves the way the dashboard
|
||
// promised it would.
|
||
|
||
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||
|
||
const cleanup = new CleanupRegistry();
|
||
test.afterEach(async () => {
|
||
await cleanup.run();
|
||
});
|
||
|
||
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
|
||
const cm = page.locator(locator).first();
|
||
await cm.click();
|
||
await page.keyboard.press('ControlOrMeta+A');
|
||
await page.keyboard.press('Delete');
|
||
await page.keyboard.type(text);
|
||
}
|
||
|
||
test('end-to-end: app + domain + script + route via dashboard → invoke via public URL', async ({
|
||
page,
|
||
uniqueSlug
|
||
}) => {
|
||
const slug = uniqueSlug('public');
|
||
const domain = `${slug}.local`;
|
||
const routePath = `/${slug}/hello`;
|
||
const scriptName = `${slug}-hello`;
|
||
const scriptSource = `return #{ statusCode: 200, body: #{ source: "public", slug: "${slug}" } };`;
|
||
|
||
// 1. Create the app from the apps list.
|
||
await page.goto('/admin/apps');
|
||
await page.getByRole('button', { name: 'New app' }).click();
|
||
await page.getByLabel('Name').fill(slug);
|
||
const slugInput = page.getByLabel('Slug');
|
||
await slugInput.fill('');
|
||
await slugInput.fill(slug);
|
||
await page.getByRole('button', { name: 'Create app' }).click();
|
||
cleanup.app(slug);
|
||
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||
|
||
// 2. Open the app and claim the domain on the Domains tab.
|
||
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
|
||
await page.getByRole('link', { name: 'Domains' }).click();
|
||
const domainForm = page.locator('form.create-form.inline');
|
||
await domainForm.getByPlaceholder(/app\.example\.com/).fill(domain);
|
||
await domainForm.getByRole('button', { name: /^Add domain$/ }).click();
|
||
await expect(page.locator('.domain-row')).toContainText(domain);
|
||
|
||
// 3. Create the script on the Scripts tab.
|
||
await page.getByRole('link', { name: 'Scripts' }).click();
|
||
await page.getByRole('button', { name: /^New script$/ }).click();
|
||
await page.getByLabel('Name').fill(scriptName);
|
||
await fillCodeMirror(page, '.cm-content', scriptSource);
|
||
await page.getByRole('button', { name: /^Create script$/ }).click();
|
||
|
||
// 4. Open the script and bind a route on the Routing tab.
|
||
await page.getByRole('link', { name: new RegExp(scriptName) }).click();
|
||
await page.getByRole('button', { name: 'Routing' }).click();
|
||
await page.getByRole('button', { name: '+ Add route' }).click();
|
||
const routeForm = page.locator('form.route-form');
|
||
await routeForm.getByLabel('Path', { exact: true }).fill(routePath);
|
||
await routeForm.getByLabel('Method').selectOption('GET');
|
||
await routeForm.getByLabel(/^Host/).fill(domain);
|
||
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||
await expect(page.locator('.route-list')).toContainText(routePath);
|
||
|
||
// 5. Invoke via the public URL, with the Host header pointing at
|
||
// the claimed domain. The dev backend listens on 127.0.0.1; the
|
||
// orchestrator resolves the app from Host, then the route.
|
||
const publicCtx = await request.newContext({ baseURL: API_BASE });
|
||
try {
|
||
const res = await publicCtx.get(routePath, { headers: { host: domain } });
|
||
expect(res.status()).toBe(200);
|
||
const body = (await res.json()) as { source: string; slug: string };
|
||
expect(body.source).toBe('public');
|
||
expect(body.slug).toBe(slug);
|
||
} finally {
|
||
await publicCtx.dispose();
|
||
}
|
||
});
|
||
|
||
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
|
||
page,
|
||
uniqueUsername
|
||
}) => {
|
||
// Worker-aware unique helper instead of Date.now() — keeps two
|
||
// workers from minting the same name on the same millisecond.
|
||
const name = uniqueUsername('cli');
|
||
|
||
// 1. Mint the key from /profile and capture the revealed token.
|
||
await page.goto('/admin/profile');
|
||
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
|
||
const mintForm = page.locator('form.mint');
|
||
await mintForm.getByPlaceholder('e.g. ci-deploy').fill(name);
|
||
// script:read is enough to read the scripts list — that's our
|
||
// "CLI verb" below.
|
||
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||
|
||
const reveal = page.locator('.reveal');
|
||
await expect(reveal).toBeVisible();
|
||
const rawToken = (await reveal.locator('code.token').textContent())?.trim();
|
||
expect(rawToken).toBeTruthy();
|
||
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||
|
||
// 2. Act like a CLI: call the API directly with Bearer <token>.
|
||
const cli = await request.newContext({
|
||
baseURL: API_BASE,
|
||
extraHTTPHeaders: { authorization: `Bearer ${rawToken}` }
|
||
});
|
||
try {
|
||
const ok = await cli.get('/api/v1/admin/scripts');
|
||
expect(ok.status()).toBe(200);
|
||
const body = (await ok.json()) as unknown;
|
||
expect(Array.isArray(body)).toBe(true);
|
||
|
||
// Sanity: a route the scope doesn't cover must reject.
|
||
// `script:read` cannot list instance admins (that's
|
||
// instance:admin territory).
|
||
const denied = await cli.get('/api/v1/admin/admins');
|
||
expect(denied.status()).toBe(403);
|
||
|
||
// 3. Revoke via the dashboard.
|
||
await page.reload();
|
||
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
|
||
await expect(revokeBtn).toBeVisible();
|
||
await revokeBtn.click();
|
||
await page.getByRole('dialog').getByRole('button', { name: /^Revoke$/ }).click();
|
||
await expect(revokeBtn).toHaveCount(0);
|
||
|
||
// 4. Same CLI call must now fail auth.
|
||
const afterRevoke = await cli.get('/api/v1/admin/scripts');
|
||
expect(afterRevoke.status()).toBe(401);
|
||
} finally {
|
||
await cli.dispose();
|
||
}
|
||
|
||
// Belt-and-braces cleanup: if the UI revoke missed, drop via API.
|
||
const api = await adminApi();
|
||
try {
|
||
const list = await api.get('/api/v1/admin/api-keys');
|
||
if (list.ok()) {
|
||
const all = (await list.json()) as Array<{ id: string; name: string }>;
|
||
const k = all.find((x) => x.name === name);
|
||
if (k) cleanup.apiKey(k.id);
|
||
}
|
||
} finally {
|
||
await api.dispose();
|
||
}
|
||
});
|