diff --git a/desktop/src/components/FormFinalizeDialog.tsx b/desktop/src/components/FormFinalizeDialog.tsx index aac47aca9..45fc5e88f 100644 --- a/desktop/src/components/FormFinalizeDialog.tsx +++ b/desktop/src/components/FormFinalizeDialog.tsx @@ -4,6 +4,8 @@ import { tauriClient } from '../lib/tauriClient'; import type { FinalizeRequest } from '../lib/formPreviewBridge'; import type { ObservationExtras } from '../types/domain'; +import { buildPreviewObservationSave } from '../lib/formPreviewObservation'; + const TESTDATA_TAG = 'Testdata'; export type FormFinalizeDialogProps = { @@ -46,11 +48,16 @@ export function FormFinalizeDialog({ setBusy(false); return; } - const id = - request.kind === 'update' ? request.observationId : crypto.randomUUID(); + const existing = + request.kind === 'update' + ? await tauriClient.getObservation(request.observationId) + : undefined; + const record = buildPreviewObservationSave(request, existing); + const id = record.id; const envelope = { observationId: id, formType: request.formType, + formVersion: record.extras?.formVersion, data: request.finalData, }; await tauriClient.writeTextFile(path, JSON.stringify(envelope, null, 2)); @@ -89,29 +96,14 @@ export function FormFinalizeDialog({ setBusy(true); setError(null); try { - if (request.kind === 'submit') { - const id = crypto.randomUUID(); - const extras = await buildExtrasWithTags(undefined); - await tauriClient.saveObservation({ - id, - payload: request.finalData, - formType: request.formType, - extras, - }); - onComplete({ result: id }); - } else { - const existing = await tauriClient.getObservation( - request.observationId, - ); - const extras = await buildExtrasWithTags(existing.extras); - await tauriClient.saveObservation({ - id: request.observationId, - payload: request.finalData, - formType: request.formType, - extras, - }); - onComplete({ result: request.observationId }); - } + const existing = + request.kind === 'update' + ? await tauriClient.getObservation(request.observationId) + : undefined; + const record = buildPreviewObservationSave(request, existing); + const extras = await buildExtrasWithTags(record.extras); + await tauriClient.saveObservation({ ...record, extras }); + onComplete({ result: record.id }); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { diff --git a/desktop/src/lib/__tests__/formPreviewBridge.test.ts b/desktop/src/lib/__tests__/formPreviewBridge.test.ts index 47c63fb38..db11c7992 100644 --- a/desktop/src/lib/__tests__/formPreviewBridge.test.ts +++ b/desktop/src/lib/__tests__/formPreviewBridge.test.ts @@ -51,6 +51,12 @@ vi.mock('../tauriClient', () => ({ .fn() .mockResolvedValue('file:///tmp/ws/attachments/synced/'), workspaceAttachmentFileUrl: vi.fn().mockResolvedValue(null), + readBundleFormSpec: vi + .fn() + .mockResolvedValue({ formType: 'demo', formSchema: {}, uiSchema: {} }), + getObservation: vi + .fn() + .mockResolvedValue({ extras: { formVersion: '7', author: 'Alice' } }), saveObservation: vi.fn().mockResolvedValue({ id: 'saved-1' }), }, })); @@ -226,6 +232,8 @@ describe('handleFormPreviewBridgeMessage', () => { }); it('completes nested finalize without calling onFinalize when hook returns payload', async () => { + vi.mocked(tauriClient.saveObservation).mockClear(); + vi.mocked(tauriClient.readBundleFormSpec).mockClear(); const postNested = vi.fn(); const nestedCw = { postMessage: postNested } as unknown as Window; const nestedIframe = { @@ -254,6 +262,8 @@ describe('handleFormPreviewBridgeMessage', () => { expect(tryNested).toHaveBeenCalled(); expect(onFinalize).not.toHaveBeenCalled(); + expect(tauriClient.saveObservation).not.toHaveBeenCalled(); + expect(tauriClient.readBundleFormSpec).not.toHaveBeenCalled(); expect(postNested).toHaveBeenCalledTimes(1); const payload = JSON.parse(postNested.mock.calls[0][0] as string); expect(payload.type).toBe('submitObservation_response'); @@ -620,6 +630,7 @@ describe('postFormplayerBridgeReply', () => { expect(tauriClient.saveObservation).toHaveBeenCalledWith({ id: 'existing-id-42', + extras: { formVersion: '7', author: 'Alice' }, formType: 'inclusion_decision', payload: finalData, }); @@ -676,3 +687,98 @@ describe('postFormplayerBridgeReply', () => { expect(payload.messageId).toBe('msg-parent'); }); }); + +describe('headless host version provenance', () => { + it.each([ + [{ version: '3' }, '3'], + [{ schemaVersion: '2', version: '3' }, '2'], + [{}, '1.0'], + [null, '1.0'], + ])('resolves installed schema %j', async (schema, expected) => { + vi.mocked(tauriClient.readBundleFormSpec).mockReset(); + if (schema === null) + vi.mocked(tauriClient.readBundleFormSpec).mockRejectedValueOnce( + new Error('missing'), + ); + else + vi.mocked(tauriClient.readBundleFormSpec).mockResolvedValueOnce({ + formType: 'demo', + formSchema: schema, + uiSchema: {}, + }); + const iframe = { + contentWindow: { postMessage: vi.fn() }, + } as unknown as HTMLIFrameElement; + await handleFormPreviewBridgeMessage( + bridgeMessageFromIframe(iframe, { + type: 'persistObservation', + messageId: 'create-version', + input: { formType: 'demo', finalData: {}, formVersion: 'untrusted' }, + }), + { iframe, onFinalize: async () => ({}) }, + ); + expect(tauriClient.saveObservation).toHaveBeenLastCalledWith( + expect.objectContaining({ extras: { formVersion: expected } }), + ); + }); + + it('uses a matching captured session without reading the bundle', async () => { + vi.mocked(tauriClient.readBundleFormSpec).mockClear(); + const iframe = { + contentWindow: { postMessage: vi.fn() }, + } as unknown as HTMLIFrameElement; + await handleFormPreviewBridgeMessage( + bridgeMessageFromIframe(iframe, { + type: 'persistObservation', + messageId: 'session-version', + input: { formType: 'demo', finalData: {}, formVersion: 'untrusted' }, + }), + { + iframe, + onFinalize: async () => ({}), + getSessionFormVersion: formType => + formType === 'demo' ? '2' : undefined, + }, + ); + expect(tauriClient.readBundleFormSpec).not.toHaveBeenCalled(); + expect(tauriClient.saveObservation).toHaveBeenLastCalledWith( + expect.objectContaining({ extras: { formVersion: '2' } }), + ); + }); + + it('preserves all update extras and never resolves a version', async () => { + const extras = { + formVersion: '7', + author: 'Alice', + tags: ['keep'], + deleted: false, + geolocation: { latitude: 1 }, + }; + vi.mocked(tauriClient.getObservation).mockResolvedValueOnce({ + extras, + } as never); + vi.mocked(tauriClient.readBundleFormSpec).mockClear(); + const getSessionFormVersion = vi.fn(); + const iframe = { + contentWindow: { postMessage: vi.fn() }, + } as unknown as HTMLIFrameElement; + await handleFormPreviewBridgeMessage( + bridgeMessageFromIframe(iframe, { + type: 'persistObservation', + messageId: 'edit-version', + input: { + formType: 'demo', + observationId: 'old', + finalData: {}, + formVersion: 'untrusted', + }, + }), + { iframe, onFinalize: async () => ({}), getSessionFormVersion }, + ); + expect(tauriClient.saveObservation).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'old', extras }), + ); + expect(getSessionFormVersion).not.toHaveBeenCalled(); + expect(tauriClient.readBundleFormSpec).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/src/lib/formPreviewBridge.ts b/desktop/src/lib/formPreviewBridge.ts index 950960f06..d6ac22f0d 100644 --- a/desktop/src/lib/formPreviewBridge.ts +++ b/desktop/src/lib/formPreviewBridge.ts @@ -31,6 +31,8 @@ import { convertFileSrc } from '@tauri-apps/api/core'; import { dirname, join } from '@tauri-apps/api/path'; import { tauriClient } from './tauriClient'; +import { resolveFormSchemaVersion } from './formSchemaVersion'; +import { buildPreviewObservationSave } from './formPreviewObservation'; import type { ObservationRecord } from '../types/domain'; /** Preview-local sequence counters (device id stub + app scope suffix). */ @@ -95,7 +97,12 @@ export const FORMULUS_INJECTION_REQUEST_TYPES = [ ] as const; export type FinalizeRequest = - | { kind: 'submit'; formType: string; finalData: Record } + | { + kind: 'submit'; + formType: string; + finalData: Record; + hostFormVersion?: string; + } | { kind: 'update'; observationId: string; @@ -118,6 +125,8 @@ export type FormPreviewDeferOpenSubObservationPayload = { export type FormPreviewBridgeContext = { iframe: HTMLIFrameElement | null; + /** Host-only snapshot for the currently opened preview; never read from WebView data. */ + getSessionFormVersion?: (formType: string) => string | undefined; /** * Maps `postMessage` event source to the iframe that should receive `*_response`. * When omitted, replies go to {@link iframe} (legacy single-embed hosts). @@ -154,6 +163,21 @@ export type FormPreviewBridgeContext = { ) => Promise<{ result?: string; error?: string } | null>; }; +async function resolveHostFormVersion( + formType: string, + ctx: FormPreviewBridgeContext, +): Promise { + const captured = ctx.getSessionFormVersion?.(formType); + if (captured !== undefined) return captured; + try { + const spec = await tauriClient.readBundleFormSpec(formType); + return resolveFormSchemaVersion(spec.formSchema); + } catch { + // Headless calls may refer to a form that is not installed locally. + return '1.0'; + } +} + function resolveBridgeReplyIframe( eventSource: Window | null | undefined, ctx: FormPreviewBridgeContext, @@ -543,6 +567,8 @@ export async function handleFormPreviewBridgeMessage( return; } } + if (req.kind === 'submit') + req.hostFormVersion = await resolveHostFormVersion(formType, ctx); const res = await ctx.onFinalize(req); reply('submitObservation', res); return; @@ -806,12 +832,25 @@ export async function handleFormPreviewBridgeMessage( typeof input.observationId === 'string' ? input.observationId.trim() : ''; - const id = existingId || crypto.randomUUID(); - await tauriClient.saveObservation({ - id, - formType, - payload: input.finalData, - }); + const request: FinalizeRequest = existingId + ? { + kind: 'update', + observationId: existingId, + formType, + finalData: input.finalData, + } + : { + kind: 'submit', + formType, + finalData: input.finalData, + hostFormVersion: await resolveHostFormVersion(formType, ctx), + }; + const existing = existingId + ? await tauriClient.getObservation(existingId) + : undefined; + const saveRequest = buildPreviewObservationSave(request, existing); + const id = saveRequest.id; + await tauriClient.saveObservation(saveRequest); reply('persistObservation', { result: { observationId: id, formData: input.finalData }, }); diff --git a/desktop/src/lib/formPreviewObservation.ts b/desktop/src/lib/formPreviewObservation.ts new file mode 100644 index 000000000..55e51574a --- /dev/null +++ b/desktop/src/lib/formPreviewObservation.ts @@ -0,0 +1,24 @@ +import type { FinalizeRequest } from './formPreviewBridge'; +import type { + ObservationRecord, + SaveObservationRequest, +} from '../types/domain'; + +/** Host-only request construction; edits retain the stored envelope metadata. */ +export function buildPreviewObservationSave( + request: FinalizeRequest, + existing?: ObservationRecord, +): SaveObservationRequest { + if (request.kind === 'update' && !existing) { + throw new Error('Existing observation is required for preview updates'); + } + return { + id: request.kind === 'update' ? request.observationId : crypto.randomUUID(), + formType: request.formType, + payload: request.finalData, + extras: + request.kind === 'update' + ? existing!.extras + : { formVersion: request.hostFormVersion ?? '1.0' }, + }; +} diff --git a/desktop/src/lib/formSchemaVersion.test.ts b/desktop/src/lib/formSchemaVersion.test.ts new file mode 100644 index 000000000..c6db3bb21 --- /dev/null +++ b/desktop/src/lib/formSchemaVersion.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest'; +import { resolveFormSchemaVersion } from './formSchemaVersion'; + +const cases: [unknown, string][] = [ + [{ schemaVersion: '2' }, '2'], + [{ version: '3' }, '3'], + [{ schemaVersion: '2', version: '3' }, '2'], + [{}, '1.0'], + [undefined, '1.0'], + [null, '1.0'], + [false, '1.0'], + [{ schemaVersion: '', version: '3' }, '3'], + [{ schemaVersion: ' \t\n', version: '3' }, '3'], + ...[null, 2, false, {}, []].map<[unknown, string]>(schemaVersion => [ + { schemaVersion, version: '3' }, + '3', + ]), + ...['', ' \t\n', null, 2, false, {}, []].map<[unknown, string]>(version => [ + { version }, + '1.0', + ]), + [{ schemaVersion: ' ', version: 2 }, '1.0'], + [{ schemaVersion: ' 2 ' }, ' 2 '], + [{ version: ' 3 ' }, ' 3 '], + [{ properties: { schemaVersion: '2', version: '3' } }, '1.0'], +]; + +it.each(cases)('resolves root metadata %j to %s', (schema, expected) => { + expect(resolveFormSchemaVersion(schema)).toBe(expected); +}); diff --git a/desktop/src/lib/formSchemaVersion.ts b/desktop/src/lib/formSchemaVersion.ts new file mode 100644 index 000000000..ce267ca7e --- /dev/null +++ b/desktop/src/lib/formSchemaVersion.ts @@ -0,0 +1,12 @@ +/** Resolve root form metadata without changing valid authored version strings. */ +export function resolveFormSchemaVersion(schema: unknown): string { + if (schema && typeof schema === 'object') { + const { schemaVersion, version } = schema as Record; + for (const candidate of [schemaVersion, version]) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + } + return '1.0'; +} diff --git a/desktop/src/pages/FormPreviewPage.test.tsx b/desktop/src/pages/FormPreviewPage.test.tsx new file mode 100644 index 000000000..25acc5ae4 --- /dev/null +++ b/desktop/src/pages/FormPreviewPage.test.tsx @@ -0,0 +1,352 @@ +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + cleanup, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { FormPreviewPage } from './FormPreviewPage'; +import { tauriClient } from '../lib/tauriClient'; +import { save } from '@tauri-apps/plugin-dialog'; +import type { FormplayerEmbedHandle } from '../components/FormplayerEmbed'; +import type { FormInitData } from '../lib/formplayerHost'; + +vi.mock('../lib/tauriClient', () => ({ + tauriClient: { + listActiveBundleForms: vi.fn(async () => [{ formType: 'demo' }]), + readBundleFormSpec: vi.fn(), + saveObservation: vi.fn(async () => ({})), + getObservation: vi.fn(), + writeTextFile: vi.fn(), + }, +})); +vi.mock('@tauri-apps/plugin-dialog', () => ({ + save: vi.fn(async () => '/tmp/export.json'), +})); +vi.mock('../hooks/useDeveloperMode', () => ({ + useDeveloperMode: () => ({ developerMode: false, devMirrorGeneration: 0 }), +})); +vi.mock('../lib/bundleExtensionLoader', () => ({ + loadBundleFormplayerExtensions: async () => ({}), +})); +vi.mock('../lib/formLocale', () => ({ + FORM_LOCALE_DEFAULT: 'default', + getDesktopFormLocalePreference: () => 'default', + scanActiveBundleFormLocales: async () => [], + setDesktopFormLocalePreference: vi.fn(), + resolveDesktopFormLocale: () => 'default', +})); +vi.mock('../components/FormPreviewAdvancedParamsDialog', () => ({ + FormPreviewAdvancedParamsDialog: () => null, +})); +vi.mock('../components/CustomAppDeviceViewport', () => ({ + CustomAppDeviceViewport: ({ + toolbarStart, + children, + }: { + toolbarStart: React.ReactNode; + children: (args: { + fillFrame: boolean; + devicePixelRatio: number; + }) => React.ReactNode; + }) => ( + <> + {toolbarStart} + {children({ fillFrame: true, devicePixelRatio: 1 })} + + ), +})); +vi.mock('../components/FormplayerEmbed', () => ({ + FormplayerEmbed: forwardRef< + FormplayerEmbedHandle, + { formInitData: FormInitData | null } + >((props, ref) => { + const iframe = useRef(null); + useImperativeHandle(ref, () => ({ + getIframe: () => iframe.current, + flushDraftAsync: async () => {}, + deliverBridgeResponse: () => {}, + notifyReceiveFocus: () => {}, + })); + return ( +