This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
dist
|
||||
node_modules
|
||||
coverage
|
||||
tmp
|
||||
.codex-tmp
|
||||
.codex-logs
|
||||
screenshots
|
||||
*.log
|
||||
*.tmp
|
||||
package-lock.json
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1367
-102
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -522,7 +522,6 @@ function WorkbenchPage({
|
||||
[conversations],
|
||||
);
|
||||
const hasSidebarRecords = conversationRecords.length > 0;
|
||||
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
|
||||
|
||||
const activeConversationTitle = useMemo(() => {
|
||||
if (!activeConversationId) return "";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../testHarness";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user