test: add browser auth e2e coverage

This commit is contained in:
Estrella Pan
2026-07-10 13:50:15 +02:00
parent 693044984e
commit 8df50b65eb
30 changed files with 1349 additions and 17 deletions

3
.gitignore vendored
View File

@@ -191,6 +191,9 @@ lerna-debug.log*
node_modules
dist
webui/.vite/deps/*
webui/e2e/.auth/
webui/playwright-report/
webui/test-results/
dist.zip
dist-ssr
*.local

View File

@@ -1,3 +1,6 @@
/build
/dist
/dev-dist
/e2e/.auth
/playwright-report
/test-results

View File

@@ -1,6 +1,9 @@
/build
/dist
/dev-dist
/e2e/.auth
/playwright-report
/test-results
/pnpm-lock.yaml
auto-imports.d.ts
components.d.ts

156
webui/e2e/fixtures/api.ts Normal file
View File

@@ -0,0 +1,156 @@
import type { APIRequestContext, APIResponse } from '@playwright/test';
import type {
ApiTokenCreated,
ApiTokenPublic,
Credentials,
SetupStatus,
TokenScope,
UserPublic,
} from './data';
import { SESSION_SUCCESS } from './data';
async function requireStatus(
response: APIResponse,
expected: number
): Promise<APIResponse> {
if (response.status() !== expected) {
throw new Error(
`${response.url()} returned ${response.status()} ` +
`${response.statusText()}, expected ${expected}`
);
}
return response;
}
export class E2EApi {
constructor(readonly request: APIRequestContext) {}
async setupStatus(): Promise<SetupStatus> {
const response = await requireStatus(
await this.request.get('/api/v1/setup/status'),
200
);
return response.json();
}
async attemptLogin(credentials: Credentials): Promise<APIResponse> {
return this.request.post('/api/v1/auth/login', {
form: {
username: credentials.username,
password: credentials.password,
},
});
}
async login(credentials: Credentials): Promise<void> {
const response = await requireStatus(
await this.attemptLogin(credentials),
200
);
const payload = await response.json();
if (JSON.stringify(payload) !== JSON.stringify(SESSION_SUCCESS))
throw new Error('Login response did not match the session-only contract');
}
async me(expectedStatus = 200): Promise<UserPublic | null> {
const response = await requireStatus(
await this.request.get('/api/v1/auth/me'),
expectedStatus
);
return expectedStatus === 200 ? response.json() : null;
}
async logout(): Promise<void> {
await requireStatus(await this.request.post('/api/v1/auth/logout'), 200);
}
async updateCurrent(credentials: Credentials): Promise<void> {
const response = await requireStatus(
await this.request.post('/api/v1/auth/update', {
data: credentials,
}),
200
);
const payload = await response.json();
if (JSON.stringify(payload) !== JSON.stringify(SESSION_SUCCESS))
throw new Error('Credential update exposed an unexpected response shape');
}
async listUsers(): Promise<UserPublic[]> {
const response = await requireStatus(
await this.request.get('/api/v1/users'),
200
);
return response.json();
}
async createUser(credentials: Credentials): Promise<UserPublic> {
const response = await requireStatus(
await this.request.post('/api/v1/users', {
data: { ...credentials, enabled: true },
}),
201
);
return response.json();
}
async deleteUser(userId: number): Promise<void> {
await requireStatus(
await this.request.delete(`/api/v1/users/${userId}`),
204
);
}
async createToken(name: string, scope: TokenScope): Promise<ApiTokenCreated> {
const response = await requireStatus(
await this.request.post('/api/v1/tokens', {
data: { name, scope },
}),
201
);
return response.json();
}
async listTokens(): Promise<ApiTokenPublic[]> {
const response = await requireStatus(
await this.request.get('/api/v1/tokens'),
200
);
return response.json();
}
async revokeToken(tokenId: number): Promise<void> {
await requireStatus(
await this.request.delete(`/api/v1/tokens/${tokenId}`),
204
);
}
async bearerGet(
path: string,
token: string,
expectedStatus: number
): Promise<APIResponse> {
return requireStatus(
await this.request.get(path, {
headers: { Authorization: `Bearer ${token}` },
}),
expectedStatus
);
}
async disableMcpIpBypass(): Promise<void> {
const getResponse = await requireStatus(
await this.request.get('/api/v1/config/get'),
200
);
const config = (await getResponse.json()) as Record<string, unknown> & {
security: Record<string, unknown>;
};
config.security.mcp_whitelist = [];
await requireStatus(
await this.request.patch('/api/v1/config/update', { data: config }),
200
);
}
}

154
webui/e2e/fixtures/auth.ts Normal file
View File

@@ -0,0 +1,154 @@
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import type {
Browser,
BrowserContext,
Page,
Response,
TestInfo,
} from '@playwright/test';
import { expect } from '@playwright/test';
import { E2EApi } from './api';
import type { Credentials } from './data';
import { SESSION_SUCCESS } from './data';
import type { E2EEnvironment } from './env';
import { NetworkGuard } from './network';
export async function forceEnglish(page: Page): Promise<void> {
await page.addInitScript(() => localStorage.setItem('lang', 'en'));
}
export async function loginThroughUI(
page: Page,
credentials: Credentials,
expectedStatus = 200
): Promise<Response> {
await page.goto('/#/login');
await page.getByLabel('Username').fill(credentials.username);
await page.getByLabel('Password').fill(credentials.password);
const responsePromise = page.waitForResponse(
(response) =>
response.url().endsWith('/api/v1/auth/login') &&
response.request().method() === 'POST'
);
await page.getByRole('button', { name: 'Login', exact: true }).click();
const response = await responsePromise;
expect(response.status()).toBe(expectedStatus);
if (expectedStatus === 200) {
expect(await response.json()).toEqual(SESSION_SUCCESS);
await expect(page).toHaveURL(/#\/bangumi$/);
}
return response;
}
export async function completeFirstRunSetup(
page: Page,
environment: E2EEnvironment
): Promise<void> {
await forceEnglish(page);
await page.goto('/#/setup');
await expect(page.getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
'1'
);
await page.getByRole('button', { name: 'Get Started' }).click();
await page.getByLabel('Username').fill(environment.username);
await page.getByLabel('Password', { exact: true }).fill(environment.password);
await page.getByLabel('Confirm Password').fill(environment.password);
await page.getByRole('button', { name: 'Next' }).click();
await page.getByLabel('Host').fill(environment.downloaderURL);
await page.getByLabel('Username').fill(environment.downloaderUsername);
await page.getByLabel('Password').fill(environment.downloaderPassword);
await page.getByLabel('Download Path').fill('/downloads/Bangumi');
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Skip' }).click();
await page.getByRole('button', { name: 'Skip' }).click();
const completeResponse = page.waitForResponse(
(response) =>
response.url().endsWith('/api/v1/setup/complete') &&
response.request().method() === 'POST'
);
await page.getByRole('button', { name: 'Complete Setup' }).click();
const response = await completeResponse;
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ status: true });
await expect(page).toHaveURL(/#\/login$/);
}
export async function bootstrapAuthenticatedState(
page: Page,
environment: E2EEnvironment,
storageStatePath: string
): Promise<void> {
const api = new E2EApi(page.context().request);
const setup = await api.setupStatus();
if (setup.need_setup) await completeFirstRunSetup(page, environment);
await loginThroughUI(page, environment);
const tokenCookie = (await page.context().cookies()).find(
(cookie) => cookie.name === 'token'
);
expect(tokenCookie).toMatchObject({ httpOnly: true, sameSite: 'Strict' });
await api.disableMcpIpBypass();
await mkdir(dirname(storageStatePath), { recursive: true });
await page.context().storageState({ path: storageStatePath });
}
export interface AuthenticatedContext {
context: BrowserContext;
page: Page;
guard: NetworkGuard;
}
export async function closeAuthenticatedContexts(
contexts: AuthenticatedContext[],
testInfo: TestInfo
): Promise<void> {
const guardResults = await Promise.allSettled(
contexts.map(({ guard }) => guard.finish(testInfo))
);
const closeResults = await Promise.allSettled(
contexts.map(({ context }) => context.close())
);
const failure = [...guardResults, ...closeResults].find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failure) throw failure.reason;
}
export async function newAuthenticatedContext(
browser: Browser,
environment: E2EEnvironment,
credentials: Credentials,
testInfo: TestInfo
): Promise<AuthenticatedContext> {
const mobile = testInfo.project.name.includes('webkit');
const context = await browser.newContext({
baseURL: environment.baseURL,
locale: 'en-US',
serviceWorkers: 'block',
// The Playwright `browser` fixture applies the project's authenticated
// storage state to manually-created contexts unless it is overridden.
// Secondary-user and logout tests need a genuinely empty session.
storageState: { cookies: [], origins: [] },
viewport: mobile
? { width: 390, height: 844 }
: { width: 1280, height: 720 },
});
try {
const page = await context.newPage();
await forceEnglish(page);
const guard = new NetworkGuard(page, environment);
await guard.install();
await loginThroughUI(page, credentials);
return { context, page, guard };
} catch (error) {
await context.close().catch(() => undefined);
throw error;
}
}

View File

@@ -0,0 +1,41 @@
export interface Credentials {
username: string;
password: string;
}
export interface SetupStatus {
need_setup: boolean;
version: string;
}
export interface UserPublic {
id: number;
username: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
export type TokenScope = 'api' | 'mcp';
export interface ApiTokenPublic {
id: number;
user_id: number;
name: string;
scope: TokenScope;
prefix: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
}
export interface ApiTokenCreated extends ApiTokenPublic {
token: string;
}
export const SESSION_SUCCESS = { authenticated: true } as const;
export function changedPassword(prefix: string): string {
return `${prefix}-Changed9!`;
}

73
webui/e2e/fixtures/env.ts Normal file
View File

@@ -0,0 +1,73 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const fixtureDirectory = fileURLToPath(new URL('.', import.meta.url));
export interface E2EEnvironment {
baseURL: string;
mockURL: string;
mockInternalURL: string;
fakeQbURL: string;
fakeQbInternalURL: string;
realQbURL: string | null;
realQbInternalURL: string | null;
downloaderURL: string;
downloaderUsername: string;
downloaderPassword: string;
username: string;
password: string;
artifactDirectory: string;
downloadDirectory: string;
profile: 'browser' | 'downloader';
}
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value)
throw new Error(`${name} must be set by the hermetic stack runner`);
return value.replace(/\/$/, '');
}
function optional(name: string): string | null {
const value = process.env[name]?.trim();
return value ? value.replace(/\/$/, '') : null;
}
export function getE2EEnvironment(): E2EEnvironment {
const profile = process.env.AB_E2E_PROFILE ?? 'browser';
if (profile !== 'browser' && profile !== 'downloader')
throw new Error(`Unsupported AB_E2E_PROFILE: ${profile}`);
const fakeQbURL = required('AB_E2E_FAKE_QB_URL');
const fakeQbInternalURL = required('AB_E2E_FAKE_QB_INTERNAL_URL');
const realQbURL = optional('AB_E2E_QB_URL');
const realQbInternalURL = optional('AB_E2E_QB_INTERNAL_URL');
if (profile === 'downloader' && (!realQbURL || !realQbInternalURL))
throw new Error(
'AB_E2E_QB_URL and AB_E2E_QB_INTERNAL_URL are required for downloader profile'
);
return {
baseURL: required('AB_E2E_BASE_URL'),
mockURL: required('AB_E2E_MOCK_URL'),
mockInternalURL: required('AB_E2E_MOCK_INTERNAL_URL'),
fakeQbURL,
fakeQbInternalURL,
realQbURL,
realQbInternalURL,
downloaderURL:
profile === 'downloader' ? realQbInternalURL! : fakeQbInternalURL,
downloaderUsername: process.env.AB_E2E_QB_USERNAME?.trim() || 'admin',
downloaderPassword: process.env.AB_E2E_QB_PASSWORD?.trim() || 'adminadmin',
username: required('AB_E2E_USERNAME'),
password: required('AB_E2E_PASSWORD'),
artifactDirectory: resolve(required('AB_E2E_ARTIFACT_DIR')),
downloadDirectory: resolve(required('AB_E2E_DOWNLOAD_DIR')),
profile,
};
}
export function authStatePath(browser: string): string {
const profile = process.env.AB_E2E_PROFILE ?? 'browser';
return resolve(fixtureDirectory, `../.auth/${profile}-${browser}.json`);
}

View File

@@ -0,0 +1,156 @@
import type { Page, Response, TestInfo } from '@playwright/test';
import type { E2EEnvironment } from './env';
const MAX_RESPONSE_BODY = 4096;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function redactArtifact(
value: string,
environment: E2EEnvironment
): string {
let redacted = value;
const secrets = [
environment.password,
environment.downloaderPassword,
process.env.AB_E2E_QB_PASSWORD ?? '',
].filter(Boolean);
for (const secret of secrets)
redacted = redacted.replace(
new RegExp(escapeRegExp(secret), 'g'),
'[REDACTED]'
);
return redacted
.replace(/(Bearer\s+)[^\s"',]+/gi, '$1[REDACTED]')
.replace(/ab_(?:api|mcp|session)_[A-Za-z0-9._~-]+/g, '[REDACTED]')
.replace(
/("?(?:password|token|authorization|cookie)"?\s*[:=]\s*)"?[^\s,"'}]+/gi,
'$1[REDACTED]'
);
}
interface FailedResponseArtifact {
status: number;
url: string;
body: string;
}
export class NetworkGuard {
private readonly allowedOrigins: Set<string>;
private readonly blocked: string[] = [];
private readonly consoleMessages: string[] = [];
private readonly pageErrors: string[] = [];
private readonly failedRequests: string[] = [];
private readonly failedResponses: FailedResponseArtifact[] = [];
private readonly pending = new Set<Promise<void>>();
constructor(
private readonly page: Page,
private readonly environment: E2EEnvironment
) {
this.allowedOrigins = new Set(
[environment.baseURL, environment.mockURL].map(
(value) => new URL(value).origin
)
);
}
private isAllowed(rawURL: string): boolean {
const url = new URL(rawURL);
if (url.protocol === 'ws:') url.protocol = 'http:';
if (url.protocol === 'wss:') url.protocol = 'https:';
if (url.protocol === 'http:' || url.protocol === 'https:')
return this.allowedOrigins.has(url.origin);
return ['about:', 'blob:', 'data:'].includes(url.protocol);
}
async install(): Promise<void> {
const context = this.page.context();
await context.route('**/*', async (route) => {
const url = route.request().url();
if (!this.isAllowed(url)) {
this.blocked.push(url);
await route.abort('blockedbyclient');
return;
}
await route.continue();
});
await context.routeWebSocket('**/*', async (webSocket) => {
const url = webSocket.url();
if (!this.isAllowed(url)) {
this.blocked.push(url);
await webSocket.close({ code: 1008, reason: 'E2E network allowlist' });
return;
}
webSocket.connectToServer();
});
this.page.on('console', (message) => {
this.consoleMessages.push(`${message.type()}: ${message.text()}`);
});
this.page.on('pageerror', (error) => {
this.pageErrors.push(error.stack ?? error.message);
});
this.page.on('requestfailed', (request) => {
this.failedRequests.push(
`${request.method()} ${request.url()}: ` +
`${request.failure()?.errorText ?? 'unknown failure'}`
);
});
this.page.on('response', (response) => {
if (response.status() < 400) return;
const capture = this.captureResponse(response).finally(() => {
this.pending.delete(capture);
});
this.pending.add(capture);
});
}
private async captureResponse(response: Response): Promise<void> {
let body = '';
try {
body = (await response.text()).slice(0, MAX_RESPONSE_BODY);
} catch {
body = '[unavailable]';
}
this.failedResponses.push({
status: response.status(),
url: response.url(),
body,
});
}
async finish(testInfo: TestInfo): Promise<void> {
await Promise.allSettled(this.pending);
if (testInfo.status !== testInfo.expectedStatus) {
const diagnostics = redactArtifact(
JSON.stringify(
{
blocked: this.blocked,
console: this.consoleMessages,
pageErrors: this.pageErrors,
failedRequests: this.failedRequests,
failedResponses: this.failedResponses,
},
null,
2
),
this.environment
);
await testInfo.attach('browser-diagnostics.json', {
body: diagnostics,
contentType: 'application/json',
});
}
if (this.blocked.length > 0) {
const urls = this.blocked
.map((url) => redactArtifact(url, this.environment))
.join(', ');
throw new Error(`Browser attempted public network access: ${urls}`);
}
}
}

