Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2925_keys-0EA5E9" alt="i18n 19 locales — 2925 keys">
<img src="https://img.shields.io/badge/Tests-7126%2B_%2F_582_files-22C55E" alt="7126+ tests / 582 files">
<img src="https://img.shields.io/badge/Tests-7138%2B_%2F_583_files-22C55E" alt="7138+ tests / 583 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (7126+ tests / 582 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7138+ tests / 583 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
Expand Down Expand Up @@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (7126+ tests, 582 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7138+ tests, 583 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
- **7126+ unit tests** across **582 test files** — CI is authoritative for pass/fail
- **7138+ unit tests** across **583 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)

Expand Down
24 changes: 22 additions & 2 deletions services/fs/fsCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,32 @@ export function compressData<T>(data: T): string {
return LZ_PREFIX + LZString.compressToUTF16(json);
}

// QNBS-v3: lz-string returns null (never throws) on corrupt/truncated input — silently substituting '{}' masked real corruption as a valid empty object (DA-01); throw instead so callers can fail closed.
export class DecompressionError extends Error {
constructor(message = 'Failed to decompress stored data — the payload is corrupt or truncated.') {
super(message);
this.name = 'DecompressionError';
}
}

// QNBS-v3 (Amazon Q): JSON.parse also wrapped — a bare SyntaxError would break the DecompressionError-only contract callers rely on.
export function decompressData<T>(raw: string): T {
if (raw.startsWith(LZ_PREFIX)) {
const decompressed = LZString.decompressFromUTF16(raw.slice(LZ_PREFIX.length));
return JSON.parse(decompressed ?? '{}') as T;
if (decompressed === null) {
throw new DecompressionError();
}
try {
return JSON.parse(decompressed) as T;
} catch {
throw new DecompressionError('Failed to parse decompressed data as JSON — the payload is corrupt.');
}
}
try {
return JSON.parse(raw) as T;
} catch {
throw new DecompressionError('Failed to parse stored data as JSON — the payload is corrupt.');
}
return JSON.parse(raw) as T;
}
Comment thread
qnbs marked this conversation as resolved.

// --- Crypto helpers ---
Expand Down
83 changes: 71 additions & 12 deletions services/fs/projectFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,41 @@ import {
writeTextFileAtomic,
} from './fsCore';

// QNBS-v3 (DA-01): distinguishes corrupt/unreadable saved data from genuine absence — callers must never treat this the same as "no project exists yet".
export class ProjectLoadError extends Error {
constructor(
public readonly reason: 'corrupt' | 'io-error',
message: string,
) {
super(message);
this.name = 'ProjectLoadError';
}
}

// QNBS-v3 (CodeAnt/CodeRabbit): array-or-EntityState — characters/worlds may be either shape in a real saved project.
function isArrayOrEntityState(value: unknown): boolean {
if (Array.isArray(value)) return true;
return (
typeof value === 'object' &&
value !== null &&
Array.isArray((value as Record<string, unknown>)['ids']) &&
typeof (value as Record<string, unknown>)['entities'] === 'object'
);
}

// QNBS-v3 (DA-01): rejects parsed JSON that isn't project-shaped at all (e.g. an unrelated file, or a prior empty-object substitution bug) instead of silently hydrating a near-blank project.
function looksLikeStoryProject(value: unknown): value is StoryProject {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v['title'] === 'string' &&
typeof v['logline'] === 'string' &&
Array.isArray(v['manuscript']) &&
isArrayOrEntityState(v['characters']) &&
isArrayOrEntityState(v['worlds'])
);
Comment thread
qnbs marked this conversation as resolved.
}

