test(scanner): refactor E2E scaffolding and extract shared test harness

This commit is contained in:
neon
2026-03-30 02:11:51 -06:00
parent db30a45782
commit b5cc1ef370
19 changed files with 892 additions and 1489 deletions
+89
View File
@@ -0,0 +1,89 @@
name: E2E Runner (reusable)
on:
workflow_call:
inputs:
suite-script:
description: "pnpm root script to run (e.g. test:e2e:scanner)"
required: true
type: string
check-name:
description: "Name shown in the GitHub checks UI"
required: true
type: string
artifact-name:
description: "Name for the uploaded artifact"
required: true
type: string
junit-glob:
description: "Glob pattern for the JUnit XML report"
required: true
type: string
job-timeout-minutes:
description: "Job timeout in minutes"
required: false
type: number
default: 45
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24"
PNPM_VERSION: "10"
E2E_DATABASE_URL: postgres://projectx:projectx@localhost:5432/projectx_e2e
NODE_ENV: test
JWT_SECRET: scanner-e2e-secret-1234
JWT_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
BOOKS_PATH: ../local/data
APP_URL: http://localhost:5173
jobs:
run-suite:
name: ${{ inputs.check-name }}
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.job-timeout-minutes }}
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: projectx
POSTGRES_PASSWORD: projectx
POSTGRES_DB: projectx
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U projectx"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
- name: Run e2e suite
shell: bash
run: pnpm run ${{ inputs.suite-script }}
- name: Publish test report annotations
if: always()
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
with:
check_name: ${{ inputs.check-name }}
files: ${{ inputs.junit-glob }}
fail_on: nothing
comment_mode: off
- name: Upload e2e artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ inputs.artifact-name }}
path: test-results/server
retention-days: 7
+22 -58
View File
@@ -5,6 +5,22 @@ on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *"
push:
paths:
- "server/src/modules/scanner/**"
- "server/test/scanner*"
- "server/test/e2e/**"
- "server/src/db/schema/**"
- "scripts/test/**"
- "scripts/db/**"
pull_request:
paths:
- "server/src/modules/scanner/**"
- "server/test/scanner*"
- "server/test/e2e/**"
- "server/src/db/schema/**"
- "scripts/test/**"
- "scripts/db/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -14,64 +30,12 @@ permissions:
contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24"
PNPM_VERSION: "10"
E2E_DATABASE_URL: postgres://projectx:projectx@localhost:5432/projectx_e2e
NODE_ENV: test
JWT_SECRET: scanner-e2e-secret-1234
JWT_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
BOOKS_PATH: ../local/data
APP_URL: http://localhost:5173
jobs:
scanner-file-ops-e2e:
name: Scanner File Ops E2E
runs-on: ubuntu-latest
timeout-minutes: 60
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: projectx
POSTGRES_PASSWORD: projectx
POSTGRES_DB: projectx
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U projectx"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
uses: ./.github/workflows/e2e-runner.yml
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
- name: Run scanner file ops e2e suite
shell: bash
run: pnpm run test:e2e:scanner:file-ops
- name: Publish test report annotations
if: always()
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
with:
check_name: Server Scanner File Ops E2E
files: test-results/server/scanner-file-ops-e2e-junit.xml
fail_on: nothing
comment_mode: off
- name: Upload scanner file ops e2e artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: scanner-file-ops-e2e-report
path: test-results/server
retention-days: 7
suite-script: test:e2e:scanner:file-ops
check-name: Server Scanner File Ops E2E
artifact-name: scanner-file-ops-e2e-report
junit-glob: test-results/server/scanner-file-ops-e2e-junit.xml
job-timeout-minutes: 60
+22 -58
View File
@@ -3,6 +3,22 @@ run-name: ${{ format('Scanner E2E - {0}', github.ref_name) }}
on:
workflow_dispatch:
push:
paths:
- "server/src/modules/scanner/**"
- "server/test/scanner*"
- "server/test/e2e/**"
- "server/src/db/schema/**"
- "scripts/test/**"
- "scripts/db/**"
pull_request:
paths:
- "server/src/modules/scanner/**"
- "server/test/scanner*"
- "server/test/e2e/**"
- "server/src/db/schema/**"
- "scripts/test/**"
- "scripts/db/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,64 +28,12 @@ permissions:
contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24"
PNPM_VERSION: "10"
E2E_DATABASE_URL: postgres://projectx:projectx@localhost:5432/projectx_e2e
NODE_ENV: test
JWT_SECRET: scanner-e2e-secret-1234
JWT_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
BOOKS_PATH: ../local/data
APP_URL: http://localhost:5173
jobs:
scanner-e2e:
name: Scanner E2E
runs-on: ubuntu-latest
timeout-minutes: 45
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: projectx
POSTGRES_PASSWORD: projectx
POSTGRES_DB: projectx
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U projectx"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
uses: ./.github/workflows/e2e-runner.yml
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
- name: Run scanner e2e suite
shell: bash
run: pnpm run test:e2e:scanner
- name: Publish test report annotations
if: always()
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
with:
check_name: Server Scanner E2E
files: test-results/server/scanner-e2e-junit.xml
fail_on: nothing
comment_mode: off
- name: Upload scanner e2e artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: scanner-e2e-report
path: test-results/server
retention-days: 7
suite-script: test:e2e:scanner
check-name: Server Scanner E2E
artifact-name: scanner-e2e-report
junit-glob: test-results/server/scanner-e2e-junit.xml
job-timeout-minutes: 45
+3
View File
@@ -26,5 +26,8 @@ local/
coverage/
*.tsbuildinfo
# Test results (generated artifacts - uploaded to CI, not committed)
test-results/
# IDE
.idea/
+2 -2
View File
@@ -33,8 +33,8 @@
"db:reset": "bash ./scripts/db/reset.sh",
"db:seed": "bash ./scripts/db/seed.sh",
"db:prepare:e2e": "bash ./scripts/db/prepare-e2e.sh",
"test:e2e:scanner": "bash ./scripts/test/scanner-e2e.sh",
"test:e2e:scanner:file-ops": "bash ./scripts/test/scanner-file-ops-e2e.sh",
"test:e2e:scanner": "bash ./scripts/test/run-e2e-suite.sh test:e2e:scanner",
"test:e2e:scanner:file-ops": "bash ./scripts/test/run-e2e-suite.sh test:e2e:scanner:file-ops",
"prepare": "husky"
},
"engines": {
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: run-e2e-suite.sh <server-script-name> [extra vitest args...]
#
# Example:
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner -- --testNamePattern="book-per-folder-disc-folder-flattening"
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner:file-ops
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <server-script-name> [extra vitest args...]" >&2
exit 1
fi
SUITE_SCRIPT="$1"
shift
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
JUNIT_DIR="$ROOT_DIR/test-results/server"
mkdir -p "$JUNIT_DIR"
if [[ "${CI:-}" != "true" ]]; then
echo "Starting local PostgreSQL (dev compose)..."
pnpm run db:up
fi
echo "Resetting and migrating dedicated e2e database..."
E2E_DATABASE_URL="$E2E_DATABASE_URL" pnpm run db:prepare:e2e
echo "Running e2e suite: $SUITE_SCRIPT..."
DATABASE_URL="$E2E_DATABASE_URL" pnpm --filter server "$SUITE_SCRIPT" "$@"
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
JUNIT_OUTPUT="${JUNIT_OUTPUT:-$ROOT_DIR/test-results/server/scanner-e2e-junit.xml}"
if [[ "${CI:-}" != "true" ]]; then
echo "Starting local PostgreSQL (dev compose)..."
pnpm run db:up
fi
echo "Resetting and migrating dedicated e2e database..."
E2E_DATABASE_URL="$E2E_DATABASE_URL" pnpm run db:prepare:e2e
mkdir -p "$(dirname "$JUNIT_OUTPUT")"
echo "Running scanner e2e suite..."
DATABASE_URL="$E2E_DATABASE_URL" JUNIT_OUTPUT="$JUNIT_OUTPUT" pnpm --filter server test:e2e:scanner
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
JUNIT_OUTPUT="${JUNIT_OUTPUT:-$ROOT_DIR/test-results/server/scanner-file-ops-e2e-junit.xml}"
if [[ "${CI:-}" != "true" ]]; then
echo "Starting local PostgreSQL (dev compose)..."
pnpm run db:up
fi
echo "Resetting and migrating dedicated e2e database..."
E2E_DATABASE_URL="$E2E_DATABASE_URL" pnpm run db:prepare:e2e
mkdir -p "$(dirname "$JUNIT_OUTPUT")"
echo "Running scanner file operations e2e suite..."
DATABASE_URL="$E2E_DATABASE_URL" JUNIT_OUTPUT="$JUNIT_OUTPUT" pnpm --filter server test:e2e:scanner:file-ops
@@ -16,7 +16,8 @@ import { classifyFile } from './lib/classify';
type Db = NodePgDatabase<typeof schema>;
type EventType = 'delete' | 'create';
const DEBOUNCE_MS = 500;
export const WATCHER_DEBOUNCE_MS = 500;
const DEBOUNCE_MS = WATCHER_DEBOUNCE_MS;
const SCAN_DEBOUNCE_MS = 3_000;
const DIR_CREATE_SUPPRESS_MS = 6_000;
const CROSS_LIBRARY_RESCAN_DEBOUNCE_MS = 1_500;
+388
View File
@@ -0,0 +1,388 @@
import { randomUUID } from 'crypto';
import { expect } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import { count, eq, isNull, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types';
import { AppModule } from '../../src/app.module';
import { DB } from '../../src/db';
import * as schema from '../../src/db/schema';
import { books, bookFiles, libraries, libraryFolders, scanJobs } from '../../src/db/schema';
import { MetadataService } from '../../src/modules/metadata/metadata.service';
export type Db = NodePgDatabase<typeof schema>;
type OrganizationMode = 'book_per_file' | 'book_per_folder';
const ADMIN_SETUP_DTO = {
username: 'scanner-e2e-admin',
name: 'Scanner E2E Admin',
email: 'scanner-e2e-admin@example.com',
password: 'ScannerE2E123',
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function makeMetadataNoopMock(): Pick<
MetadataService,
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
> {
return {
extractAndSave: () => Promise.resolve(undefined),
refreshCoverForBook: () => Promise.resolve(false),
extractAudioFileDuration: () => Promise.resolve(undefined),
aggregateAudioDuration: () => Promise.resolve(undefined),
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
};
}
async function getAdminToken(app: NestFastifyApplication): Promise<string> {
const setupResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/setup',
payload: ADMIN_SETUP_DTO,
});
if (setupResponse.statusCode === 201) {
const body = setupResponse.json() as { accessToken?: string };
if (!body.accessToken) throw new Error('Setup succeeded but accessToken was missing');
return body.accessToken;
}
if (setupResponse.statusCode === 409) {
throw new Error('Initial setup already completed. Use the dedicated e2e database reset command before running e2e tests.');
}
throw new Error(`Unexpected setup response: ${setupResponse.statusCode} ${setupResponse.body}`);
}
export interface E2EContext {
app: NestFastifyApplication;
db: Db;
adminToken: string;
}
export async function createE2EContext(): Promise<E2EContext> {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(MetadataService)
.useValue(makeMetadataNoopMock())
.compile();
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix('api/v1');
await app.register(fastifyCookie as never);
await app.init();
await app.getHttpAdapter().getInstance().ready();
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app);
return { app, db, adminToken };
}
export async function closeE2EContext(ctx: E2EContext): Promise<void> {
await ctx.app.close();
}
export interface SeedLibraryInput {
rootPath: string;
mode: OrganizationMode;
allowedFormats?: string[];
excludePatterns?: string[];
watch?: boolean;
name?: string;
}
export async function seedLibrary(db: Db, input: SeedLibraryInput): Promise<{ libraryId: number; libraryFolderId: number }> {
const [library] = await db
.insert(libraries)
.values({
name: input.name ?? `e2e-${input.mode}-${randomUUID()}`,
watch: input.watch ?? false,
organizationMode: input.mode,
allowedFormats: input.allowedFormats ?? [],
excludePatterns: input.excludePatterns ?? [],
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
})
.returning({ id: libraries.id });
const [libraryFolder] = await db
.insert(libraryFolders)
.values({
libraryId: library.id,
path: input.rootPath,
})
.returning({ id: libraryFolders.id });
return { libraryId: library.id, libraryFolderId: libraryFolder.id };
}
export async function triggerLibraryScan(ctx: E2EContext, libraryId: number): Promise<number> {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
headers: { authorization: `Bearer ${ctx.adminToken}` },
});
if (response.statusCode !== 202) {
throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`);
}
const body = response.json() as { jobId?: number };
if (!body.jobId) {
throw new Error(`Scan endpoint returned no jobId: ${response.body}`);
}
return body.jobId;
}
export async function triggerAndWaitForLibraryScan(ctx: E2EContext, libraryId: number, timeoutMs = 30_000): Promise<typeof scanJobs.$inferSelect> {
const jobId = await triggerLibraryScan(ctx, libraryId);
return waitForScanCompletion(ctx.db, jobId, timeoutMs);
}
export async function waitForScanCompletion(db: Db, jobId: number, timeoutMs = 30_000, pollMs = 100): Promise<typeof scanJobs.$inferSelect> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const [job] = await db.select().from(scanJobs).where(eq(scanJobs.id, jobId)).limit(1);
if (!job) throw new Error(`Scan job ${jobId} not found`);
if (job.status === 'completed') return job;
if (job.status === 'failed') {
throw new Error(`Scan job ${jobId} failed: ${job.errorMessage ?? 'Unknown error'}`);
}
await sleep(pollMs);
}
throw new Error(`Timed out waiting for scan job ${jobId} to complete`);
}
export interface LibraryBookState {
folderPath: string;
status: string;
primaryPath: string | null;
filePaths: string[];
}
export async function loadLibraryBookState(db: Db, libraryId: number): Promise<LibraryBookState[]> {
const bookRows = await db
.select({
id: books.id,
folderPath: books.folderPath,
status: books.status,
primaryFileId: books.primaryFileId,
})
.from(books)
.where(eq(books.libraryId, libraryId));
const states: LibraryBookState[] = [];
for (const row of bookRows) {
const fileRows = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.bookId, row.id));
let primaryPath: string | null = null;
if (row.primaryFileId !== null) {
const [primary] = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.id, row.primaryFileId)).limit(1);
primaryPath = primary?.absolutePath ?? null;
}
states.push({
folderPath: row.folderPath,
status: row.status,
primaryPath,
filePaths: fileRows.map((fileRow) => fileRow.absolutePath).sort(),
});
}
return states.sort((a, b) => a.folderPath.localeCompare(b.folderPath));
}
export interface IntegritySnapshot {
orphanBookMetadata: number;
orphanBookFiles: number;
orphanBookAuthors: number;
orphanBookGenres: number;
orphanBookTags: number;
orphanBookNarrators: number;
orphanCollectionBooks: number;
orphanUserBookStatus: number;
orphanReadingProgress: number;
orphanReadingSessions: number;
orphanAudiobookProgressBook: number;
orphanAudiobookProgressFile: number;
orphanBookmarks: number;
orphanAnnotations: number;
orphanReaderPreferences: number;
booksWithoutMetadata: number;
invalidPrimaryFileRef: number;
duplicatePresentFilePathPerLibrary: number;
}
export async function loadIntegritySnapshot(db: Db): Promise<IntegritySnapshot> {
const [orphanBookMetadata] = await db
.select({ value: count() })
.from(schema.bookMetadata)
.leftJoin(schema.books, eq(schema.bookMetadata.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookFiles] = await db
.select({ value: count() })
.from(schema.bookFiles)
.leftJoin(schema.books, eq(schema.bookFiles.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookAuthors] = await db
.select({ value: count() })
.from(schema.bookAuthors)
.leftJoin(schema.books, eq(schema.bookAuthors.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookGenres] = await db
.select({ value: count() })
.from(schema.bookGenres)
.leftJoin(schema.books, eq(schema.bookGenres.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookTags] = await db
.select({ value: count() })
.from(schema.bookTags)
.leftJoin(schema.books, eq(schema.bookTags.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookNarrators] = await db
.select({ value: count() })
.from(schema.bookNarrators)
.leftJoin(schema.books, eq(schema.bookNarrators.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanCollectionBooks] = await db
.select({ value: count() })
.from(schema.collectionBooks)
.leftJoin(schema.books, eq(schema.collectionBooks.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanUserBookStatus] = await db
.select({ value: count() })
.from(schema.userBookStatus)
.leftJoin(schema.books, eq(schema.userBookStatus.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanReadingProgress] = await db
.select({ value: count() })
.from(schema.readingProgress)
.leftJoin(schema.bookFiles, eq(schema.readingProgress.bookFileId, schema.bookFiles.id))
.where(isNull(schema.bookFiles.id));
const [orphanReadingSessions] = await db
.select({ value: count() })
.from(schema.readingSessions)
.leftJoin(schema.bookFiles, eq(schema.readingSessions.bookFileId, schema.bookFiles.id))
.where(isNull(schema.bookFiles.id));
const [orphanAudiobookProgressBook] = await db
.select({ value: count() })
.from(schema.audiobookProgress)
.leftJoin(schema.books, eq(schema.audiobookProgress.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanAudiobookProgressFile] = await db
.select({ value: count() })
.from(schema.audiobookProgress)
.leftJoin(schema.bookFiles, eq(schema.audiobookProgress.currentFileId, schema.bookFiles.id))
.where(isNull(schema.bookFiles.id));
const [orphanBookmarks] = await db
.select({ value: count() })
.from(schema.bookmarks)
.leftJoin(schema.books, eq(schema.bookmarks.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanAnnotations] = await db
.select({ value: count() })
.from(schema.annotations)
.leftJoin(schema.books, eq(schema.annotations.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanReaderPreferences] = await db
.select({ value: count() })
.from(schema.readerPreferences)
.leftJoin(schema.bookFiles, eq(schema.readerPreferences.bookFileId, schema.bookFiles.id))
.where(isNull(schema.bookFiles.id));
const [booksWithoutMetadata] = await db
.select({ value: count() })
.from(schema.books)
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
.where(isNull(schema.bookMetadata.bookId));
const invalidPrimaryRows = await db
.select({ bookId: schema.books.id })
.from(schema.books)
.leftJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
.where(
sql`${schema.books.status} = 'present' AND ${schema.books.primaryFileId} IS NOT NULL AND (${schema.bookFiles.id} IS NULL OR ${schema.bookFiles.bookId} <> ${schema.books.id})`,
);
const duplicatePresentRows = await db
.select({
libraryId: schema.books.libraryId,
absolutePath: schema.bookFiles.absolutePath,
fileCount: sql<number>`count(*)`,
})
.from(schema.bookFiles)
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
.where(eq(schema.books.status, 'present'))
.groupBy(schema.books.libraryId, schema.bookFiles.absolutePath)
.having(sql`count(*) > 1`);
return {
orphanBookMetadata: Number(orphanBookMetadata.value),
orphanBookFiles: Number(orphanBookFiles.value),
orphanBookAuthors: Number(orphanBookAuthors.value),
orphanBookGenres: Number(orphanBookGenres.value),
orphanBookTags: Number(orphanBookTags.value),
orphanBookNarrators: Number(orphanBookNarrators.value),
orphanCollectionBooks: Number(orphanCollectionBooks.value),
orphanUserBookStatus: Number(orphanUserBookStatus.value),
orphanReadingProgress: Number(orphanReadingProgress.value),
orphanReadingSessions: Number(orphanReadingSessions.value),
orphanAudiobookProgressBook: Number(orphanAudiobookProgressBook.value),
orphanAudiobookProgressFile: Number(orphanAudiobookProgressFile.value),
orphanBookmarks: Number(orphanBookmarks.value),
orphanAnnotations: Number(orphanAnnotations.value),
orphanReaderPreferences: Number(orphanReaderPreferences.value),
booksWithoutMetadata: Number(booksWithoutMetadata.value),
invalidPrimaryFileRef: invalidPrimaryRows.length,
duplicatePresentFilePathPerLibrary: duplicatePresentRows.length,
};
}
export async function assertNoIntegrityViolations(db: Db): Promise<void> {
const snapshot = await loadIntegritySnapshot(db);
for (const [key, value] of Object.entries(snapshot)) {
expect(value, `integrity violation: ${key}`).toBe(0);
}
}
export async function waitForCondition(check: () => Promise<void>, timeoutMs = 20_000, pollMs = 200): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown = null;
while (Date.now() < deadline) {
try {
await check();
return;
} catch (err) {
lastError = err;
await sleep(pollMs);
}
}
throw new Error(`Timed out waiting for condition: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
-235
View File
@@ -1,235 +0,0 @@
import { randomUUID } from 'crypto';
import fastifyCookie from '@fastify/cookie';
import { eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types';
import { AppModule } from '../../src/app.module';
import { DB } from '../../src/db';
import * as schema from '../../src/db/schema';
import { bookFiles, books, libraries, libraryFolders, scanJobs } from '../../src/db/schema';
import { MetadataService } from '../../src/modules/metadata/metadata.service';
import { FileWatcherService } from '../../src/modules/scanner/file-watcher.service';
type Db = NodePgDatabase<typeof schema>;
type OrganizationMode = 'book_per_file' | 'book_per_folder';
const ADMIN_SETUP_DTO = {
username: 'scanner-e2e-admin',
name: 'Scanner E2E Admin',
email: 'scanner-e2e-admin@example.com',
password: 'ScannerE2E123',
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function makeMetadataNoopMock(): Pick<
MetadataService,
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
> {
return {
extractAndSave: () => Promise.resolve(undefined),
refreshCoverForBook: () => Promise.resolve(false),
extractAudioFileDuration: () => Promise.resolve(undefined),
aggregateAudioDuration: () => Promise.resolve(undefined),
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
};
}
async function getAdminToken(app: NestFastifyApplication): Promise<string> {
const setupResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/setup',
payload: ADMIN_SETUP_DTO,
});
if (setupResponse.statusCode === 201) {
const body = setupResponse.json() as { accessToken?: string };
if (!body.accessToken) throw new Error('Setup succeeded but accessToken was missing');
return body.accessToken;
}
if (setupResponse.statusCode === 409) {
throw new Error('Initial setup already completed. Use the dedicated e2e database reset command before running scanner e2e tests.');
}
throw new Error(`Unexpected setup response: ${setupResponse.statusCode} ${setupResponse.body}`);
}
export interface ScannerE2EContext {
app: NestFastifyApplication;
db: Db;
adminToken: string;
}
export async function createScannerE2EContext(): Promise<ScannerE2EContext> {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(MetadataService)
.useValue(makeMetadataNoopMock())
.compile();
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix('api/v1');
await app.register(fastifyCookie as never);
await app.init();
await app.getHttpAdapter().getInstance().ready();
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app);
return { app, db, adminToken };
}
export async function closeScannerE2EContext(ctx: ScannerE2EContext): Promise<void> {
await ctx.app.close();
}
export interface SeedLibraryInput {
rootPath: string;
mode: OrganizationMode;
allowedFormats?: string[];
excludePatterns?: string[];
watch?: boolean;
name?: string;
}
export async function seedLibrary(db: Db, input: SeedLibraryInput): Promise<{ libraryId: number; libraryFolderId: number }> {
const [library] = await db
.insert(libraries)
.values({
name: input.name ?? `scanner-e2e-${input.mode}-${randomUUID()}`,
watch: input.watch ?? false,
organizationMode: input.mode,
allowedFormats: input.allowedFormats ?? [],
excludePatterns: input.excludePatterns ?? [],
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
})
.returning({ id: libraries.id });
const [libraryFolder] = await db
.insert(libraryFolders)
.values({
libraryId: library.id,
path: input.rootPath,
})
.returning({ id: libraryFolders.id });
return { libraryId: library.id, libraryFolderId: libraryFolder.id };
}
export async function triggerLibraryScan(ctx: ScannerE2EContext, libraryId: number): Promise<number> {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
headers: { authorization: `Bearer ${ctx.adminToken}` },
});
if (response.statusCode !== 202) {
throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`);
}
const body = response.json() as { jobId?: number };
if (!body.jobId) {
throw new Error(`Scan endpoint returned no jobId: ${response.body}`);
}
return body.jobId;
}
export async function triggerAndWaitForLibraryScan(
ctx: ScannerE2EContext,
libraryId: number,
timeoutMs = 30_000,
): Promise<typeof scanJobs.$inferSelect> {
const jobId = await triggerLibraryScan(ctx, libraryId);
return waitForScanCompletion(ctx.db, jobId, timeoutMs);
}
export async function startLibraryWatcher(ctx: ScannerE2EContext, libraryId: number, paths: string[]): Promise<void> {
const watcher = ctx.app.get(FileWatcherService);
await watcher.startWatcher(libraryId, paths);
}
export async function stopLibraryWatcher(ctx: ScannerE2EContext, libraryId: number): Promise<void> {
const watcher = ctx.app.get(FileWatcherService);
await watcher.stopWatcher(libraryId);
}
export async function waitForScanCompletion(db: Db, jobId: number, timeoutMs = 30_000, pollMs = 100): Promise<typeof scanJobs.$inferSelect> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const [job] = await db.select().from(scanJobs).where(eq(scanJobs.id, jobId)).limit(1);
if (!job) throw new Error(`Scan job ${jobId} not found`);
if (job.status === 'completed') return job;
if (job.status === 'failed') {
throw new Error(`Scan job ${jobId} failed: ${job.errorMessage ?? 'Unknown error'}`);
}
await sleep(pollMs);
}
throw new Error(`Timed out waiting for scan job ${jobId} to complete`);
}
export interface LibraryBookState {
folderPath: string;
status: string;
primaryPath: string | null;
filePaths: string[];
}
export async function loadLibraryBookState(db: Db, libraryId: number): Promise<LibraryBookState[]> {
const bookRows = await db
.select({
id: books.id,
folderPath: books.folderPath,
status: books.status,
primaryFileId: books.primaryFileId,
})
.from(books)
.where(eq(books.libraryId, libraryId));
const states: LibraryBookState[] = [];
for (const row of bookRows) {
const fileRows = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.bookId, row.id));
let primaryPath: string | null = null;
if (row.primaryFileId !== null) {
const [primary] = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.id, row.primaryFileId)).limit(1);
primaryPath = primary?.absolutePath ?? null;
}
states.push({
folderPath: row.folderPath,
status: row.status,
primaryPath,
filePaths: fileRows.map((fileRow) => fileRow.absolutePath).sort(),
});
}
return states.sort((a, b) => a.folderPath.localeCompare(b.folderPath));
}
export async function waitForCondition(check: () => Promise<void>, timeoutMs = 20_000, pollMs = 200): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown = null;
while (Date.now() < deadline) {
try {
await check();
return;
} catch (err) {
lastError = err;
await sleep(pollMs);
}
}
throw new Error(`Timed out waiting for condition: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
@@ -0,0 +1,27 @@
export {
assertNoIntegrityViolations,
closeE2EContext as closeScannerE2EContext,
createE2EContext as createScannerE2EContext,
loadLibraryBookState,
seedLibrary,
triggerAndWaitForLibraryScan,
triggerLibraryScan,
waitForCondition,
waitForScanCompletion,
type E2EContext as ScannerE2EContext,
type LibraryBookState,
type SeedLibraryInput,
} from '../app-harness';
import { FileWatcherService } from '../../../src/modules/scanner/file-watcher.service';
import type { E2EContext } from '../app-harness';
export async function startLibraryWatcher(ctx: E2EContext, libraryId: number, paths: string[]): Promise<void> {
const watcher = ctx.app.get(FileWatcherService);
await watcher.startWatcher(libraryId, paths);
}
export async function stopLibraryWatcher(ctx: E2EContext, libraryId: number): Promise<void> {
const watcher = ctx.app.get(FileWatcherService);
await watcher.stopWatcher(libraryId);
}
+115 -293
View File
@@ -6,12 +6,14 @@ import { randomUUID } from 'crypto';
import { mkdir, realpath, rename, rm, stat, writeFile } from 'fs/promises';
import { dirname, join, relative } from 'path';
import { and, count, eq, isNull, like, sql } from 'drizzle-orm';
import { and, count, eq, like } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import type { FixtureEntry } from './e2e/scanner-fixture-builder';
import { createFixtureTree, file } from './e2e/scanner-fixture-builder';
import { WATCHER_DEBOUNCE_MS } from '../src/modules/scanner/file-watcher.service';
import type { FixtureEntry, FixtureTree } from './e2e/scanner/scanner-fixture-builder';
import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder';
import {
assertNoIntegrityViolations,
closeScannerE2EContext,
createScannerE2EContext,
loadLibraryBookState,
@@ -22,7 +24,7 @@ import {
waitForCondition,
type LibraryBookState,
type ScannerE2EContext,
} from './e2e/scanner-harness';
} from './e2e/scanner/scanner-harness';
type OrganizationMode = 'book_per_file' | 'book_per_folder';
type TriggerMode = 'manual' | 'watcher';
@@ -76,27 +78,6 @@ interface SeededLibrary {
libraryId: number;
}
interface IntegritySnapshot {
orphanBookMetadata: number;
orphanBookFiles: number;
orphanBookAuthors: number;
orphanBookGenres: number;
orphanBookTags: number;
orphanBookNarrators: number;
orphanCollectionBooks: number;
orphanUserBookStatus: number;
orphanReadingProgress: number;
orphanReadingSessions: number;
orphanAudiobookProgressBook: number;
orphanAudiobookProgressFile: number;
orphanBookmarks: number;
orphanAnnotations: number;
orphanReaderPreferences: number;
booksWithoutMetadata: number;
invalidPrimaryFileRef: number;
duplicatePresentFilePathPerLibrary: number;
}
interface StatefulSnapshot {
metadataTitle: string | null;
metadataPublisher: string | null;
@@ -486,6 +467,7 @@ const structuralScenarios: StructuralScenario[] = [
a: {
statusByFolder: { Shelf: 'present' },
fileOwners: { 'Shelf/book.epub': 'Shelf' },
presentCount: 1,
},
},
},
@@ -499,6 +481,7 @@ const structuralScenarios: StructuralScenario[] = [
a: {
statusByFolder: { 'book.epub': 'present' },
fileOwners: { 'book.epub': 'book.epub' },
presentCount: 1,
},
},
},
@@ -582,7 +565,7 @@ const structuralScenarios: StructuralScenario[] = [
entries: [file('lib-a/Track.mp3')],
operations: [
{ type: 'deleteFile', path: 'lib-a/Track.mp3' },
{ type: 'sleep', ms: 900 },
{ type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 },
{ type: 'writeFile', path: 'lib-a/Track.mp3' },
],
expected: { a: { statusByFolder: { 'Track.mp3': 'present' }, presentCount: 1, missingCount: 0 } },
@@ -627,7 +610,7 @@ const structuralScenarios: StructuralScenario[] = [
entries: [file('lib-a/Swap/old.epub')],
operations: [
{ type: 'deleteDir', path: 'lib-a/Swap' },
{ type: 'sleep', ms: 900 },
{ type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 },
{ type: 'writeFile', path: 'lib-a/Swap/new.epub' },
],
expected: {
@@ -650,7 +633,7 @@ const structuralScenarios: StructuralScenario[] = [
entries: [file('lib-a/Book/book.epub')],
operations: [
{ type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-b/Inbox/book.epub' },
{ type: 'sleep', ms: 900 },
{ type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 },
{ type: 'writeFile', path: 'lib-a/Book/book.epub' },
],
expected: {
@@ -785,152 +768,6 @@ function assertLibraryExpectation(rootPath: string, actual: LibraryBookState[],
}
}
async function loadIntegritySnapshot(db: ScannerE2EContext['db']): Promise<IntegritySnapshot> {
const [orphanBookMetadata] = await db
.select({ value: count() })
.from(schema.bookMetadata)
.leftJoin(schema.books, eq(schema.bookMetadata.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookFiles] = await db
.select({ value: count() })
.from(schema.bookFiles)
.leftJoin(schema.books, eq(schema.bookFiles.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookAuthors] = await db
.select({ value: count() })
.from(schema.bookAuthors)
.leftJoin(schema.books, eq(schema.bookAuthors.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookGenres] = await db
.select({ value: count() })
.from(schema.bookGenres)
.leftJoin(schema.books, eq(schema.bookGenres.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookTags] = await db
.select({ value: count() })
.from(schema.bookTags)
.leftJoin(schema.books, eq(schema.bookTags.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanBookNarrators] = await db
.select({ value: count() })
.from(schema.bookNarrators)
.leftJoin(schema.books, eq(schema.bookNarrators.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanCollectionBooks] = await db
.select({ value: count() })
.from(schema.collectionBooks)
.leftJoin(schema.books, eq(schema.collectionBooks.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanUserBookStatus] = await db
.select({ value: count() })
.from(schema.userBookStatus)
.leftJoin(schema.books, eq(schema.userBookStatus.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanReadingProgress] = await db
.select({ value: count() })
.from(schema.readingProgress)
.leftJoin(schema.bookFiles, eq(schema.readingProgress.bookFileId, schema.bookFiles.id))
.where(isNull(schema.bookFiles.id));
const [orphanReadingSessions] = await db
.select({ value: count() })
.from(schema.readingSessions)
.leftJoin(schema.bookFiles, eq(schema.readingSessions.bookFileId, schema.bookFiles.id))
.where(isNull(schema.bookFiles.id));
const [orphanAudiobookProgressBook] = await db
.select({ value: count() })
.from(schema.audiobookProgress)
.leftJoin(schema.books, eq(schema.audiobookProgress.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanAudiobookProgressFile] = await db
.select({ value: count() })
.from(schema.audiobookProgress)
.leftJoin(schema.bookFiles, eq(schema.audiobookProgress.currentFileId, schema.bookFiles.id))
.where(isNull(schema.bookFiles.id));
const [orphanBookmarks] = await db
.select({ value: count() })
.from(schema.bookmarks)
.leftJoin(schema.books, eq(schema.bookmarks.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanAnnotations] = await db
.select({ value: count() })
.from(schema.annotations)
.leftJoin(schema.books, eq(schema.annotations.bookId, schema.books.id))
.where(isNull(schema.books.id));
const [orphanReaderPreferences] = await db
.select({ value: count() })
.from(schema.readerPreferences)
.leftJoin(schema.bookFiles, eq(schema.readerPreferences.bookFileId, schema.bookFiles.id))
.where(isNull(schema.bookFiles.id));
const [booksWithoutMetadata] = await db
.select({ value: count() })
.from(schema.books)
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
.where(isNull(schema.bookMetadata.bookId));
const invalidPrimaryRows = await db
.select({ bookId: schema.books.id })
.from(schema.books)
.leftJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
.where(
sql`${schema.books.status} = 'present' AND ${schema.books.primaryFileId} IS NOT NULL AND (${schema.bookFiles.id} IS NULL OR ${schema.bookFiles.bookId} <> ${schema.books.id})`,
);
const duplicatePresentRows = await db
.select({
libraryId: schema.books.libraryId,
absolutePath: schema.bookFiles.absolutePath,
fileCount: sql<number>`count(*)`,
})
.from(schema.bookFiles)
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
.where(eq(schema.books.status, 'present'))
.groupBy(schema.books.libraryId, schema.bookFiles.absolutePath)
.having(sql`count(*) > 1`);
return {
orphanBookMetadata: Number(orphanBookMetadata.value),
orphanBookFiles: Number(orphanBookFiles.value),
orphanBookAuthors: Number(orphanBookAuthors.value),
orphanBookGenres: Number(orphanBookGenres.value),
orphanBookTags: Number(orphanBookTags.value),
orphanBookNarrators: Number(orphanBookNarrators.value),
orphanCollectionBooks: Number(orphanCollectionBooks.value),
orphanUserBookStatus: Number(orphanUserBookStatus.value),
orphanReadingProgress: Number(orphanReadingProgress.value),
orphanReadingSessions: Number(orphanReadingSessions.value),
orphanAudiobookProgressBook: Number(orphanAudiobookProgressBook.value),
orphanAudiobookProgressFile: Number(orphanAudiobookProgressFile.value),
orphanBookmarks: Number(orphanBookmarks.value),
orphanAnnotations: Number(orphanAnnotations.value),
orphanReaderPreferences: Number(orphanReaderPreferences.value),
booksWithoutMetadata: Number(booksWithoutMetadata.value),
invalidPrimaryFileRef: invalidPrimaryRows.length,
duplicatePresentFilePathPerLibrary: duplicatePresentRows.length,
};
}
async function assertNoIntegrityViolations(db: ScannerE2EContext['db']): Promise<void> {
const snapshot = await loadIntegritySnapshot(db);
for (const [key, value] of Object.entries(snapshot)) {
expect(value, `integrity violation ${key}`).toBe(0);
}
}
async function resetScannerFileOpsState(db: ScannerE2EContext['db']): Promise<void> {
await db.delete(schema.libraries).where(like(schema.libraries.name, 'scanner-file-ops-%'));
await db.delete(schema.users).where(like(schema.users.username, 'state-user-%'));
@@ -1293,61 +1130,42 @@ async function writeScenarioReport(results: ScenarioRunResult[]): Promise<void>
);
}
async function runRecordedScenario(scenarioId: string, trigger: TriggerMode, results: ScenarioRunResult[], work: () => Promise<void>): Promise<void> {
interface StatefulScenario {
id: string;
trigger: TriggerMode;
fixturePrefix: string;
fixtureEntries: FixtureEntry[];
run: (context: ScannerE2EContext, fixture: FixtureTree) => Promise<void>;
}
async function runStatefulScenario(context: ScannerE2EContext, scenario: StatefulScenario, results: ScenarioRunResult[]): Promise<void> {
const startedAt = Date.now();
await resetScannerFileOpsState(context.db);
const fixture = await createFixtureTree(scenario.fixtureEntries, scenario.fixturePrefix);
try {
await work();
results.push({ id: scenarioId, trigger, status: 'passed', durationMs: Date.now() - startedAt });
await scenario.run(context, fixture);
results.push({ id: scenario.id, trigger: scenario.trigger, status: 'passed', durationMs: Date.now() - startedAt });
} catch (err) {
results.push({
id: scenarioId,
trigger,
id: scenario.id,
trigger: scenario.trigger,
status: 'failed',
durationMs: Date.now() - startedAt,
error: err instanceof Error ? err.message : String(err),
});
throw err;
} finally {
await fixture.cleanup();
}
}
describe('Scanner file operations (e2e)', () => {
let context: ScannerE2EContext | null = null;
const scenarioResults: ScenarioRunResult[] = [];
beforeAll(async () => {
context = await createScannerE2EContext();
});
afterAll(async () => {
await writeScenarioReport(scenarioResults);
if (context) {
await closeScannerE2EContext(context);
}
});
describe('structural scenarios', () => {
for (const scenario of structuralScenarios) {
it(
scenario.id,
async () => {
if (!context) throw new Error('E2E context not initialized');
await runStructuralScenario(context, scenario, scenarioResults);
},
SCENARIO_TIMEOUT_MS,
);
}
});
describe('stateful consistency scenarios', () => {
it(
'stateful-manual-delete-primary-preserves-data',
async () => {
if (!context) throw new Error('E2E context not initialized');
await runRecordedScenario('stateful-manual-delete-primary-preserves-data', 'manual', scenarioResults, async () => {
await resetScannerFileOpsState(context.db);
const fixture = await createFixtureTree([file('lib-a/Book/book.epub')], 'scanner-file-ops-stateful-manual-delete-');
try {
const statefulScenarios: StatefulScenario[] = [
{
id: 'stateful-manual-delete-primary-preserves-data',
trigger: 'manual',
fixturePrefix: 'scanner-file-ops-stateful-manual-delete-',
fixtureEntries: [file('lib-a/Book/book.epub')],
run: async (context, fixture) => {
const requestedRootA = join(fixture.rootPath, 'lib-a');
await mkdir(requestedRootA, { recursive: true });
const rootA = await realpath(requestedRootA);
@@ -1373,23 +1191,14 @@ describe('Scanner file operations (e2e)', () => {
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
await assertNoIntegrityViolations(context.db);
} finally {
await fixture.cleanup();
}
});
},
SCENARIO_TIMEOUT_MS,
);
it(
'stateful-manual-cross-library-move-preserves-book-identity',
async () => {
if (!context) throw new Error('E2E context not initialized');
await runRecordedScenario('stateful-manual-cross-library-move-preserves-book-identity', 'manual', scenarioResults, async () => {
await resetScannerFileOpsState(context.db);
const fixture = await createFixtureTree([file('lib-a/Book/book.epub')], 'scanner-file-ops-stateful-cross-lib-manual-');
try {
},
{
id: 'stateful-manual-cross-library-move-preserves-book-identity',
trigger: 'manual',
fixturePrefix: 'scanner-file-ops-stateful-cross-lib-manual-',
fixtureEntries: [file('lib-a/Book/book.epub')],
run: async (context, fixture) => {
const requestedRootA = join(fixture.rootPath, 'lib-a');
const requestedRootB = join(fixture.rootPath, 'lib-b');
await mkdir(requestedRootA, { recursive: true });
@@ -1439,25 +1248,14 @@ describe('Scanner file operations (e2e)', () => {
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
await assertNoIntegrityViolations(context.db);
} finally {
await fixture.cleanup();
}
});
},
SCENARIO_TIMEOUT_MS,
);
it(
'stateful-watcher-cross-library-move-preserves-book-identity',
async () => {
if (!context) throw new Error('E2E context not initialized');
await runRecordedScenario('stateful-watcher-cross-library-move-preserves-book-identity', 'watcher', scenarioResults, async () => {
await resetScannerFileOpsState(context.db);
const fixture = await createFixtureTree([file('lib-a/Book/book.epub')], 'scanner-file-ops-stateful-cross-lib-watcher-');
let sourceLibraryId: number | null = null;
let destinationLibraryId: number | null = null;
try {
},
{
id: 'stateful-watcher-cross-library-move-preserves-book-identity',
trigger: 'watcher',
fixturePrefix: 'scanner-file-ops-stateful-cross-lib-watcher-',
fixtureEntries: [file('lib-a/Book/book.epub')],
run: async (context, fixture) => {
const requestedRootA = join(fixture.rootPath, 'lib-a');
const requestedRootB = join(fixture.rootPath, 'lib-b');
await mkdir(requestedRootA, { recursive: true });
@@ -1465,6 +1263,9 @@ describe('Scanner file operations (e2e)', () => {
const rootA = await realpath(requestedRootA);
const rootB = await realpath(requestedRootB);
let sourceLibraryId: number | null = null;
let destinationLibraryId: number | null = null;
try {
const sourceLibrary = await seedLibrary(context.db, {
rootPath: rootA,
mode: 'book_per_folder',
@@ -1510,32 +1311,22 @@ describe('Scanner file operations (e2e)', () => {
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
await assertNoIntegrityViolations(context.db);
} finally {
if (sourceLibraryId != null) {
await stopLibraryWatcher(context, sourceLibraryId);
if (sourceLibraryId != null) await stopLibraryWatcher(context, sourceLibraryId);
if (destinationLibraryId != null) await stopLibraryWatcher(context, destinationLibraryId);
}
if (destinationLibraryId != null) {
await stopLibraryWatcher(context, destinationLibraryId);
}
await fixture.cleanup();
}
});
},
SCENARIO_TIMEOUT_MS,
);
it(
'stateful-watcher-rename-folder-preserves-data',
async () => {
if (!context) throw new Error('E2E context not initialized');
await runRecordedScenario('stateful-watcher-rename-folder-preserves-data', 'watcher', scenarioResults, async () => {
await resetScannerFileOpsState(context.db);
const fixture = await createFixtureTree([file('lib-a/Old/book.epub')], 'scanner-file-ops-stateful-watcher-rename-');
let libraryId: number | null = null;
try {
},
{
id: 'stateful-watcher-rename-folder-preserves-data',
trigger: 'watcher',
fixturePrefix: 'scanner-file-ops-stateful-watcher-rename-',
fixtureEntries: [file('lib-a/Old/book.epub')],
run: async (context, fixture) => {
const requestedRootA = join(fixture.rootPath, 'lib-a');
await mkdir(requestedRootA, { recursive: true });
const rootA = await realpath(requestedRootA);
let libraryId: number | null = null;
try {
const library = await seedLibrary(context.db, {
rootPath: rootA,
mode: 'book_per_folder',
@@ -1563,29 +1354,21 @@ describe('Scanner file operations (e2e)', () => {
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
await assertNoIntegrityViolations(context.db);
} finally {
if (libraryId != null) {
await stopLibraryWatcher(context, libraryId);
if (libraryId != null) await stopLibraryWatcher(context, libraryId);
}
await fixture.cleanup();
}
});
},
SCENARIO_TIMEOUT_MS,
);
it(
'stateful-watcher-delete-then-restore-preserves-data',
async () => {
if (!context) throw new Error('E2E context not initialized');
await runRecordedScenario('stateful-watcher-delete-then-restore-preserves-data', 'watcher', scenarioResults, async () => {
await resetScannerFileOpsState(context.db);
const fixture = await createFixtureTree([file('lib-a/Book/book.epub')], 'scanner-file-ops-stateful-watcher-delete-restore-');
let libraryId: number | null = null;
try {
},
{
id: 'stateful-watcher-delete-then-restore-preserves-data',
trigger: 'watcher',
fixturePrefix: 'scanner-file-ops-stateful-watcher-delete-restore-',
fixtureEntries: [file('lib-a/Book/book.epub')],
run: async (context, fixture) => {
const requestedRootA = join(fixture.rootPath, 'lib-a');
await mkdir(requestedRootA, { recursive: true });
const rootA = await realpath(requestedRootA);
let libraryId: number | null = null;
try {
const library = await seedLibrary(context.db, {
rootPath: rootA,
mode: 'book_per_folder',
@@ -1620,14 +1403,53 @@ describe('Scanner file operations (e2e)', () => {
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
await assertNoIntegrityViolations(context.db);
} finally {
if (libraryId != null) {
await stopLibraryWatcher(context, libraryId);
if (libraryId != null) await stopLibraryWatcher(context, libraryId);
}
await fixture.cleanup();
},
},
];
describe('Scanner file operations (e2e)', () => {
let context: ScannerE2EContext | null = null;
const scenarioResults: ScenarioRunResult[] = [];
beforeAll(async () => {
context = await createScannerE2EContext();
});
afterAll(async () => {
if (context) {
await assertNoIntegrityViolations(context.db);
}
await writeScenarioReport(scenarioResults);
if (context) {
await closeScannerE2EContext(context);
}
});
describe('structural scenarios', () => {
for (const scenario of structuralScenarios) {
it(
scenario.id,
async () => {
if (!context) throw new Error('E2E context not initialized');
await runStructuralScenario(context, scenario, scenarioResults);
},
SCENARIO_TIMEOUT_MS,
);
}
});
describe('stateful consistency scenarios', () => {
for (const scenario of statefulScenarios) {
it(
scenario.id,
async () => {
if (!context) throw new Error('E2E context not initialized');
await runStatefulScenario(context, scenario, scenarioResults);
},
SCENARIO_TIMEOUT_MS,
);
}
});
});
+8 -3
View File
@@ -5,9 +5,10 @@ vi.mock('../src/modules/scanner/lib/stability', () => ({
import { mkdir, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import type { FixtureEntry } from './e2e/scanner-fixture-builder';
import { createFixtureTree, file } from './e2e/scanner-fixture-builder';
import type { FixtureEntry } from './e2e/scanner/scanner-fixture-builder';
import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder';
import {
assertNoIntegrityViolations,
closeScannerE2EContext,
createScannerE2EContext,
loadLibraryBookState,
@@ -16,7 +17,7 @@ import {
waitForScanCompletion,
type LibraryBookState,
type ScannerE2EContext,
} from './e2e/scanner-harness';
} from './e2e/scanner/scanner-harness';
type OrganizationMode = 'book_per_file' | 'book_per_folder';
@@ -390,6 +391,7 @@ async function runScenario(context: ScannerE2EContext, scenario: ScannerScenario
const actual = await loadLibraryBookState(context.db, libraryId);
assertScenarioOutcome(fixture.rootPath, scenario.expected, actual);
await assertNoIntegrityViolations(context.db);
results.push({
id: scenario.id,
@@ -440,6 +442,9 @@ describe('Scanner structures (e2e)', () => {
});
afterAll(async () => {
if (context) {
await assertNoIntegrityViolations(context.db);
}
await writeScenarioReport(scenarioResults);
if (context) {
await closeScannerE2EContext(context);
-75
View File
@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<testsuites name="vitest tests" tests="35" failures="0" errors="0" time="4.7548795">
<testsuite name="test/scanner.e2e-spec.ts" timestamp="2026-04-15T03:30:47.710Z" hostname="DC-KJ3DT4LQPW.local" tests="35" failures="0" errors="0" skipped="0" time="4.7548795">
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-flat-root-file" time="0.121739833">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-mixed-root-and-folder" time="0.129137875">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-multi-format-folder-and-root-stray" time="0.128462708">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-audiobook-multipart-folder" time="0.124288667">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-disc-folder-flattening" time="0.125028375">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-ebook-with-stem-audio-subfolder" time="0.12772325">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-root-stem-audio-not-flattened" time="0.135436417">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-hidden-files-and-folders-ignored" time="0.126640375">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-exclude-patterns" time="0.126519708">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-allowed-formats" time="0.123326667">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-zero-byte-content-file-skipped" time="0.123150792">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-no-primary-files" time="0.118748583">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-deep-nested-book-folder" time="0.122631834">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-same-filename-different-authors" time="0.126866875">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-disc-name-variants-flattened" time="0.12586425">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-stem-subfolder-case-mismatch-no-merge" time="0.126321">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-stem-subfolder-with-disc-flattening" time="0.122238708">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-root-multi-format-files-remain-separate" time="0.118935375">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-multi-book-single-folder-coalesced" time="0.113775166">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-allowed-formats-audio-filter" time="0.1125685">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-exclude-exact-root-filename" time="0.118648125">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-uppercase-extensions" time="0.119853458">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_folder &gt; book-per-folder-non-disc-similar-folder-not-flattened" time="0.131077875">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-multiple-content-files" time="0.123768959">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-audio-parts" time="0.120098584">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-ignore-non-content" time="0.130401459">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-allowed-formats-and-excludes" time="0.132419792">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-deep-root-mix" time="0.132637709">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-disc-subfolders-not-grouped" time="0.126081375">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-uppercase-extensions" time="0.118420667">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-exclude-exact-filename" time="0.119933458">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-allowed-formats-case-insensitive" time="0.121673791">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-hidden-and-no-extension-ignored" time="0.118652625">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-root-and-nested-same-name" time="0.114143459">
</testcase>
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) &gt; organizationMode=book_per_file &gt; book-per-file-zero-byte-file-skipped" time="0.117714958">
</testcase>
</testsuite>
</testsuites>
@@ -1,218 +0,0 @@
{
"generatedAt": "2026-04-15T03:30:47.698Z",
"total": 35,
"passed": 35,
"failed": 0,
"results": [
{
"id": "book-per-folder-flat-root-file",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 119
},
{
"id": "book-per-folder-mixed-root-and-folder",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 126
},
{
"id": "book-per-folder-multi-format-folder-and-root-stray",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 124
},
{
"id": "book-per-folder-audiobook-multipart-folder",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 121
},
{
"id": "book-per-folder-disc-folder-flattening",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 120
},
{
"id": "book-per-folder-ebook-with-stem-audio-subfolder",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 123
},
{
"id": "book-per-folder-root-stem-audio-not-flattened",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 123
},
{
"id": "book-per-folder-hidden-files-and-folders-ignored",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 124
},
{
"id": "book-per-folder-exclude-patterns",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 123
},
{
"id": "book-per-folder-allowed-formats",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 121
},
{
"id": "book-per-folder-zero-byte-content-file-skipped",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 121
},
{
"id": "book-per-folder-no-primary-files",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 115
},
{
"id": "book-per-folder-deep-nested-book-folder",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 120
},
{
"id": "book-per-folder-same-filename-different-authors",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 124
},
{
"id": "book-per-folder-disc-name-variants-flattened",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 121
},
{
"id": "book-per-folder-stem-subfolder-case-mismatch-no-merge",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 122
},
{
"id": "book-per-folder-stem-subfolder-with-disc-flattening",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 116
},
{
"id": "book-per-folder-root-multi-format-files-remain-separate",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 117
},
{
"id": "book-per-folder-multi-book-single-folder-coalesced",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 111
},
{
"id": "book-per-folder-allowed-formats-audio-filter",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 110
},
{
"id": "book-per-folder-exclude-exact-root-filename",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 112
},
{
"id": "book-per-folder-uppercase-extensions",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 115
},
{
"id": "book-per-folder-non-disc-similar-folder-not-flattened",
"mode": "book_per_folder",
"status": "passed",
"durationMs": 126
},
{
"id": "book-per-file-multiple-content-files",
"mode": "book_per_file",
"status": "passed",
"durationMs": 116
},
{
"id": "book-per-file-audio-parts",
"mode": "book_per_file",
"status": "passed",
"durationMs": 116
},
{
"id": "book-per-file-ignore-non-content",
"mode": "book_per_file",
"status": "passed",
"durationMs": 125
},
{
"id": "book-per-file-allowed-formats-and-excludes",
"mode": "book_per_file",
"status": "passed",
"durationMs": 128
},
{
"id": "book-per-file-deep-root-mix",
"mode": "book_per_file",
"status": "passed",
"durationMs": 128
},
{
"id": "book-per-file-disc-subfolders-not-grouped",
"mode": "book_per_file",
"status": "passed",
"durationMs": 121
},
{
"id": "book-per-file-uppercase-extensions",
"mode": "book_per_file",
"status": "passed",
"durationMs": 116
},
{
"id": "book-per-file-exclude-exact-filename",
"mode": "book_per_file",
"status": "passed",
"durationMs": 118
},
{
"id": "book-per-file-allowed-formats-case-insensitive",
"mode": "book_per_file",
"status": "passed",
"durationMs": 118
},
{
"id": "book-per-file-hidden-and-no-extension-ignored",
"mode": "book_per_file",
"status": "passed",
"durationMs": 113
},
{
"id": "book-per-file-root-and-nested-same-name",
"mode": "book_per_file",
"status": "passed",
"durationMs": 112
},
{
"id": "book-per-file-zero-byte-file-skipped",
"mode": "book_per_file",
"status": "passed",
"durationMs": 112
}
]
}
@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<testsuites name="vitest tests" tests="39" failures="0" errors="0" time="37.268869625">
<testsuite name="test/scanner-file-ops.e2e-spec.ts" timestamp="2026-04-15T03:31:40.314Z" hostname="DC-KJ3DT4LQPW.local" tests="39" failures="0" errors="0" skipped="0" time="37.268869625">
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-delete-primary-file-marks-missing" time="0.294578333">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-delete-folder-marks-only-removed-book-missing" time="0.3128125">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-rename-file-in-place" time="0.294205792">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-rename-folder" time="0.294784667">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-move-root-file-into-subfolder" time="0.30047175">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-move-subfolder-file-to-root" time="0.304838667">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-cross-library-file-move" time="0.524123875">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-cross-library-folder-move" time="0.506195334">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-multi-step-churn" time="0.294776084">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-book-per-file-delete-and-move" time="0.294187041">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-move-into-existing-folder-merges" time="0.295670708">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-cross-library-move-into-existing-folder-merges" time="0.539346042">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-delete-and-recreate-same-path" time="0.282088959">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-delete-and-recreate-folder-with-new-file" time="0.26758125">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-book-per-file-folder-move-multi-file" time="0.245308375">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; manual-cross-library-move-then-reseed-source" time="0.514633208">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-delete-primary-file-marks-missing" time="0.765211042">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-delete-folder" time="0.791121958">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-rename-file-in-place" time="0.781976542">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-rename-folder" time="0.796244">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-move-root-file-into-subfolder" time="3.918478333">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-move-subfolder-file-to-root" time="3.886523458">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-cross-library-file-move" time="0.8804715">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-cross-library-folder-move" time="0.881713208">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-case-only-file-rename" time="0.781255667">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-non-content-file-creates-ignored" time="0.161706292">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-non-content-file-delete-ignored" time="0.155078375">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-book-per-file-delete-then-restore" time="1.693462125">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-move-into-existing-folder-merges" time="3.746106125">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-delete-and-recreate-same-path-within-debounce" time="0.134310834">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-delete-and-recreate-folder-with-new-file" time="1.686218333">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-cross-library-move-then-reseed-source" time="4.8905555">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-create-then-delete-new-path-within-debounce" time="0.171457541">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; structural scenarios &gt; watcher-book-per-file-cross-library-folder-move-multi-file" time="0.929455833">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; stateful consistency scenarios &gt; stateful-manual-delete-primary-preserves-data" time="0.286941625">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; stateful consistency scenarios &gt; stateful-manual-cross-library-move-preserves-book-identity" time="0.579881375">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; stateful consistency scenarios &gt; stateful-watcher-cross-library-move-preserves-book-identity" time="0.976622459">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; stateful consistency scenarios &gt; stateful-watcher-rename-folder-preserves-data" time="0.871528084">
</testcase>
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) &gt; stateful consistency scenarios &gt; stateful-watcher-delete-then-restore-preserves-data" time="1.502913459">
</testcase>
</testsuite>
</testsuites>
@@ -1,243 +0,0 @@
{
"generatedAt": "2026-04-15T03:31:40.294Z",
"total": 39,
"passed": 39,
"failed": 0,
"skipped": 0,
"results": [
{
"id": "manual-delete-primary-file-marks-missing",
"trigger": "manual",
"status": "passed",
"durationMs": 288
},
{
"id": "manual-delete-folder-marks-only-removed-book-missing",
"trigger": "manual",
"status": "passed",
"durationMs": 305
},
{
"id": "manual-rename-file-in-place",
"trigger": "manual",
"status": "passed",
"durationMs": 288
},
{
"id": "manual-rename-folder",
"trigger": "manual",
"status": "passed",
"durationMs": 290
},
{
"id": "manual-move-root-file-into-subfolder",
"trigger": "manual",
"status": "passed",
"durationMs": 294
},
{
"id": "manual-move-subfolder-file-to-root",
"trigger": "manual",
"status": "passed",
"durationMs": 300
},
{
"id": "manual-cross-library-file-move",
"trigger": "manual",
"status": "passed",
"durationMs": 517
},
{
"id": "manual-cross-library-folder-move",
"trigger": "manual",
"status": "passed",
"durationMs": 499
},
{
"id": "manual-multi-step-churn",
"trigger": "manual",
"status": "passed",
"durationMs": 287
},
{
"id": "manual-book-per-file-delete-and-move",
"trigger": "manual",
"status": "passed",
"durationMs": 285
},
{
"id": "manual-move-into-existing-folder-merges",
"trigger": "manual",
"status": "passed",
"durationMs": 285
},
{
"id": "manual-cross-library-move-into-existing-folder-merges",
"trigger": "manual",
"status": "passed",
"durationMs": 529
},
{
"id": "manual-delete-and-recreate-same-path",
"trigger": "manual",
"status": "passed",
"durationMs": 276
},
{
"id": "manual-delete-and-recreate-folder-with-new-file",
"trigger": "manual",
"status": "passed",
"durationMs": 264
},
{
"id": "manual-book-per-file-folder-move-multi-file",
"trigger": "manual",
"status": "passed",
"durationMs": 240
},
{
"id": "manual-cross-library-move-then-reseed-source",
"trigger": "manual",
"status": "passed",
"durationMs": 508
},
{
"id": "watcher-delete-primary-file-marks-missing",
"trigger": "watcher",
"status": "passed",
"durationMs": 758
},
{
"id": "watcher-delete-folder",
"trigger": "watcher",
"status": "passed",
"durationMs": 781
},
{
"id": "watcher-rename-file-in-place",
"trigger": "watcher",
"status": "passed",
"durationMs": 776
},
{
"id": "watcher-rename-folder",
"trigger": "watcher",
"status": "passed",
"durationMs": 789
},
{
"id": "watcher-move-root-file-into-subfolder",
"trigger": "watcher",
"status": "passed",
"durationMs": 3913
},
{
"id": "watcher-move-subfolder-file-to-root",
"trigger": "watcher",
"status": "passed",
"durationMs": 3881
},
{
"id": "watcher-cross-library-file-move",
"trigger": "watcher",
"status": "passed",
"durationMs": 876
},
{
"id": "watcher-cross-library-folder-move",
"trigger": "watcher",
"status": "passed",
"durationMs": 875
},
{
"id": "watcher-case-only-file-rename",
"trigger": "watcher",
"status": "passed",
"durationMs": 774
},
{
"id": "watcher-non-content-file-creates-ignored",
"trigger": "watcher",
"status": "passed",
"durationMs": 157
},
{
"id": "watcher-non-content-file-delete-ignored",
"trigger": "watcher",
"status": "passed",
"durationMs": 148
},
{
"id": "watcher-book-per-file-delete-then-restore",
"trigger": "watcher",
"status": "passed",
"durationMs": 1689
},
{
"id": "watcher-move-into-existing-folder-merges",
"trigger": "watcher",
"status": "passed",
"durationMs": 3739
},
{
"id": "watcher-delete-and-recreate-same-path-within-debounce",
"trigger": "watcher",
"status": "passed",
"durationMs": 130
},
{
"id": "watcher-delete-and-recreate-folder-with-new-file",
"trigger": "watcher",
"status": "passed",
"durationMs": 1679
},
{
"id": "watcher-cross-library-move-then-reseed-source",
"trigger": "watcher",
"status": "passed",
"durationMs": 4882
},
{
"id": "watcher-create-then-delete-new-path-within-debounce",
"trigger": "watcher",
"status": "passed",
"durationMs": 168
},
{
"id": "watcher-book-per-file-cross-library-folder-move-multi-file",
"trigger": "watcher",
"status": "passed",
"durationMs": 922
},
{
"id": "stateful-manual-delete-primary-preserves-data",
"trigger": "manual",
"status": "passed",
"durationMs": 287
},
{
"id": "stateful-manual-cross-library-move-preserves-book-identity",
"trigger": "manual",
"status": "passed",
"durationMs": 580
},
{
"id": "stateful-watcher-cross-library-move-preserves-book-identity",
"trigger": "watcher",
"status": "passed",
"durationMs": 976
},
{
"id": "stateful-watcher-rename-folder-preserves-data",
"trigger": "watcher",
"status": "passed",
"durationMs": 871
},
{
"id": "stateful-watcher-delete-then-restore-preserves-data",
"trigger": "watcher",
"status": "passed",
"durationMs": 1502
}
]
}