mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-04 14:45:26 +08:00
test: add browser auth e2e coverage
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/build
|
||||
/dist
|
||||
/dev-dist
|
||||
/e2e/.auth
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
@@ -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
156
webui/e2e/fixtures/api.ts
Normal 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
154
webui/e2e/fixtures/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
41
webui/e2e/fixtures/data.ts
Normal file
41
webui/e2e/fixtures/data.ts
Normal 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
73
webui/e2e/fixtures/env.ts
Normal 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`);
|
||||
}
|
||||
156
webui/e2e/fixtures/network.ts
Normal file
156
webui/e2e/fixtures/network.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
webui/e2e/setup/bootstrap-chromium.setup.ts
Normal file
11
webui/e2e/setup/bootstrap-chromium.setup.ts
Normal 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')
|
||||
);
|
||||
});
|
||||
11
webui/e2e/setup/bootstrap-firefox.setup.ts
Normal file
11
webui/e2e/setup/bootstrap-firefox.setup.ts
Normal 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')
|
||||
);
|
||||
});
|
||||
7
webui/e2e/setup/bootstrap-webkit.setup.ts
Normal file
7
webui/e2e/setup/bootstrap-webkit.setup.ts
Normal 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'));
|
||||
});
|
||||
75
webui/e2e/specs/bootstrap-auth.spec.ts
Normal file
75
webui/e2e/specs/bootstrap-auth.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
24
webui/e2e/specs/production-smoke.spec.ts
Normal file
24
webui/e2e/specs/production-smoke.spec.ts
Normal 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);
|
||||
});
|
||||
137
webui/e2e/specs/session-access.spec.ts
Normal file
137
webui/e2e/specs/session-access.spec.ts
Normal 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;
|
||||
});
|
||||
190
webui/e2e/specs/token-secret.spec.ts
Normal file
190
webui/e2e/specs/token-secret.spec.ts
Normal 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;
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
97
webui/playwright.config.ts
Normal file
97
webui/playwright.config.ts
Normal 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
61
webui/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
10
webui/tsconfig.e2e.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"noEmit": true,
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["playwright.config.ts", "e2e/**/*.ts"],
|
||||
"references": []
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user