View File

@@ -0,0 +1,11 @@
import { bootstrapAuthenticatedState } from '../fixtures/auth';
import { authStatePath } from '../fixtures/env';
import { test } from '../fixtures/test';
test('complete first-run setup for Chromium', async ({ page, environment }) => {
await bootstrapAuthenticatedState(
page,
environment,
authStatePath('chromium')
);
});

View File

@@ -0,0 +1,11 @@
import { bootstrapAuthenticatedState } from '../fixtures/auth';
import { authStatePath } from '../fixtures/env';
import { test } from '../fixtures/test';
test('complete first-run setup for Firefox', async ({ page, environment }) => {
await bootstrapAuthenticatedState(
page,
environment,
authStatePath('firefox')
);
});

View File

@@ -0,0 +1,7 @@
import { bootstrapAuthenticatedState } from '../fixtures/auth';
import { authStatePath } from '../fixtures/env';
import { test } from '../fixtures/test';
test('complete first-run setup for WebKit', async ({ page, environment }) => {
await bootstrapAuthenticatedState(page, environment, authStatePath('webkit'));
});

View File

@@ -0,0 +1,75 @@
import { E2EApi } from '../fixtures/api';
import {
closeAuthenticatedContexts,
forceEnglish,
loginThroughUI,
newAuthenticatedContext,
} from '../fixtures/auth';
import { SESSION_SUCCESS } from '../fixtures/data';
import { expect, test } from '../fixtures/test';
test('refresh keeps a cookie-only session without exposing a token', async ({
page,
}) => {
const refreshResponse = page.waitForResponse(
(response) =>
response.url().endsWith('/api/v1/auth/refresh_token') &&
response.request().method() === 'POST'
);
await page.goto('/#/bangumi');
const response = await refreshResponse;
expect(response.status()).toBe(200);
expect(await response.json()).toEqual(SESSION_SUCCESS);
const tokenCookie = (await page.context().cookies()).find(
(cookie) => cookie.name === 'token'
);
expect(tokenCookie).toMatchObject({ httpOnly: true, sameSite: 'Strict' });
});
test('wrong password is rejected without leaving the login page', async ({
page,
environment,
}) => {
await page.context().clearCookies();
await forceEnglish(page);
await page.addInitScript(() => localStorage.setItem('isLoggedIn', 'false'));
await loginThroughUI(
page,
{ username: environment.username, password: 'definitely-wrong' },
401
);
await expect(page).toHaveURL(/#\/login$/);
expect(
(await page.context().cookies()).some((cookie) => cookie.name === 'token')
).toBe(false);
});
test('logout and browser back cannot revive a revoked session', async ({
browser,
environment,
}, testInfo) => {
const authenticated = await newAuthenticatedContext(
browser,
environment,
environment,
testInfo
);
const { page } = authenticated;
try {
await page.goto('/#/bangumi');
const api = new E2EApi(page.context().request);
await api.logout();
await page.reload();
await expect(page).toHaveURL(/#\/login$/);
await page.goBack();
await expect(page).not.toHaveURL(/#\/bangumi$/);
await page.goto('/#/bangumi');
await expect(page).toHaveURL(/#\/login$/);
} finally {
await closeAuthenticatedContexts([authenticated], testInfo);
}
});

View File

@@ -0,0 +1,24 @@
import { expect, test } from '../fixtures/test';
test('production shell and health endpoint are served', async ({
page,
request,
}) => {
const health = await request.get('/health');
expect(health.ok()).toBe(true);
await expect(health.json()).resolves.toMatchObject({
status: 'ok',
db_ok: true,
});
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('lang', /.+/);
const assets = await page
.locator('script[src], link[rel="stylesheet"]')
.evaluateAll((elements) =>
elements.map(
(element) => element.getAttribute('src') ?? element.getAttribute('href')
)
);
expect(assets.some((asset) => asset?.includes('/assets/'))).toBe(true);
});

View File

@@ -0,0 +1,137 @@
import { request as playwrightRequest } from '@playwright/test';
import { E2EApi } from '../fixtures/api';
import type { AuthenticatedContext } from '../fixtures/auth';
import {
closeAuthenticatedContexts,
loginThroughUI,
newAuthenticatedContext,
} from '../fixtures/auth';
import type { ApiTokenCreated } from '../fixtures/data';
import { SESSION_SUCCESS, changedPassword } from '../fixtures/data';
import { expect, test } from '../fixtures/test';
test('password change rotates both browser contexts and rejects the old password', async ({
api,
browser,
environment,
uniqueName,
}, testInfo) => {
const credentials = {
username: `u-${uniqueName.slice(-16)}`,
password: 'Initial-Password9!',
};
const user = await api.createUser(credentials);
let first: AuthenticatedContext | undefined;
let second: AuthenticatedContext | undefined;
let cleanupFailure: unknown;
try {
first = await newAuthenticatedContext(
browser,
environment,
credentials,
testInfo
);
second = await newAuthenticatedContext(
browser,
environment,
credentials,
testInfo
);
await first.page.getByRole('button', { name: 'System menu' }).click();
await first.page.getByRole('menuitem', { name: 'Profile' }).click();
const dialog = first.page.getByRole('dialog').last();
const nextCredentials = {
username: credentials.username,
password: changedPassword(uniqueName),
};
await dialog.getByLabel('Username').fill(nextCredentials.username);
await dialog
.getByLabel('Password', { exact: true })
.fill(nextCredentials.password);
const updateResponse = first.page.waitForResponse(
(response) =>
response.url().endsWith('/api/v1/auth/update') &&
response.request().method() === 'POST'
);
await dialog.getByRole('button', { name: 'Update' }).click();
expect(await (await updateResponse).json()).toEqual(SESSION_SUCCESS);
await new E2EApi(second.context.request).me(401);
await second.context.clearCookies();
await second.page.evaluate(() =>
localStorage.setItem('isLoggedIn', 'false')
);
await second.page.reload();
await expect(second.page).toHaveURL(/#\/login$/);
await loginThroughUI(second.page, credentials, 401);
await loginThroughUI(second.page, nextCredentials);
await expect(second.page).toHaveURL(/#\/bangumi$/);
} finally {
const contexts = [first, second].filter(
(value): value is AuthenticatedContext => value !== undefined
);
const cleanup = await Promise.allSettled([
closeAuthenticatedContexts(contexts, testInfo),
api.deleteUser(user.id),
]);
const failure = cleanup.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
cleanupFailure = failure?.reason;
}
if (cleanupFailure) throw cleanupFailure;
});
test('API and MCP tokens are isolated and Bearer cannot control accounts', async ({
api,
environment,
uniqueName,
}) => {
const bearerContext = await playwrightRequest.newContext({
baseURL: environment.baseURL,
});
const bearerApi = new E2EApi(bearerContext);
const tokens: ApiTokenCreated[] = [];
let cleanupFailure: unknown;
try {
const apiToken = await api.createToken(`${uniqueName}-api`, 'api');
tokens.push(apiToken);
const mcpToken = await api.createToken(`${uniqueName}-mcp`, 'mcp');
tokens.push(mcpToken);
await bearerApi.bearerGet('/api/v1/bangumi/get/all', apiToken.token, 200);
await bearerApi.bearerGet('/api/v1/users', apiToken.token, 403);
await bearerApi.bearerGet('/api/v1/tokens', apiToken.token, 403);
await bearerApi.bearerGet('/api/v1/bangumi/get/all', mcpToken.token, 401);
await bearerApi.bearerGet('/mcp/sse', apiToken.token, 403);
// An explicit invalid Authorization header must beat the valid admin cookie.
await api.bearerGet('/api/v1/bangumi/get/all', mcpToken.token, 401);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${environment.baseURL}/mcp/sse`, {
headers: { Authorization: `Bearer ${mcpToken.token}` },
signal: controller.signal,
});
expect(response.status).toBe(200);
} finally {
clearTimeout(timeout);
controller.abort();
}
} finally {
const cleanup = await Promise.allSettled([
bearerContext.dispose(),
...tokens.map((token) => api.revokeToken(token.id)),
]);
const failure = cleanup.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
cleanupFailure = failure?.reason;
}
if (cleanupFailure) throw cleanupFailure;
});

View File

@@ -0,0 +1,190 @@
import { createHash } from 'node:crypto';
import type { Page, Route } from '@playwright/test';
import type { ApiTokenCreated } from '../fixtures/data';
import { expect, test } from '../fixtures/test';
// A screenshot or video of the reveal dialog is itself a secret leak. This
// spec relies on the redacted NetworkGuard diagnostics instead.
test.use({ screenshot: 'off', trace: 'off', video: 'off' });
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
async function expectSecretMatches(page: Page, secret: string): Promise<void> {
const revealed = await page.getByLabel('One-time token').textContent();
expect(sha256(revealed ?? '')).toBe(sha256(secret));
}
async function expectSecretAbsent(page: Page, secret: string): Promise<void> {
const body = await page.locator('body').innerText();
expect(body.includes(secret)).toBe(false);
}
async function openAccess(page: Page): Promise<void> {
await page.goto('/#/config');
await expect(page).toHaveURL(/#\/config$/);
const accessPanel = page
.getByRole('button', { name: 'Users & Access', exact: true })
.last();
await expect(accessPanel).toBeVisible();
}
async function openBangumi(page: Page): Promise<void> {
await page.goto('/#/bangumi');
await expect(page).toHaveURL(/#\/bangumi$/);
await expect(
page.getByRole('button', { name: 'Add RSS Feed', exact: true })
).toBeVisible();
}
async function createTokenFromUI(
page: Page,
name: string
): Promise<ApiTokenCreated> {
await page.getByRole('button', { name: 'Create token' }).click();
await page.getByLabel('Token name').fill(name);
const createResponse = page.waitForResponse(
(response) =>
response.url().endsWith('/api/v1/tokens') &&
response.request().method() === 'POST'
);
await page.getByRole('button', { name: 'Create', exact: true }).click();
const response = await createResponse;
expect(response.status()).toBe(201);
const created = (await response.json()) as ApiTokenCreated;
return created;
}
test('one-time token disappears on close and never appears in the list', async ({
page,
api,
uniqueName,
}) => {
await openAccess(page);
let created: ApiTokenCreated | null = null;
try {
created = await createTokenFromUI(page, `${uniqueName}-close`);
await expectSecretMatches(page, created.token);
const revealDialog = page.getByRole('dialog', { name: 'Token created' });
await revealDialog.getByRole('button', { name: 'Close' }).click();
await expect(page.getByLabel('One-time token')).toHaveCount(0);
await openBangumi(page);
await openAccess(page);
const tokenRow = page.getByRole('group', { name: created.name });
await expect(tokenRow).toContainText(created.prefix);
await expectSecretAbsent(page, created.token);
expect((await tokenRow.innerText()).includes(created.token)).toBe(false);
} finally {
if (created) await api.revokeToken(created.id);
}
});
function requireCreatedToken(created: ApiTokenCreated | null): ApiTokenCreated {
if (!created) throw new Error('The delayed token request was never created');
return created;
}
test('late token response cannot restore a secret after navigation', async ({
page,
api,
uniqueName,
}) => {
let releaseResponse!: () => void;
const release = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
let upstreamReady!: () => void;
const ready = new Promise<void>((resolve) => {
upstreamReady = resolve;
});
let routeSettled!: () => void;
const settled = new Promise<void>((resolve) => {
routeSettled = resolve;
});
let created: ApiTokenCreated | null = null;
let handlerStarted = false;
let responseDelivered = false;
let deliveryFailure: unknown;
let cleanupFailure: unknown;
const tokenName = `${uniqueName}-late`;
const handleTokenRoute = async (route: Route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
handlerStarted = true;
try {
const response = await route.fetch();
expect(response.status()).toBe(201);
created = (await response.json()) as ApiTokenCreated;
upstreamReady();
await release;
try {
await route.fulfill({ response });
responseDelivered = true;
} catch (error) {
deliveryFailure = error;
}
} finally {
routeSettled();
}
};
await page.route('**/api/v1/tokens', handleTokenRoute);
try {
await openAccess(page);
await page.getByRole('button', { name: 'Create token' }).click();
await page.getByLabel('Token name').fill(tokenName);
await page.getByRole('button', { name: 'Create', exact: true }).click();
await ready;
// An in-document hash change keeps the pending fetch alive in WebKit. The
// destination sentinel proves KeepAlive deactivated Config before the
// intercepted response is delivered. The modal makes the navigation
// links inert while the create request is pending, so drive the same hash
// router transition directly.
await page.evaluate(() => {
window.location.hash = '#/bangumi';
});
await expect(page).toHaveURL(/#\/bangumi$/);
await expect(
page.getByRole('button', { name: 'Add RSS Feed', exact: true })
).toBeVisible();
releaseResponse();
await settled;
if (deliveryFailure) throw deliveryFailure;
expect(responseDelivered).toBe(true);
const issued = requireCreatedToken(created);
await expectSecretAbsent(page, issued.token);
await openAccess(page);
const tokenRow = page.getByRole('group', { name: issued.name });
await expect(tokenRow).toContainText(issued.prefix);
await expect(page.getByLabel('One-time token')).toHaveCount(0);
await expectSecretAbsent(page, issued.token);
} finally {
releaseResponse();
const unroute = await Promise.allSettled([
page.unroute('**/api/v1/tokens', handleTokenRoute),
]);
if (handlerStarted) await settled;
const revoke = await Promise.allSettled([
api.listTokens().then(async (tokens) => {
const matching = tokens.filter(
(token) => token.name === tokenName && token.revoked_at === null
);
await Promise.all(matching.map((token) => api.revokeToken(token.id)));
}),
]);
const failure = [...unroute, ...revoke].find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
cleanupFailure = failure?.reason;
}
if (cleanupFailure) throw cleanupFailure;
});

View File

@@ -15,6 +15,10 @@
"lint:fix": "eslint . --fix",
"preview": "vite preview",
"test": "vitest",
"test:e2e": "playwright test",
"test:e2e:chromium": "playwright test --project=chromium-desktop",
"test:e2e:webkit": "playwright test --project=webkit-mobile",
"test:e2e:typecheck": "tsc --noEmit -p tsconfig.e2e.json",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"generate-pwa-assets": "pwa-assets-generator --preset minimal public/images/logo.svg"
@@ -35,7 +39,9 @@
"devDependencies": {
"@antfu/eslint-config": "^0.38.6",
"@icon-park/vue-next": "^1.4.2",
"@iconify-json/simple-icons": "1.2.89",
"@intlify/unplugin-vue-i18n": "^0.11.0",
"@playwright/test": "1.61.1",
"@storybook/addon-essentials": "^7.6.20",
"@storybook/addon-interactions": "^7.6.20",
"@storybook/addon-links": "^7.6.20",

View File

@@ -0,0 +1,97 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const profile = process.env.AB_E2E_PROFILE ?? 'browser';
const baseURL = process.env.AB_E2E_BASE_URL ?? 'http://127.0.0.1:0';
const projectDirectory = fileURLToPath(new URL('.', import.meta.url));
const authDirectory = resolve(projectDirectory, 'e2e/.auth');
function authFile(browser: string) {
return resolve(authDirectory, `${profile}-${browser}.json`);
}
const downloaderOnly = profile === 'downloader';
const commonUse = {
baseURL,
locale: 'en-US',
serviceWorkers: 'block' as const,
// Raw Playwright traces serialize storage state, request bodies, and
// response payloads. Authenticated E2E traces would therefore publish
// session cookies and one-time tokens with CI artifacts. The redacted
// NetworkGuard diagnostics below remain the supported failure record.
trace: 'off' as const,
screenshot: 'only-on-failure' as const,
video: 'retain-on-failure' as const,
};
export default defineConfig({
testDir: './e2e',
outputDir: 'test-results',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 60_000,
expect: { timeout: 10_000 },
forbidOnly: Boolean(process.env.CI),
reporter: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }],
],
use: commonUse,
projects: [
{
name: 'setup-chromium',
testMatch: /setup\/bootstrap-chromium\.setup\.ts/,
use: { ...devices['Desktop Chrome'], ...commonUse },
},
{
name: 'chromium-desktop',
testMatch: /specs\/.*\.spec\.ts/,
dependencies: ['setup-chromium'],
grep: downloaderOnly ? /@downloader/ : undefined,
grepInvert: downloaderOnly ? undefined : /@(?:downloader|mobile)/,
use: {
...devices['Desktop Chrome'],
...commonUse,
storageState: authFile('chromium'),
},
},
{
name: 'setup-webkit',
testMatch: /setup\/bootstrap-webkit\.setup\.ts/,
use: {
...devices['iPhone 13'],
...commonUse,
viewport: { width: 390, height: 844 },
},
},
{
name: 'webkit-mobile',
testMatch: /specs\/.*\.spec\.ts/,
dependencies: ['setup-webkit'],
grepInvert: /@downloader/,
use: {
...devices['iPhone 13'],
...commonUse,
viewport: { width: 390, height: 844 },
storageState: authFile('webkit'),
},
},
{
name: 'setup-firefox',
testMatch: /setup\/bootstrap-firefox\.setup\.ts/,
use: { ...devices['Desktop Firefox'], ...commonUse },
},
{
name: 'firefox-nightly',
testMatch: /specs\/.*\.spec\.ts/,
dependencies: ['setup-firefox'],
grepInvert: /@(?:downloader|mobile)/,
use: {
...devices['Desktop Firefox'],
...commonUse,
storageState: authFile('firefox'),
},
},
],
});

61
webui/pnpm-lock.yaml generated
View File

@@ -48,9 +48,15 @@ importers:
'@icon-park/vue-next':
specifier: ^1.4.2
version: 1.4.2(vue@3.5.8(typescript@4.9.5))
'@iconify-json/simple-icons':
specifier: 1.2.89
version: 1.2.89
'@intlify/unplugin-vue-i18n':
specifier: ^0.11.0
version: 0.11.0(rollup@2.79.1)(vue-i18n@9.14.5(vue@3.5.8(typescript@4.9.5)))(webpack-sources@3.2.3)
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
'@storybook/addon-essentials':
specifier: ^7.6.20
version: 7.6.20(@types/react@18.3.8)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(webpack-sources@3.2.3)
@@ -146,7 +152,7 @@ importers:
version: 0.16.7(vite@4.5.5(@types/node@18.19.50)(sass@1.62.1)(terser@5.33.0))(workbox-build@7.0.0(@types/babel__core@7.20.5))(workbox-window@7.0.0)
vitest:
specifier: ^0.30.1
version: 0.30.1(happy-dom@12.10.3)(sass@1.62.1)(terser@5.33.0)
version: 0.30.1(happy-dom@12.10.3)(playwright@1.61.1)(sass@1.62.1)(terser@5.33.0)
vue-tsc:
specifier: ^1.8.27
version: 1.8.27(typescript@4.9.5)
@@ -1039,6 +1045,9 @@ packages:
peerDependencies:
vue: 3.x
'@iconify-json/simple-icons@1.2.89':
resolution: {integrity: sha512-hRaCY5s2G5oWAIhc4LCGYn6g6RrwLL4zhoLOT+KUO3joVCxVlZKA+839bv/47Nbe9/ZD4UA6dznZ4XPYcI53wA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -1171,6 +1180,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.28':
resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
@@ -3473,6 +3487,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4662,6 +4681,16 @@ packages:
pkg-types@1.2.0:
resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -5857,8 +5886,8 @@ packages:
vue-component-type-helpers@2.1.6:
resolution: {integrity: sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==}
vue-component-type-helpers@3.3.6:
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
vue-component-type-helpers@3.3.7:
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -7220,6 +7249,10 @@ snapshots:
dependencies:
vue: 3.5.8(typescript@4.9.5)
'@iconify-json/simple-icons@1.2.89':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {}
'@iconify/utils@2.1.33':
@@ -7402,6 +7435,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@polka/url@1.0.0-next.28': {}
'@radix-ui/number@1.0.1':
@@ -8386,7 +8423,7 @@ snapshots:
ts-dedent: 2.2.0
type-fest: 2.19.0
vue: 3.5.8(typescript@4.9.5)
vue-component-type-helpers: 3.3.6
vue-component-type-helpers: 3.3.7
transitivePeerDependencies:
- encoding
- supports-color
@@ -10500,6 +10537,9 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -11674,6 +11714,14 @@ snapshots:
mlly: 1.7.1
pathe: 1.1.2
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
pluralize@8.0.0: {}
polished@4.3.1:
@@ -12915,7 +12963,7 @@ snapshots:
sass: 1.62.1
terser: 5.33.0
vitest@0.30.1(happy-dom@12.10.3)(sass@1.62.1)(terser@5.33.0):
vitest@0.30.1(happy-dom@12.10.3)(playwright@1.61.1)(sass@1.62.1)(terser@5.33.0):
dependencies:
'@types/chai': 4.3.19
'@types/chai-subset': 1.3.5
@@ -12945,6 +12993,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
happy-dom: 12.10.3
playwright: 1.61.1
transitivePeerDependencies:
- less
- lightningcss
@@ -12963,7 +13012,7 @@ snapshots:
vue-component-type-helpers@2.1.6: {}
vue-component-type-helpers@3.3.6: {}
vue-component-type-helpers@3.3.7: {}
vue-demi@0.14.10(vue@3.5.8(typescript@4.9.5)):
dependencies:

View File

@@ -31,12 +31,46 @@ async function mountSheet(props = {}) {
}
describe('ab-bottom-sheet', () => {
it('should expose its title as the dialog accessible name', async () => {
await mountSheet();
const dialog = document.querySelector('[role="dialog"]');
const labelledBy = dialog?.getAttribute('aria-labelledby');
expect(labelledBy).toBeTruthy();
expect(document.getElementById(labelledBy!)?.textContent).toContain(
'Edit rule'
);
});
it('should provide an accessible close action', async () => {
const wrapper = await mountSheet({ closeLabel: 'Dismiss sheet' });
const closeButton = document.querySelector<HTMLButtonElement>(
'button[aria-label="Dismiss sheet"]'
);
expect(closeButton).not.toBeNull();
expect(closeButton!.classList.contains('ab-icon-btn--md')).toBe(true);
closeButton!.click();
await wrapper.vm.$nextTick();
expect(wrapper.emitted('update:show')).toEqual([[false]]);
expect(wrapper.emitted('close')).toHaveLength(1);
});
it('should hide the close action when requested', async () => {
await mountSheet({ showClose: false });
expect(document.querySelector('button[aria-label="Close"]')).toBeNull();
});
it('should render the fullscreen panel class when requested', async () => {
await mountSheet({ fullscreen: true, avoidKeyboard: false });
const panel = document.querySelector('[data-testid="bottom-sheet-panel"]');
expect(panel).not.toBeNull();
expect(
document.querySelector('.ab-bottom-sheet__panel--fullscreen')
).not.toBeNull();
panel?.classList.contains('ab-bottom-sheet__panel--fullscreen')
).toBe(true);
});
it('should not translate the sheet for keyboard changes when avoidance is disabled', async () => {

View File

@@ -4,21 +4,28 @@ import { usePointerSwipe } from '@vueuse/core';
import {
Dialog,
DialogPanel,
DialogTitle,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue';
import { Close } from '@icon-park/vue-next';
import AbIconButton from './ab-icon-button.vue';
const props = withDefaults(
defineProps<{
show: boolean;
title?: string;
closeable?: boolean;
showClose?: boolean;
closeLabel?: string;
maxHeight?: string;
fullscreen?: boolean;
avoidKeyboard?: boolean;
}>(),
{
closeable: true,
showClose: true,
closeLabel: 'Close',
maxHeight: '85dvh',
fullscreen: false,
avoidKeyboard: true,
@@ -140,6 +147,7 @@ function close() {
<div class="ab-bottom-sheet__container">
<DialogPanel
ref="sheetRef"
data-testid="bottom-sheet-panel"
class="ab-bottom-sheet__panel"
:class="{ 'ab-bottom-sheet__panel--fullscreen': fullscreen }"
:style="[sheetStyle, { maxHeight: panelMaxHeight }]"
@@ -150,8 +158,22 @@ function close() {
</div>
<!-- Title -->
<div v-if="title" class="ab-bottom-sheet__header">
<h3 class="ab-bottom-sheet__title">{{ title }}</h3>
<div
v-if="title || (closeable && showClose)"
class="ab-bottom-sheet__header"
>
<DialogTitle v-if="title" as="h3" class="ab-bottom-sheet__title">
{{ title }}
</DialogTitle>
<AbIconButton
v-if="closeable && showClose"
class="ab-bottom-sheet__close"
size="md"
:label="closeLabel"
@click="close"
>
<Close theme="outline" size="16" />
</AbIconButton>
</div>
<!-- Content -->
@@ -248,6 +270,10 @@ function close() {
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 20px 12px;
border-bottom: 1px solid var(--color-border);
}
@@ -259,6 +285,10 @@ function close() {
margin: 0;
}
&__close {
margin-left: auto;
}
&__content {
flex: 1;
overflow-y: auto;

View File

@@ -58,6 +58,8 @@ function close() {
:show="show"
:title="title"
:closeable="closable"
:show-close="showClose"
:close-label="$t('common.close')"
:max-height="maxHeight"
:fullscreen="mobileFullscreen"
:avoid-keyboard="avoidKeyboard"

View File

@@ -256,6 +256,12 @@ describe('config access', () => {
);
expect(wrapper.text()).toContain('expired-token');
expect(wrapper.text()).toContain('revoked-token');
expect(wrapper.find('[role="group"][aria-label="alice"]').exists()).toBe(
true
);
expect(
wrapper.find('[role="group"][aria-label="active-token"]').exists()
).toBe(true);
wrapper.unmount();
});
@@ -312,6 +318,9 @@ describe('config access', () => {
await flushPromises();
expect(state.issuedToken).toBe('ab_api_one_time_secret');
expect(wrapper.text()).toContain('ab_api_one_time_secret');
expect(wrapper.find('[aria-label="access.one_time_token"]').text()).toBe(
'ab_api_one_time_secret'
);
await wrapper
.find('[data-title="access.token_created"] .stub-modal-close')

View File

@@ -189,7 +189,13 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
<div v-if="loading" class="empty-state">{{ $t('access.loading') }}</div>
<div v-else class="access-list">
<div v-for="user in users" :key="user.id" class="access-row">
<div
v-for="user in users"
:key="user.id"
class="access-row"
role="group"
:aria-label="user.username"
>
<div class="row-main">
<strong>{{ user.username }}</strong>
<span class="status-dot" :class="[{ disabled: !user.enabled }]">
@@ -230,7 +236,13 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
{{ $t('access.no_tokens') }}
</div>
<div v-else class="access-list">
<div v-for="token in tokens" :key="token.id" class="access-row">
<div
v-for="token in tokens"
:key="token.id"
class="access-row"
role="group"
:aria-label="token.name"
>
<div class="token-info">
<div class="row-main">
<strong>{{ token.name }}</strong>
@@ -342,7 +354,7 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
>
<div class="token-reveal">
<p>{{ $t('access.token_once') }}</p>
<code>{{ issuedToken }}</code>
<code :aria-label="$t('access.one_time_token')">{{ issuedToken }}</code>
</div>
<template #footer>
<ab-button size="sm" variant="primary" @click="copyIssuedToken">

View File

@@ -10,7 +10,19 @@ const { t } = useMyI18n();
<template>
<div class="wizard-container">
<div class="wizard-progress">
<div class="wizard-progress-bar">
<div
class="wizard-progress-bar"
role="progressbar"
:aria-label="
t('setup.nav.step', {
current: currentStep + 1,
total: totalSteps,
})
"
aria-valuemin="1"
:aria-valuemax="totalSteps"
:aria-valuenow="currentStep + 1"
>
<!-- 按“已到第几步”计算,避免第一步进度条完全空白 -->
<div
class="wizard-progress-fill"

View File

@@ -38,6 +38,8 @@ const passwordError = computed(() => {
<input
v-model="accountData.username"
type="text"
:aria-label="t('setup.account.username')"
autocomplete="username"
placeholder="admin"
class="setup-input"
/>
@@ -47,6 +49,8 @@ const passwordError = computed(() => {
<input
v-model="accountData.password"
type="password"
:aria-label="t('setup.account.password')"
autocomplete="new-password"
class="setup-input"
/>
</ab-field>
@@ -55,6 +59,8 @@ const passwordError = computed(() => {
<input
v-model="accountData.confirmPassword"
type="password"
:aria-label="t('setup.account.confirm_password')"
autocomplete="new-password"
class="setup-input"
/>
</ab-field>

View File

@@ -99,6 +99,7 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
<input
v-model="downloaderData.host"
type="text"
:aria-label="t('config.downloader_set.host')"
placeholder="172.17.0.1:8080"
class="setup-input"
/>
@@ -108,6 +109,8 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
<input
v-model="downloaderData.username"
type="text"
:aria-label="t('config.downloader_set.username')"
autocomplete="username"
placeholder="admin"
class="setup-input"
/>
@@ -117,6 +120,8 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
<input
v-model="downloaderData.password"
type="password"
:aria-label="t('config.downloader_set.password')"
autocomplete="current-password"
class="setup-input"
/>
</ab-field>
@@ -125,13 +130,17 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
<input
v-model="downloaderData.path"
type="text"
:aria-label="t('config.downloader_set.path')"
placeholder="/downloads/Bangumi"
class="setup-input"
/>
</ab-field>
<ab-field :label="t('config.downloader_set.ssl')">
<NSwitch v-model:value="downloaderData.ssl" />
<NSwitch
v-model:value="downloaderData.ssl"
:aria-label="t('config.downloader_set.ssl')"
/>
</ab-field>
</div>

View File

@@ -66,6 +66,7 @@ const canTest = computed(() => {
<NSelect
v-model:value="notificationData.type"
:options="notificationTypes"
:aria-label="t('config.notification_set.type')"
size="small"
/>
</ab-field>
@@ -74,6 +75,8 @@ const canTest = computed(() => {
<input
v-model="notificationData.token"
type="text"
:aria-label="t('config.notification_set.token')"
autocomplete="off"
class="setup-input setup-input-wide"
/>
</ab-field>
@@ -82,6 +85,7 @@ const canTest = computed(() => {
<input
v-model="notificationData.chat_id"
type="text"
:aria-label="t('config.notification_set.chat_id')"
class="setup-input"
/>
</ab-field>

View File

@@ -59,13 +59,19 @@ function handleNext() {
<input
v-model="rssData.url"
type="text"
:aria-label="t('setup.rss.url')"
placeholder="https://mikanani.me/RSS/..."
class="setup-input setup-input-wide"
/>
</ab-field>
<ab-field v-if="rssData.name" :label="t('setup.rss.feed_name')">
<input v-model="rssData.name" type="text" class="setup-input" />
<input
v-model="rssData.name"
type="text"
:aria-label="t('setup.rss.feed_name')"
class="setup-input"
/>
</ab-field>
</div>

10
webui/tsconfig.e2e.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": true,
"types": ["node", "@playwright/test"]
},
"include": ["playwright.config.ts", "e2e/**/*.ts"],
"references": []
}

View File

@@ -15,7 +15,9 @@ export default defineConfig({
baseFontSize: 4,
}),
presetAttributify(),
presetIcons({ cdn: 'https://esm.sh/' }),
// Icon collections are installed locally so production builds remain
// reproducible when the E2E runtime has no public network access.
presetIcons(),
],
preflights: [
{