test: add web quality gates
Web Quality / verify (push) Has been cancelled

This commit is contained in:
2026-06-09 11:34:56 +08:00
parent af5081d382
commit f322679d4a
16 changed files with 1893 additions and 113 deletions
+30
View File
@@ -0,0 +1,30 @@
name: Web Quality
on:
pull_request:
push:
branches:
- main
- master
- "codex/**"
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- name: Install dependencies
run: npm ci
- name: Verify
run: npm run verify
+10
View File
@@ -0,0 +1,10 @@
dist
node_modules
coverage
tmp
.codex-tmp
.codex-logs
screenshots
*.log
*.tmp
package-lock.json
+77
View File
@@ -0,0 +1,77 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"dist/**",
"node_modules/**",
"coverage/**",
"tmp/**",
".codex-tmp/**",
".codex-logs/**",
"screenshots/**",
"*.log",
"*.tmp",
"vite-*.log",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: [
"src/**/*.{ts,tsx}",
"scripts/**/*.mjs",
"vite.config.ts",
"eslint.config.js",
],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
AbortController: "readonly",
AbortSignal: "readonly",
Blob: "readonly",
clearInterval: "readonly",
clearTimeout: "readonly",
console: "readonly",
crypto: "readonly",
document: "readonly",
File: "readonly",
fetch: "readonly",
FormData: "readonly",
Headers: "readonly",
HTMLTextAreaElement: "readonly",
localStorage: "readonly",
navigator: "readonly",
process: "readonly",
React: "readonly",
RequestInit: "readonly",
Response: "readonly",
setInterval: "readonly",
setTimeout: "readonly",
URL: "readonly",
URLSearchParams: "readonly",
window: "readonly",
},
},
plugins: {
"react-hooks": reactHooks,
},
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-unused-expressions": "warn",
"no-empty": ["error", { allowEmptyCatch: true }],
"no-undef": "off",
"no-useless-escape": "warn",
"prefer-const": "warn",
},
},
);
+1367 -102
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -7,7 +7,11 @@
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1",
"test": "node scripts/run-unit-tests.mjs",
"type-check": "tsc -p tsconfig.json --noEmit",
"lint": "eslint .",
"format:check": "prettier --check .github/workflows/web-quality.yml eslint.config.js scripts/run-unit-tests.mjs src/test src/api/generationConcurrency.test.ts src/utils/enterpriseVideoPolicy.test.ts src/utils/taskLifecycle.test.ts",
"verify": "npm run test && npm run type-check && npm run lint && npm run format:check && npm run governance:check && npm run style:check && npm run build",
"governance:check": "node scripts/check-governance.mjs",
"style:check": "node scripts/check-style-governance.mjs",
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
@@ -20,13 +24,18 @@
"zustand": "5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@vitejs/plugin-react": "4.2.1",
"eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1",
"playwright": "1.60.0",
"prettier": "^3.8.3",
"sharp": "0.34.5",
"typescript": "5.3.3",
"vite": "5.1.0",
"typescript-eslint": "^8.60.1",
"vite": "5.4.21",
"vite-plugin-compression2": "2.5.3"
}
}
+55
View File
@@ -0,0 +1,55 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { createServer } from "vite";
const repoRoot = process.cwd();
function normalizePath(value) {
return value.replace(/\\/g, "/");
}
function findTestFiles(dir) {
const result = [];
if (!fs.existsSync(dir)) return result;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push(...findTestFiles(fullPath));
continue;
}
if (/\.test\.tsx?$/.test(entry.name)) result.push(fullPath);
}
return result.sort();
}
const server = await createServer({
configFile: path.join(repoRoot, "vite.config.ts"),
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const harness = await server.ssrLoadModule("/src/test/testHarness");
const testFiles = findTestFiles(path.join(repoRoot, "src"));
if (testFiles.length === 0) {
console.error("No test files found.");
process.exitCode = 1;
} else {
console.log(`Running ${testFiles.length} test files`);
for (const file of testFiles) {
const modulePath = `/${normalizePath(path.relative(repoRoot, file))}`;
await server.ssrLoadModule(modulePath);
}
const result = await harness.runRegisteredTests();
console.log(`Unit test result: ${result.passed}/${result.total} passed`);
if (result.failed > 0) process.exitCode = 1;
}
} finally {
await server.close();
}
+15 -6
View File
@@ -3,6 +3,19 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
function combineAbortSignals(signal: AbortSignal | undefined, timeoutSignal: AbortSignal): AbortSignal {
if (!signal) return timeoutSignal;
const controller = new AbortController();
const abort = () => controller.abort();
if (signal.aborted || timeoutSignal.aborted) {
abort();
return controller.signal;
}
signal.addEventListener("abort", abort, { once: true });
timeoutSignal.addEventListener("abort", abort, { once: true });
return controller.signal;
}
export interface AdVideoUserConfig {
platform: string;
aspectRatio: string;
@@ -162,9 +175,7 @@ async function chat(
{ role: "user", content: userContent },
];
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = options?.signal
? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal;
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
@@ -210,9 +221,7 @@ async function visionChat(
let lastError: Error | null = null;
for (const model of VISION_MODELS) {
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
try {
const out = await retryOnTransient(async () => {
const res = await fetch(buildApiUrl("ai/chat"), {
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it } from "../test/testHarness";
import {
__resetGenerationConcurrencyForTests,
claimGenerationSlot,
getActiveGenerationTaskCount,
getEffectiveGenerationLimit,
getGenerationUserKey,
releaseGenerationSlot,
setUserMaxConcurrency,
} from "./generationConcurrency";
describe("generationConcurrency", () => {
afterEach(() => {
__resetGenerationConcurrencyForTests();
});
it("uses the default generation limit until the server provides a user-specific limit", () => {
expect(getEffectiveGenerationLimit()).toBe(3);
setUserMaxConcurrency(5);
expect(getEffectiveGenerationLimit()).toBe(5);
setUserMaxConcurrency(0);
expect(getEffectiveGenerationLimit()).toBe(3);
});
it("claims and releases local generation slots so the submit button can recover", () => {
const userKey = getGenerationUserKey("user-1");
const releaseFirst = claimGenerationSlot({
userKey,
kind: "image",
id: "slot-1",
});
claimGenerationSlot({ userKey, kind: "video", id: "slot-2" });
expect(getActiveGenerationTaskCount(userKey)).toBe(2);
releaseFirst();
expect(getActiveGenerationTaskCount(userKey)).toBe(1);
releaseGenerationSlot("slot-2");
expect(getActiveGenerationTaskCount(userKey)).toBe(0);
});
it("enforces per-user limits without blocking other users", () => {
setUserMaxConcurrency(1);
claimGenerationSlot({ userKey: "alice", kind: "image", id: "alice-slot" });
expect(() =>
claimGenerationSlot({
userKey: "alice",
kind: "video",
id: "alice-slot-2",
}),
).toThrow("最多生成 1 个图片/视频任务");
expect(() =>
claimGenerationSlot({ userKey: "bob", kind: "video", id: "bob-slot" }),
).not.toThrow();
});
});
+5
View File
@@ -75,3 +75,8 @@ export function releaseGenerationSlot(id: string | undefined | null): void {
if (!id) return;
activeSlots.delete(id);
}
export function __resetGenerationConcurrencyForTests(): void {
activeSlots.clear();
userMaxConcurrency = null;
}
+2 -2
View File
@@ -10,7 +10,7 @@ import {
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactElement } from "react";
import "../../styles/pages/assets.css";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -36,7 +36,7 @@ interface AssetsPageProps {
onOpenLogin: () => void;
}
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: ReactElement | null }> = [
{ key: "all", label: "全部", icon: null },
{ key: "character", label: "人物", icon: <UserOutlined /> },
{ key: "scene", label: "场景", icon: <FileImageOutlined /> },
+1 -1
View File
@@ -715,7 +715,7 @@ async function createUploadedImageItems(files: File[], limit: number, prefix: st
const items = await Promise.all(selectedFiles.map(async (file, index) => {
const localPreviewUrl = URL.createObjectURL(file);
let dimensions: { width?: number; height?: number } = {};
let dimensions: { width?: number; height?: number };
try {
dimensions = await readImageDimensions(localPreviewUrl);
} catch {
-1
View File
@@ -522,7 +522,6 @@ function WorkbenchPage({
[conversations],
);
const hasSidebarRecords = conversationRecords.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const activeConversationTitle = useMemo(() => {
if (!activeConversationId) return "";
+150
View File
@@ -0,0 +1,150 @@
type TestFn = () => unknown | Promise<unknown>;
interface TestCase {
name: string;
run: TestFn;
afterEachFns: TestFn[];
}
const tests: TestCase[] = [];
const suiteStack: string[] = [];
const afterEachStack: TestFn[][] = [[]];
function formatValue(value: unknown): string {
return typeof value === "string" ? `"${value}"` : JSON.stringify(value);
}
function fail(message: string): never {
throw new Error(message);
}
function assertThrows(
actual: unknown,
expectedMessage?: string,
inverted = false,
): void {
if (typeof actual !== "function") {
fail("Expected value to be a function");
}
let thrown: unknown;
try {
actual();
} catch (error) {
thrown = error;
}
if (inverted) {
if (thrown)
fail(
`Expected function not to throw, but it threw ${thrown instanceof Error ? thrown.message : String(thrown)}`,
);
return;
}
if (!thrown) fail("Expected function to throw");
if (expectedMessage) {
const message = thrown instanceof Error ? thrown.message : String(thrown);
if (!message.includes(expectedMessage)) {
fail(
`Expected thrown message to include "${expectedMessage}", got "${message}"`,
);
}
}
}
export function describe(name: string, run: () => void): void {
suiteStack.push(name);
afterEachStack.push([]);
try {
run();
} finally {
afterEachStack.pop();
suiteStack.pop();
}
}
export function it(name: string, run: TestFn): void {
tests.push({
name: [...suiteStack, name].join(" > "),
run,
afterEachFns: afterEachStack.flat(),
});
}
export function afterEach(run: TestFn): void {
afterEachStack[afterEachStack.length - 1].push(run);
}
export function expect(actual: unknown) {
return {
toBe(expected: unknown): void {
if (!Object.is(actual, expected)) {
fail(`Expected ${formatValue(actual)} to be ${formatValue(expected)}`);
}
},
toEqual(expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
fail(`Expected ${actualJson} to equal ${expectedJson}`);
}
},
toThrow(expectedMessage?: string): void {
assertThrows(actual, expectedMessage);
},
not: {
toThrow(): void {
assertThrows(actual, undefined, true);
},
},
};
}
export async function runRegisteredTests(): Promise<{
passed: number;
failed: number;
total: number;
}> {
let passed = 0;
let failed = 0;
for (const test of tests) {
let failure: unknown;
try {
await test.run();
} catch (error) {
failure = error;
}
for (const hook of [...test.afterEachFns].reverse()) {
try {
await hook();
} catch (error) {
failure = failure || error;
}
}
if (failure) {
failed += 1;
console.error(`FAIL ${test.name}`);
console.error(
failure instanceof Error
? ` ${failure.message}`
: ` ${String(failure)}`,
);
} else {
passed += 1;
console.log(`PASS ${test.name}`);
}
}
const total = passed + failed;
if (total === 0) {
console.error("No unit tests were registered.");
return { passed, failed: 1, total };
}
console.log(`\n${passed} passed, ${failed} failed`);
return { passed, failed, total };
}
+1
View File
@@ -0,0 +1 @@
export * from "../testHarness";
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "../test/testHarness";
import {
calculateEnterpriseVideoCredits,
getEnterpriseVideoCreditRate,
normalizeEnterpriseResolution,
} from "./enterpriseVideoPolicy";
describe("enterpriseVideoPolicy", () => {
it("keeps video billing at 1 CNY to 100 credits", () => {
expect(
calculateEnterpriseVideoCredits({
model: "happyhorse-1.0",
resolution: "1080P",
durationSeconds: 5,
}),
).toBe(640);
expect(
calculateEnterpriseVideoCredits({
model: "wan2.7-i2v",
resolution: "720P",
durationSeconds: 5,
}),
).toBe(300);
});
it("rounds duration up to the next second before billing", () => {
expect(
calculateEnterpriseVideoCredits({
model: "vidu-q3-turbo",
resolution: "1080P",
durationSeconds: 5.2,
}),
).toBe(600);
});
it("normalizes unsupported resolutions to 1080P", () => {
expect(normalizeEnterpriseResolution("4K")).toBe("1080P");
expect(
getEnterpriseVideoCreditRate({
model: "pixverse-c1",
resolution: "4K",
durationSeconds: 5,
}),
).toBe(1);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "../test/testHarness";
import {
TEXT_INPUT_CREDITS_PER_MILLION,
TEXT_OUTPUT_CREDITS_PER_MILLION,
estimateTextTokenCredits,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "./taskLifecycle";
describe("taskLifecycle", () => {
it("keeps text token billing at 1 CNY to 100 credits", () => {
expect(TEXT_INPUT_CREDITS_PER_MILLION).toBe(200);
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
expect(
estimateTextTokenCredits({
promptTokens: 1_000_000,
completionTokens: 1_000_000,
}),
).toBe(700);
});
it("ignores negative token counts when estimating text billing", () => {
expect(
estimateTextTokenCredits({
promptTokens: -100,
completionTokens: 500_000,
}),
).toBe(250);
});
it("marks unstarted tasks locally timed out after submit timeout", () => {
const policy = getTaskTimeoutPolicy({ kind: "image" });
expect(
isTaskLocallyTimedOut({
startedAt: 1_000,
lastProgressAt: 1_000,
now: 1_000 + policy.submitTimeoutMs,
policy,
progress: 0,
}),
).toBe("no_progress");
});
it("marks running tasks locally timed out when progress stops", () => {
const policy = getTaskTimeoutPolicy({ kind: "video", model: "wan2.7-i2v" });
expect(
isTaskLocallyTimedOut({
startedAt: 1_000,
lastProgressAt: 2_000,
now: 2_000 + policy.noProgressTimeoutMs,
policy,
progress: 40,
}),
).toBe("no_progress");
});
});