Files
miaojingAI/scripts/test-generation-job-persistence.mjs

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);