export class FsProjectStore extends FsAssetStore {
async saveProject(project: SaveProjectInput): Promise<void> {
const flat = normalizeSaveProjectInputToStoryProject(project);
Expand Down Expand Up @@ -83,26 +118,50 @@ export class FsProjectStore extends FsAssetStore {
}
}

/**
* Genuine absence (no saved file for this ID) resolves to `null` — legitimate and unchanged.
* A corrupt or unreadable file throws `ProjectLoadError` instead: DA-01 requires that this never
* collapse into the same `null` a caller would read as "no project exists yet".
*/
async loadProject(projectId: string): Promise<StoryProject | null> {
try {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
const safeProjectId = sanitizePathSegment(projectId);
const projectFile = await apis.join(appDataPath, 'projects', safeProjectId, 'project.json');
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
const safeProjectId = sanitizePathSegment(projectId);
const projectFile = await apis.join(appDataPath, 'projects', safeProjectId, 'project.json');

// QNBS-v3 (CodeRabbit/codex): exists() rejecting is an I/O failure too, not absence — classify it the same as a readTextFile failure rather than letting it escape raw.
let content: string;
try {
if (!(await apis.exists(projectFile))) {
return null;
}
content = await retryFs(() => apis.readTextFile(projectFile));
} catch (error) {
logger.error('Failed to read project file (I/O error):', error);
throw new ProjectLoadError(
'io-error',
`Could not read the project file for "${projectId}" — it may be locked, permission-denied, or otherwise inaccessible.`,
);
}

const content = await retryFs(() => apis.readTextFile(projectFile));
const project = decompressData<StoryProject>(content);
// QNBS-v3: schedule observation after this async load resolves so validation cannot delay or alter the load result.
scheduleCoreProjectValidation(project);
return project;
let project: StoryProject;
try {
const parsed = decompressData<unknown>(content);
if (!looksLikeStoryProject(parsed)) {
throw new Error('Parsed content is not project-shaped (missing title/manuscript).');
}
project = parsed;
} catch (error) {
logger.error('Failed to load project:', error);
return null;
logger.error('Failed to parse project file (corrupt data):', error);
throw new ProjectLoadError(
'corrupt',
`The saved project file for "${projectId}" appears to be corrupted and could not be read. The file has not been deleted.`,
);
}

// QNBS-v3: schedule observation after this async load resolves so validation cannot delay or alter the load result.
scheduleCoreProjectValidation(project);
return project;
}

async listProjects(): Promise<string[]> {
Expand Down
17 changes: 16 additions & 1 deletion services/libraryBackupService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/
import JSZip from 'jszip';
import type { Settings, StoryProject } from '../types';
import { ProjectLoadError } from './fs/projectFsStore';
import { logger } from './logger';
import type { BinderAssetPayload } from './storageBackend';
import { storageService } from './storageService';

Expand Down Expand Up @@ -125,7 +127,20 @@ export async function collectLibraryBackupPayload(
onProgress?.('list', 0, total);

for (const projectId of projectIds) {
const project = await storageService.loadProject(projectId);
// QNBS-v3 (DA-01): loadProject now throws on corrupt/unreadable data rather than returning null — one bad project must not abort the whole backup.
let project: StoryProject | null;
try {
project = await storageService.loadProject(projectId);
} catch (error) {
// QNBS-v3 (codex P1): only the expected corruption/I-O case is swallowed — an unexpected bug must still surface, not be silently absorbed as "skip this project".
if (!(error instanceof ProjectLoadError)) throw error;
logger.warn('collectLibraryBackupPayload: skipping unreadable project', {
projectId,
reason: error.reason,
error: error.message,
});
project = null;
Comment thread
qnbs marked this conversation as resolved.
}
const codex = await storageService.getStoryCodex(projectId);
const ragVectors = await storageService.getRagVectors(projectId);
const binderIds = await storageService.listBinderAssetIds(projectId);
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/libraryBackupService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,50 @@ describe('libraryBackupService zip roundtrip', () => {
expect(parsed.projects[0]?.projectId).toBe('p1');
});
});

describe('libraryBackupService — partial corruption (DA-01)', () => {
beforeEach(async () => {
vi.clearAllMocks();
const { storageService } = await import('../../services/storageService');
vi.mocked(storageService.getStorageBackendKind).mockResolvedValue('filesystem');
vi.mocked(storageService.getStoryCodex).mockResolvedValue(null);
vi.mocked(storageService.getRagVectors).mockResolvedValue([]);
vi.mocked(storageService.listBinderAssetIds).mockResolvedValue([]);
vi.mocked(storageService.loadSettings).mockResolvedValue(null);
vi.mocked(storageService.listSnapshots).mockResolvedValue([]);
});

it('does not abort the whole backup when one project is corrupt — the good project still backs up', async () => {
const { storageService } = await import('../../services/storageService');
const { ProjectLoadError } = await import('../../services/fs/projectFsStore');
vi.mocked(storageService.listProjects).mockResolvedValue(['good', 'corrupt']);
vi.mocked(storageService.loadProject).mockImplementation(async (projectId: string) => {
if (projectId === 'corrupt') {
throw new ProjectLoadError('corrupt', 'The saved project file for "corrupt" is corrupted.');
}
return minimalProject() as unknown as StoryProject;
});
const { collectLibraryBackupPayload } = await import('../../services/libraryBackupService');
const payload = await collectLibraryBackupPayload();
expect(payload.projects).toHaveLength(2);
const good = payload.projects.find((p) => p.projectId === 'good');
const corrupt = payload.projects.find((p) => p.projectId === 'corrupt');
expect(good?.project).not.toBeNull();
// QNBS-v3: the corrupt entry stays present with a null payload — the whole backup must not abort.
expect(corrupt?.project).toBeNull();
});

// QNBS-v3 (codex P1): an unexpected (non-ProjectLoadError) failure must still surface, not be silently swallowed as if it were an ordinary corrupt project.
it('rethrows an unexpected (non-ProjectLoadError) failure instead of silently swallowing it', async () => {
const { storageService } = await import('../../services/storageService');
vi.mocked(storageService.listProjects).mockResolvedValue(['ok', 'buggy']);
vi.mocked(storageService.loadProject).mockImplementation(async (projectId: string) => {
if (projectId === 'buggy') {
throw new TypeError('Cannot read properties of undefined (a genuine programming bug)');
}
return minimalProject() as unknown as StoryProject;
});
const { collectLibraryBackupPayload } = await import('../../services/libraryBackupService');
await expect(collectLibraryBackupPayload()).rejects.toThrow(TypeError);
});
});
16 changes: 14 additions & 2 deletions tests/unit/services/fs/fsCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
* sanitization, and word counting — no Tauri APIs required.
*/

import LZString from 'lz-string';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TauriApis } from '../../../../services/fs/fsCore';
import {
compressData,
countProjectWords,
decompressData,
DecompressionError,
decryptText,
encryptText,
retryFs,
Expand Down Expand Up @@ -137,8 +139,18 @@ describe('compressData / decompressData', () => {
expect(decompressData(raw)).toEqual(data);
});

it('decompresses a corrupt lz payload to an empty object', () => {
expect(decompressData('\x00lz1\x00@@not-valid@@')).toEqual({});
// QNBS-v3 (DA-01): was 'decompresses a corrupt lz payload to an empty object' — must fail closed instead.
it('throws DecompressionError on a corrupt/truncated lz payload instead of substituting {}', () => {
expect(() => decompressData('\x00lz1\x00@@not-valid@@')).toThrow(DecompressionError);
});

it('throws DecompressionError (not a bare SyntaxError) when decompression succeeds but the result is not valid JSON', () => {
const raw = `\x00lz1\x00${LZString.compressToUTF16('this is not valid json {{{')}`;
expect(() => decompressData(raw)).toThrow(DecompressionError);
});

it('throws DecompressionError (not a bare SyntaxError) for malformed uncompressed JSON', () => {
expect(() => decompressData('this is not json {{{')).toThrow(DecompressionError);
});
});

Expand Down
Loading
Loading