197 lines
10 KiB
JavaScript
197 lines
10 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
|
|
function read(relativePath) {
|
|
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
|
}
|
|
|
|
async function runTest(name, fn) {
|
|
try {
|
|
await fn();
|
|
console.log(`PASS ${name}`);
|
|
} catch (error) {
|
|
console.error(`FAIL ${name}`);
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
await runTest('generation job runner can dispatch reverse-prompt payloads to the reverse prompt route', () => {
|
|
const source = read('src/lib/generation-job-runner.ts');
|
|
assert.match(source, /type GenerationJobType = 'image' \| 'video' \| 'reverse-prompt';/);
|
|
assert.match(source, /const endpoint = type === 'image' \? '\/api\/generate\/image' : type === 'video' \? '\/api\/generate\/video' : '\/api\/generate\/reverse-prompt';/);
|
|
});
|
|
|
|
await runTest('generation job runner uses long-lived internal HTTP requests for slow video jobs', () => {
|
|
const source = read('src/lib/generation-job-runner.ts');
|
|
assert.match(source, /requestInternalGenerationJson/);
|
|
assert.match(source, /GENERATION_INTERNAL_REQUEST_TIMEOUT_MS/);
|
|
assert.match(source, /25 \* 60_000/);
|
|
assert.match(source, /req\.setTimeout\(timeoutMs/);
|
|
assert.doesNotMatch(source, /await fetch\(`\$\{baseUrl\}\$\{endpoint\}`/);
|
|
});
|
|
|
|
await runTest('generation jobs route can list active jobs and accept reverse-prompt submissions', () => {
|
|
const source = read('src/app/api/generation-jobs/route.ts');
|
|
assert.match(source, /export async function GET\(request: NextRequest\)/);
|
|
assert.match(source, /status IN \('queued', 'running'\)/);
|
|
assert.match(source, /type !== 'image' && type !== 'video' && type !== 'reverse-prompt'/);
|
|
});
|
|
|
|
await runTest('creation history post accepts trusted internal generation requests', () => {
|
|
const source = read('src/app/api/creation-history/route.ts');
|
|
assert.match(source, /isTrustedInternalGenerationRequest/);
|
|
assert.match(source, /x-miaojing-generation-user-id/);
|
|
assert.match(source, /if \(!userId\) return NextResponse\.json\(\{ error: '请先登录' \}, \{ status: 401 \}\);/);
|
|
});
|
|
|
|
await runTest('generation worker persists completed jobs back into creation history', () => {
|
|
const source = read('src/lib/generation-job-worker.ts');
|
|
assert.match(source, /\/api\/creation-history/);
|
|
assert.match(source, /persistGenerationHistoryRecord|saveGenerationHistoryRecord|creation history/i);
|
|
assert.match(source, /status: 'succeeded'/);
|
|
});
|
|
|
|
await runTest('image generation caps persisted images to the requested count', () => {
|
|
const source = read('src/app/api/generate/image/route.ts');
|
|
assert.match(source, /function capPersistedImagesToRequestedCount/);
|
|
assert.match(source, /imageResponsePayload\([^,\n]+,\s*n\)/);
|
|
assert.match(source, /persistQualifiedImageUrls\([^)]*requestedCount/s);
|
|
});
|
|
|
|
await runTest('creation history serializes same-user same-url inserts to prevent duplicate rows', () => {
|
|
const source = read('src/app/api/creation-history/route.ts');
|
|
assert.match(source, /pg_advisory_xact_lock/);
|
|
assert.match(source, /historyRecordDedupeLockKey/);
|
|
assert.match(source, /WHERE user_id = \$1 AND result_url = \$2/);
|
|
});
|
|
|
|
await runTest('create panels restore active jobs from the server after reload or auth change', () => {
|
|
for (const relativePath of [
|
|
'src/components/create/text-to-image.tsx',
|
|
'src/components/create/image-to-image.tsx',
|
|
'src/components/create/text-to-video.tsx',
|
|
'src/components/create/image-to-video.tsx',
|
|
'src/components/create/reverse-prompt-panel.tsx',
|
|
]) {
|
|
const source = read(relativePath);
|
|
assert.match(source, /useGenerationJobRecovery|fetchActiveGenerationJobs|\/api\/generation-jobs\?status=queued%2Crunning|\/api\/generation-jobs\?status=queued,running/);
|
|
}
|
|
});
|
|
|
|
await runTest('recovered job polling is not cancelled by active task state updates', () => {
|
|
const source = read('src/components/create/use-generation-job-recovery.ts');
|
|
assert.match(source, /knownJobIdsRef/);
|
|
const effectMatches = [...source.matchAll(/useEffect\(\(\) => \{[\s\S]*?void recover\(\);[\s\S]*?\}, \[([^\]]*)\]\);/g)];
|
|
assert.ok(effectMatches.length > 0, 'expected to find the recovery polling effect');
|
|
const dependencies = effectMatches.at(-1)?.[1] || '';
|
|
assert.doesNotMatch(dependencies, /\btypes\b/);
|
|
assert.doesNotMatch(dependencies, /\bnormalizedKnownJobIds\b/);
|
|
});
|
|
|
|
await runTest('active generation job recovery avoids anonymous polling and dedupes short-lived list requests', () => {
|
|
const source = read('src/lib/generation-job-client.ts');
|
|
assert.match(source, /const ACTIVE_JOBS_REQUEST_TTL_MS = \d+;/);
|
|
assert.match(source, /activeJobsRequestCache/);
|
|
assert.match(source, /if \(!authToken\) return \[\];/);
|
|
assert.match(source, /getActiveJobsRequestKey\(normalizedTypes, authToken\)/);
|
|
});
|
|
|
|
await runTest('generation jobs stay recoverable after the browser closes before the result is consumed', () => {
|
|
const clientSource = read('src/lib/generation-job-client.ts');
|
|
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
|
|
|
assert.match(clientSource, /PENDING_GENERATION_JOBS_STORAGE_PREFIX/);
|
|
assert.match(clientSource, /rememberPendingGenerationJob/);
|
|
assert.match(clientSource, /forgetPendingGenerationJob/);
|
|
assert.match(clientSource, /fetchGenerationJobStatus/);
|
|
assert.match(clientSource, /fetchRecoverableGenerationJobs/);
|
|
assert.match(clientSource, /rememberPendingGenerationJob\(type,\s*createData\.jobId/);
|
|
assert.match(recoverySource, /fetchRecoverableGenerationJobs/);
|
|
assert.doesNotMatch(recoverySource, /const jobs = await fetchActiveGenerationJobs\(typesRef\.current\);/);
|
|
});
|
|
|
|
await runTest('terminal recovered generation jobs clear pending browser recovery state', () => {
|
|
const clientSource = read('src/lib/generation-job-client.ts');
|
|
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
|
|
|
assert.match(clientSource, /statusData\.status === 'succeeded'[\s\S]*forgetPendingGenerationJob/);
|
|
assert.match(clientSource, /statusData\.status === 'failed'[\s\S]*forgetPendingGenerationJob/);
|
|
assert.match(clientSource, /statusData\.status === 'cancelled'[\s\S]*forgetPendingGenerationJob/);
|
|
assert.match(clientSource, /cancelGenerationJob[\s\S]*forgetPendingGenerationJob/);
|
|
assert.match(recoverySource, /forgetPendingGenerationJob/);
|
|
});
|
|
|
|
await runTest('active job recovery dedupes locally submitted tasks by client request id', () => {
|
|
const taskListSource = read('src/components/create/generation-task-list.tsx');
|
|
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
|
const textToImageSource = read('src/components/create/text-to-image.tsx');
|
|
|
|
assert.match(taskListSource, /clientRequestId\?: string;/);
|
|
assert.match(recoverySource, /payload\?\.clientRequestId/);
|
|
assert.match(recoverySource, /getJobIdentityIds/);
|
|
assert.match(recoverySource, /identityIds\.some\(id => activeJobIdsRef\.current\.has\(id\) \|\| knownJobIdsRef\.current\.has\(id\)\)/);
|
|
assert.match(textToImageSource, /clientRequestId: taskId/);
|
|
assert.match(textToImageSource, /task\.clientRequestId/);
|
|
});
|
|
|
|
await runTest('generation job API dedupes active jobs by semantic payload without client request id', () => {
|
|
const source = read('src/app/api/generation-jobs/route.ts');
|
|
assert.match(source, /payload - 'clientRequestId'/);
|
|
assert.match(source, /\$3::jsonb - 'clientRequestId'/);
|
|
assert.match(source, /deduplicated: true/);
|
|
});
|
|
|
|
await runTest('create panels do not prepend duplicate completed media urls', () => {
|
|
for (const relativePath of [
|
|
'src/components/create/text-to-image.tsx',
|
|
'src/components/create/image-to-image.tsx',
|
|
'src/components/create/text-to-video.tsx',
|
|
'src/components/create/image-to-video.tsx',
|
|
]) {
|
|
const source = read(relativePath);
|
|
assert.match(source, /filter\(url => !prev\.includes\(url\)\)/, `${relativePath} should filter duplicate result URLs before prepending`);
|
|
}
|
|
});
|
|
|
|
await runTest('create panels block duplicate in-flight submissions before creating another job', () => {
|
|
for (const relativePath of [
|
|
'src/components/create/text-to-image.tsx',
|
|
'src/components/create/image-to-image.tsx',
|
|
'src/components/create/text-to-video.tsx',
|
|
'src/components/create/image-to-video.tsx',
|
|
]) {
|
|
const source = read(relativePath);
|
|
assert.match(source, /activeSubmissionSignaturesRef = useRef\(new Set<string>\(\)\)/, `${relativePath} should keep in-flight submission signatures`);
|
|
assert.match(source, /activeSubmissionSignaturesRef\.current\.has\(submissionSignature\)/, `${relativePath} should check an in-flight duplicate signature`);
|
|
assert.match(source, /activeSubmissionSignaturesRef\.current\.add\(submissionSignature\)/, `${relativePath} should mark the signature before creating the job`);
|
|
assert.match(source, /activeSubmissionSignaturesRef\.current\.delete\(submissionSignature\)/, `${relativePath} should clear the signature after the job settles`);
|
|
assert.match(source, /相同任务正在生成中,请勿重复提交/, `${relativePath} should explain duplicate submit prevention`);
|
|
}
|
|
});
|
|
|
|
await runTest('create panels do not label active generation as another submit action', () => {
|
|
for (const relativePath of [
|
|
'src/components/create/text-to-image.tsx',
|
|
'src/components/create/image-to-image.tsx',
|
|
'src/components/create/text-to-video.tsx',
|
|
'src/components/create/image-to-video.tsx',
|
|
'src/components/create/mobile-creation-composer.tsx',
|
|
]) {
|
|
const source = read(relativePath);
|
|
assert.doesNotMatch(source, /继续提交任务/, `${relativePath} should not invite duplicate submits while a task is running`);
|
|
}
|
|
});
|
|
|
|
await runTest('generation job client builds auth headers from one parsed token per request', () => {
|
|
const source = read('src/lib/generation-job-client.ts');
|
|
assert.match(source, /function getAuthHeaders\(authToken = getAuthToken\(\)\)/);
|
|
assert.match(source, /const authHeaders = getAuthHeaders\(\);/);
|
|
assert.doesNotMatch(source, /\.\.\.\(getAuthToken\(\) \? \{ Authorization: `Bearer \$\{getAuthToken\(\)\}` \} : \{\}\)/);
|
|
});
|
|
|
|
if (process.exitCode) process.exit(process.exitCode);
|