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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ The WebView root component is assigned to `globalThis.webViewComponent` (not exp

Drafts are loaded and saved through [AnalysisRepository](src/types/analysis-repository.ts), the seam a later storage backend implements. [DraftRepository](src/services/draftRepository.ts) implements it over the draft envelope and its per-book shards, and rewrites only the books that changed. It imports nothing from PAPI and reaches storage only through a `KeyValueStorage`. That lets the perf harness run it in Node, straight over the storage files ([perf/storage.ts](perf/storage.ts)).

Saved projects are listed through [ProjectSummaries](src/services/projectSummaries.ts): one record holding every project's summary, rewritten on each project write so listing reads no analysis. It is PAPI-free in the same way.

### Components

Prefer Platform.Bible's `platform-bible-react` components (`Button`, `Input`, `Textarea`, `Label`, `Switch`, `RadioGroup`, `Popover`, `Tooltip`, etc.) over hand-rolled raw HTML (`<button>`, `<input>`, `<textarea>`, `<label>`, custom dropdowns) wherever an equivalent exists — this keeps the UI visually and behaviorally consistent with the host app and gives us theming and accessibility for free. Reserve raw elements for cases where the platform component can't preserve behavior the extension depends on (e.g. `field-sizing: content` gloss inputs); when you do, add a comment explaining why. When a raw element is replaced by a platform component in tests, extend `__mocks__/platform-bible-react.tsx` to stub the newly-used component.
Expand Down
7 changes: 6 additions & 1 deletion e2e-tests/tests/perf/app-perf.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,11 @@ async function measureTier(
const manifest = readManifest(tier.name);
const draftJson = readDraftJson(tier.name);
const draftWire = wireBytes(draftJson);
const wire: Record<string, number> = { draft: draftWire };
Object.assign(result, {
viewBook: tier.viewBook,
dataset: { counts: manifest.counts, bytes: manifest.bytes },
wireBytes: { draft: draftWire, projectsForSource: draftWire * tier.savedProjects },
wireBytes: wire,
});
if (draftWire > MAX_WEBSOCKET_PAYLOAD_BYTES) {
result.skipped = `The draft is ${(draftWire / 1e6).toFixed(1)} MB on the wire, over the websocket's per-message cap; the app cannot load it.`;
Expand Down Expand Up @@ -367,6 +368,10 @@ async function measureTier(
await expect(dialog).toHaveCount(0);
}
result.picker = picker.summary();
const projectsJson = await sendCommand(page, 'interlinearizer.getProjectsForSource', projectId);
if (typeof projectsJson !== 'string')
throw new Error(`getProjectsForSource returned ${typeof projectsJson}, not a JSON string`);
wire.projectsForSource = wireBytes(projectsJson);
await recordMemory('afterPicker');

// Save: writing the draft's analysis to the project it was opened from.
Expand Down
2 changes: 1 addition & 1 deletion e2e-tests/tests/perf/view-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const HOST_CALLS = {
getProjectsForSource: {
measure: 'projects-fetch',
command: 'getProjectsForSource',
phasePrefixes: ['getProjectsForSource', 'listProjects'],
phasePrefixes: ['getProjectsForSource'],
},
saveAnalysis: {
measure: 'save',
Expand Down
12 changes: 4 additions & 8 deletions perf/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@ import fs from 'fs/promises';
import type { DraftProject, InterlinearProject } from 'interlinearizer';
import path from 'path';
import { DraftRepository } from '../src/services/draftRepository';
import { ProjectSummaries, summarizeProject } from '../src/services/projectSummaries';
import { projectKey } from '../src/services/storageKeys';
import type { KeyValueStorage } from '../src/types/analysis-repository';
import { CURRENT_MODEL_VERSION } from '../src/types/model-version';
import { isStringArray } from '../src/types/type-guards';

/** Key of the index listing every saved project's id. */
const PROJECT_IDS_KEY = 'projectIds';

function isNotFound(e: unknown): boolean {
return !!e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT';
Expand Down Expand Up @@ -64,7 +61,8 @@ export async function writeDraft(dir: string, draft: DraftProject): Promise<void
* in `dir`.
*
* @returns The new project's id.
* @throws If the project index is not an array of ids, or a record cannot be read or written.
* @throws If the stored project summaries are corrupt or from a newer build, or a record cannot be
* read or written.
*/
export async function addSavedProject(
dir: string,
Expand All @@ -84,8 +82,6 @@ export async function addSavedProject(
analysis: draft.analysis,
};
await storage.write(projectKey(project.id), JSON.stringify(project));
const index: unknown = JSON.parse((await storage.read(PROJECT_IDS_KEY)) ?? '[]');
if (!isStringArray(index)) throw new Error(`${PROJECT_IDS_KEY} is not an array of ids`);
await storage.write(PROJECT_IDS_KEY, JSON.stringify([...index, project.id]));
await new ProjectSummaries(storage).add(summarizeProject(project));
return project.id;
}
29 changes: 8 additions & 21 deletions src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as hostPerf from '../services/hostPerf';
import * as projectStorage from '../services/projectStorage';
import * as pt9ImportService from '../services/pt9ImportService';
import { emptyAnalysis, emptyDraft } from '../types/empty-factories';
import { toProjectSummary } from '../types/interlinear-project-summary';
import { createTestActivationContext, makeStubProject } from './test-helpers';

jest.mock('../services/bookService');
Expand Down Expand Up @@ -1055,8 +1056,8 @@ describe('main', () => {
});

describe('interlinearizer.getProjectsForSource command', () => {
const mockGetProjectsForSource = jest.mocked(projectStorage.getProjectsForSource);
const stubProject = makeStubProject('proj-id');
const mockGetSummaries = jest.mocked(projectStorage.getProjectSummariesForSource);
const summary = toProjectSummary(makeStubProject('proj-id'));

it('registers the interlinearizer.getProjectsForSource command', async () => {
const context = createTestActivationContext();
Expand All @@ -1070,32 +1071,18 @@ describe('main', () => {
);
});

it("returns summaries of the matching projects, describing each one's analysis", async () => {
mockGetProjectsForSource.mockResolvedValue([stubProject]);
const handler = await getProjectsForSourceHandler();

const result = await handler('src-project');

expect(JSON.parse(result)).toEqual([
expect.objectContaining({
id: stubProject.id,
books: [],
tokenAnalysisCount: 0,
}),
]);
});

it('omits each project analysis, which the list never reads and would ship whole', async () => {
mockGetProjectsForSource.mockResolvedValue([stubProject]);
it("returns the stored summaries of the source's projects", async () => {
mockGetSummaries.mockResolvedValue([summary]);
const handler = await getProjectsForSourceHandler();

const result = await handler('src-project');

expect(result).not.toContain('"analysis"');
expect(mockGetSummaries).toHaveBeenCalledWith(expect.anything(), 'src-project');
expect(JSON.parse(result)).toEqual([summary]);
});

it('throws and logs an error when storage throws', async () => {
mockGetProjectsForSource.mockRejectedValue(new Error('disk full'));
mockGetSummaries.mockRejectedValue(new Error('disk full'));
const handler = await getProjectsForSourceHandler();

await expect(handler('src-project')).rejects.toThrow('disk full');
Expand Down
Loading
Loading