From 7d9d1d2a6a53b83e5d8318dfd8b62390ce7618e4 Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Wed, 11 Nov 2026 18:46:57 +0300 Subject: [PATCH 1/4] fix(formulus): persist host-resolved form session version --- .../src/components/FormplayerModal.test.tsx | 205 ++++++++++++++++++ formulus/src/components/FormplayerModal.tsx | 4 + .../__tests__/WatermelonDBRepo.test.ts | 4 +- formulus/src/services/FormService.ts | 2 +- .../services/__tests__/FormService.test.ts | 17 ++ .../__tests__/attachmentStorage.test.ts | 21 +- formulus/src/services/attachmentStorage.ts | 8 +- .../src/webview/FormulusMessageHandlers.ts | 6 + .../webview/__tests__/profileBridge.test.ts | 51 +++++ 9 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 formulus/src/components/FormplayerModal.test.tsx diff --git a/formulus/src/components/FormplayerModal.test.tsx b/formulus/src/components/FormplayerModal.test.tsx new file mode 100644 index 000000000..7c401476e --- /dev/null +++ b/formulus/src/components/FormplayerModal.test.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { act, render } from '@testing-library/react-native'; +import FormplayerModal, { FormplayerModalHandle } from './FormplayerModal'; +import { FormSpec } from '../services/FormService'; +import { createFormulusMessageHandlers } from '../webview/FormulusMessageHandlers'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { getItem: jest.fn(), setItem: jest.fn() }, +})); +jest.mock('../diagnostics/logger', () => ({ + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn() }, + persistWebViewConsole: jest.fn(), + webViewTag: jest.fn(), +})); +jest.mock('react-native', () => { + const native = jest.requireActual('react-native'); + const primitives = new Set([ + 'Text', + 'View', + 'TouchableOpacity', + 'Modal', + 'ActivityIndicator', + ]); + return new Proxy(native, { + get(target, key) { + return primitives.has(String(key)) + ? String(key) + : Reflect.get(target, key); + }, + }); +}); +jest.mock('../profiles/ProfileActivity', () => ({ + profileActivity: jest + .requireActual('../services/testUtils/profileMocks') + .createProfileActivityMock(), +})); +jest.mock('../profiles/ProfileRuntime', () => ({ + getActiveProfile: () => ({ id: 'test' }), +})); +jest.mock('../profiles/ProfilePaths', () => + jest + .requireActual('../services/testUtils/profileMocks') + .createProfilePathsMock('/test'), +); +jest.mock('../profiles/ProfileStorage', () => ({ + __esModule: true, + default: { getItem: jest.fn(), setItem: jest.fn() }, +})); +jest.mock('../database', () => ({ + databaseService: { + getLocalRepo: () => ({ + saveObservation: mockSave, + updateObservation: mockUpdate, + }), + }, +})); +jest.mock('../database/DatabaseService', () => ({ + databaseService: { + getLocalRepo: () => ({ + saveObservation: mockSave, + updateObservation: mockUpdate, + }), + }, +})); +jest.mock('../services', () => ({ FormService: { getInstance: jest.fn() } })); +jest.mock('../services/FormService', () => ({ + FormService: { getInstance: jest.fn() }, +})); +jest.mock('../services/ExtensionService', () => ({ + ExtensionService: { + getInstance: () => ({ + getCustomAppExtensions: async () => ({ functions: {}, renderers: {} }), + }), + }, +})); +jest.mock('../services/GeolocationService', () => ({ + geolocationService: { + beginObservationSession: jest.fn(), + endObservationSession: jest.fn(), + }, + GeolocationService: { getInstance: () => ({}) }, +})); +jest.mock('../services/SyncService', () => ({ SyncService: {} })); +jest.mock('../services/ServerConfigService', () => ({ + ServerConfigService: {}, +})); +jest.mock('../services/SequenceCounterService', () => ({ + sequenceCounterService: {}, +})); +jest.mock('../services/QrcodeRequestCoordinator', () => ({ + qrcodeRequestCoordinator: {}, +})); +jest.mock('../api/synkronus/Auth', () => ({ getUserInfo: jest.fn() })); +jest.mock('../services/LocaleSettingsService', () => ({ + localeSettingsService: { resolveActiveLocale: async () => 'en' }, +})); +jest.mock('../services/FormLocaleSettingsService', () => ({ + formLocaleSettingsService: { resolveActiveFormLocale: async () => 'default' }, +})); +jest.mock('../contexts/AppThemeContext', () => ({ + useAppTheme: () => ({ themeColors: {}, resolvedMode: 'light' }), +})); +jest.mock('../contexts/ConfirmModalContext', () => ({ + useConfirmModal: () => ({ showConfirm: mockConfirm }), +})); +jest.mock('../hooks/useScreenShellStyle', () => ({ + useScreenShellStyle: () => ({}), +})); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +jest.mock('@react-native-vector-icons/material-icons', () => 'Icon'); +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView', +})); +jest.mock('react-native-permissions', () => ({ PERMISSIONS: {}, RESULTS: {} })); +jest.mock('react-native-image-picker', () => ({})); +jest.mock('@react-native-documents/picker', () => ({})); +jest.mock('react-native-webview', () => ({})); +jest.mock('react-native-fs', () => ({ + exists: jest.fn(async () => false), + mkdir: jest.fn(), + MainBundlePath: '/app', + DocumentDirectoryPath: '/docs', +})); +jest.mock('./CustomAppWebView', () => { + const react = jest.requireActual('react'); + return { + __esModule: true, + default: react.forwardRef((_props: unknown, ref) => { + react.useImperativeHandle(ref, () => ({ sendFormInit: mockInit })); + return null; + }), + }; +}); + +const mockSave = jest.fn(async () => 'new-id'); +const mockUpdate = jest.fn(async () => true); +const mockConfirm = jest.fn(); +const mockInit = jest.fn(async () => {}); + +beforeEach(() => jest.clearAllMocks()); + +test.each([false, true])( + 'real modal -> bridge -> attachment persistence captures opening version (edit=%s)', + async edit => { + const ref = React.createRef(); + const screen = render( + , + ); + const spec: FormSpec = { + id: 'person', + name: 'Person', + description: '', + schemaVersion: '2', + schema: { type: 'object' }, + uiSchema: {}, + }; + await act(async () => { + await ref.current!.initializeForm( + spec, + null, + edit ? 'existing' : null, + null, + null, + ); + }); + expect(mockInit).toHaveBeenCalledWith( + expect.objectContaining({ formType: 'person' }), + ); + // Mutating the available spec must not change the opened session's snapshot. + spec.schemaVersion = '3'; + await act(async () => { + const handlers = createFormulusMessageHandlers(); + if (edit) + await handlers.onUpdateObservation({ + formType: 'person', + observationId: 'existing', + finalData: { name: 'Edited' }, + }); + else + await handlers.onSubmitObservation({ + formType: 'person', + finalData: { name: 'New' }, + formVersion: 'untrusted', + } as never); + }); + if (edit) { + expect(mockUpdate).toHaveBeenCalledWith({ + observationId: 'existing', + data: { name: 'Edited' }, + }); + expect(mockSave).not.toHaveBeenCalled(); + } else { + expect(mockSave).toHaveBeenCalledWith({ + formType: 'person', + formVersion: '2', + data: { name: 'New' }, + }); + expect(mockUpdate).not.toHaveBeenCalled(); + } + screen.unmount(); + }, +); diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index ef407fc9d..1f71568be 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -111,6 +111,8 @@ export interface FormplayerModalHandle { const FormplayerModal = forwardRef( ({ visible, isActive = true, onClose }, ref) => { const webViewRef = useRef(null); + // Snapshot the opening spec; bundle/cache refreshes must not change this session. + const sessionFormVersionRef = useRef('1.0'); const [profileId] = useState(() => getActiveProfile().id); const flushSequence = useRef(0); const pendingFlushes = useRef( @@ -368,6 +370,7 @@ const FormplayerModal = forwardRef( ); } + sessionFormVersionRef.current = formType.schemaVersion; subObservationModeRef.current = subObservationMode; skipFinalizeRef.current = skipFinalize; @@ -690,6 +693,7 @@ const FormplayerModal = forwardRef( const persistResult = await persistObservationWithAttachments( { formType, + formVersion: sessionFormVersionRef.current, finalData, observationId: effectiveObservationId, subObservationMode, diff --git a/formulus/src/database/repositories/__tests__/WatermelonDBRepo.test.ts b/formulus/src/database/repositories/__tests__/WatermelonDBRepo.test.ts index 0ec59d00f..308e0d1ce 100644 --- a/formulus/src/database/repositories/__tests__/WatermelonDBRepo.test.ts +++ b/formulus/src/database/repositories/__tests__/WatermelonDBRepo.test.ts @@ -320,7 +320,7 @@ describe('WatermelonDBRepo', () => { // Arrange const testObservation: Partial = { formType: 'test-form', - formVersion: '1.0', + formVersion: 'authored-7.2', data: { field1: 'original' }, deleted: false, }; @@ -363,6 +363,8 @@ describe('WatermelonDBRepo', () => { : updatedObservation.data; expect(parsedData.field1).toBe('updated'); + expect(originalObservation?.formVersion).toBe('authored-7.2'); + expect(updatedObservation.formVersion).toBe('authored-7.2'); } // Verify we can find the updated record by its observation_id diff --git a/formulus/src/services/FormService.ts b/formulus/src/services/FormService.ts index 0dacafca6..e0d3faa1a 100644 --- a/formulus/src/services/FormService.ts +++ b/formulus/src/services/FormService.ts @@ -454,7 +454,7 @@ export class FormService { const input: NewObservationInput = { formType, data, - formVersion: '1.0', // Default version + formVersion: this.getFormSpecById(formType)?.schemaVersion ?? '1.0', }; if (input.formType === undefined) { diff --git a/formulus/src/services/__tests__/FormService.test.ts b/formulus/src/services/__tests__/FormService.test.ts index db7671ed7..8642314a4 100644 --- a/formulus/src/services/__tests__/FormService.test.ts +++ b/formulus/src/services/__tests__/FormService.test.ts @@ -230,6 +230,23 @@ describe('FormService', () => { ); }); + describe('direct observation creation', () => { + test.each([ + ['person', '7.2'], + ['unknown', '1.0'], + ])('uses the host version for %s', async (formType, expected) => { + setupFormSpecsFromFsMocks({ type: 'object', schemaVersion: '7.2' }); + await formServiceInstance.invalidateCache(); + mockSaveObservation.mockResolvedValue('new-id'); + await formServiceInstance.addNewObservation(formType, { name: 'Test' }); + expect(mockSaveObservation).toHaveBeenCalledWith({ + formType, + formVersion: expected, + data: { name: 'Test' }, + }); + }); + }); + describe('getFormSpecById', () => { test('should return the correct form type for a valid ID', () => { formServiceInstance.addFormSpec({ diff --git a/formulus/src/services/__tests__/attachmentStorage.test.ts b/formulus/src/services/__tests__/attachmentStorage.test.ts index 95fc95495..e9cac33ec 100644 --- a/formulus/src/services/__tests__/attachmentStorage.test.ts +++ b/formulus/src/services/__tests__/attachmentStorage.test.ts @@ -202,7 +202,11 @@ describe('commitDraftAttachmentsAfterSave', () => { }); describe('persistObservationWithAttachments (FormplayerModal submit contract)', () => { - type SaveArgs = { formType: string; data: Record }; + type SaveArgs = { + formType: string; + formVersion: string; + data: Record; + }; type UpdateArgs = { observationId: string; data: Record; @@ -236,6 +240,7 @@ describe('persistObservationWithAttachments (FormplayerModal submit contract)', it('calls commitDraftAttachmentsAfterSave and saveObservation for a new observation', async () => { const input = { formType: 'person', + formVersion: '2.7', finalData: { photo: `${GUID_A}.jpg` }, observationId: null, subObservationMode: false, @@ -251,6 +256,7 @@ describe('persistObservationWithAttachments (FormplayerModal submit contract)', expect(commit).toHaveBeenCalledWith(input.finalData); expect(saveObservation).toHaveBeenCalledWith({ formType: 'person', + formVersion: '2.7', data: { photo: `${GUID_A}.jpg`, _committed: true }, }); expect(updateObservation).not.toHaveBeenCalled(); @@ -264,6 +270,7 @@ describe('persistObservationWithAttachments (FormplayerModal submit contract)', it('calls commitDraftAttachmentsAfterSave and updateObservation when updating an existing observation', async () => { const input = { formType: 'person', + formVersion: 'new-session-version', finalData: { photo: `${GUID_A}.jpg` }, observationId: 'existing-id', subObservationMode: false, @@ -305,6 +312,18 @@ describe('persistObservationWithAttachments (FormplayerModal submit contract)', expect(result.formData).toBe(input.finalData); }); + it('uses 1.0 when no host version is supplied', async () => { + await persistObservationWithAttachments( + { formType: 'person', finalData: {}, subObservationMode: false }, + { saveObservation, updateObservation, commitDraftAttachments: commit }, + ); + expect(saveObservation).toHaveBeenCalledWith({ + formType: 'person', + formVersion: '1.0', + data: { _committed: true }, + }); + }); + it('throws if saveObservation returns null (e.g. DB error)', async () => { saveObservation.mockResolvedValueOnce(null); await expect( diff --git a/formulus/src/services/attachmentStorage.ts b/formulus/src/services/attachmentStorage.ts index 9ad2902e6..0078e471c 100644 --- a/formulus/src/services/attachmentStorage.ts +++ b/formulus/src/services/attachmentStorage.ts @@ -173,6 +173,7 @@ export async function sweepStaleDraftAttachments( export interface ObservationPersistDeps { saveObservation: (args: { formType: string; + formVersion: string; data: Record; }) => Promise; updateObservation: (args: { @@ -184,6 +185,7 @@ export interface ObservationPersistDeps { export interface PersistObservationInput { formType: string; + formVersion?: string; finalData: Record; observationId?: string | null; subObservationMode: boolean; @@ -229,7 +231,11 @@ export async function persistObservationWithAttachments( return { observationId, formData: committedData }; } - const newId = await deps.saveObservation({ formType, data: committedData }); + const newId = await deps.saveObservation({ + formType, + formVersion: input.formVersion ?? '1.0', + data: committedData, + }); if (!newId) { throw new Error('Failed to save new observation'); } diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index 7e1de436b..c3a3b798b 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -1304,11 +1304,17 @@ export function createFormulusMessageHandlers() { ); } + const formVersion = input.observationId + ? undefined + : ((await FormService.getInstance()).getFormSpecById(input.formType) + ?.schemaVersion ?? '1.0'); + // Reuse the exact Formplayer submit persistence path (attachment promotion // included). Headless writes are never sub-observations. return persistObservationWithAttachments( { formType: input.formType, + formVersion, finalData: input.finalData, observationId: input.observationId ?? null, subObservationMode: false, diff --git a/formulus/src/webview/__tests__/profileBridge.test.ts b/formulus/src/webview/__tests__/profileBridge.test.ts index 10380feb2..d0905332a 100644 --- a/formulus/src/webview/__tests__/profileBridge.test.ts +++ b/formulus/src/webview/__tests__/profileBridge.test.ts @@ -269,3 +269,54 @@ test('manager reset rejects queued sends and clears delayed ready callbacks', as expect(ref.current.injectJavaScript).not.toHaveBeenCalled(); jest.useRealTimers(); }); + +describe('headless host-resolved form version', () => { + const { FormService } = require('../../services/FormService'); + const { databaseService } = require('../../database/DatabaseService'); + const { + persistObservationWithAttachments, + } = require('../../services/attachmentStorage'); + + test.each([ + [{ schemaVersion: '4.2' }, '4.2'], + [undefined, '1.0'], + ])('resolves create version from host spec %p', async (spec, expected) => { + const getFormSpecById = jest.fn(() => spec); + FormService.getInstance.mockResolvedValue({ getFormSpecById }); + databaseService.getLocalRepo.mockReturnValue({ + saveObservation: jest.fn(), + updateObservation: jest.fn(), + }); + await createFormulusMessageHandlers().onPersistObservation({ + input: { formType: 'person', finalData: {}, formVersion: 'untrusted' }, + } as never); + expect(getFormSpecById).toHaveBeenCalledWith('person'); + expect(persistObservationWithAttachments).toHaveBeenCalledWith( + expect.objectContaining({ formVersion: expected }), + expect.anything(), + ); + }); + + test('updates skip version resolution and ignore incoming version', async () => { + databaseService.getLocalRepo.mockReturnValue({ + saveObservation: jest.fn(), + updateObservation: jest.fn(), + }); + await createFormulusMessageHandlers().onPersistObservation({ + input: { + formType: 'person', + observationId: 'existing', + finalData: {}, + formVersion: 'untrusted', + }, + } as never); + expect(FormService.getInstance).not.toHaveBeenCalled(); + expect(persistObservationWithAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + observationId: 'existing', + formVersion: undefined, + }), + expect.anything(), + ); + }); +}); From 6bb3825325a8bcb163d0c4cd3a856dc0383f5b17 Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Wed, 11 Nov 2026 18:54:52 +0300 Subject: [PATCH 2/4] fix(formplayer): align draft and sticky schema versions --- formulus-formplayer/src/App.tsx | 11 ++- .../DraftService.profileStorage.test.ts | 76 +++++++++++++++++++ .../src/services/DraftService.ts | 3 +- .../StickyService.schemaVersion.test.ts | 40 ++++++++++ .../src/utils/formSchemaVersion.test.ts | 25 ++++++ .../src/utils/formSchemaVersion.ts | 12 +++ 6 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 formulus-formplayer/src/services/StickyService.schemaVersion.test.ts create mode 100644 formulus-formplayer/src/utils/formSchemaVersion.test.ts create mode 100644 formulus-formplayer/src/utils/formSchemaVersion.ts diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 2119f62a9..bdff7e82b 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -50,6 +50,7 @@ import { applyStickyDefaults, } from './utils/stickyFieldHelpers'; import { stickyService } from './services/StickyService'; +import { resolveFormSchemaVersion } from './utils/formSchemaVersion'; import { applyFormUiTranslations } from './i18n/applyFormUiTranslations'; import { resolveEffectiveFormLocale, @@ -803,8 +804,7 @@ function App() { ), ); } else if (!isSubObservationSession(initData)) { - const formVersion = (formSchemaTyped as { version?: string }) - ?.version; + const formVersion = resolveFormSchemaVersion(formSchemaTyped); const layoutRoot = ensureSwipeLayoutRoot(uiSchema as FormUISchema); const stickyPaths = collectStickyFieldPaths(layoutRoot); const stored = stickyService.getStickyValues( @@ -925,7 +925,7 @@ function App() { ) { const availableDrafts = draftService.getDraftsForForm( receivedFormType, - (formSchema as any)?.version, + resolveFormSchemaVersion(formSchema), ); if (availableDrafts.length > 0) { console.log( @@ -1541,8 +1541,7 @@ function App() { } // Persist sticky field values for next new observation of this form. if (!isSubObservationSession(payloadFormInit) && uischema) { - const formVersion = (schema as { version?: string } | null) - ?.version; + const formVersion = resolveFormSchemaVersion(schema); const stickyPaths = collectStickyFieldPaths(uischema); const stickyValues = extractStickyValues(payloadData, stickyPaths); if (Object.keys(stickyValues).length > 0) { @@ -1673,7 +1672,7 @@ function App() { { + const { draft } = await services('profile-a'); + const id = draft.saveDraft( + 'household', + {}, + { + formType: 'household', + observationId: null, + params: {}, + savedData: {}, + formSchema, + }, + 'session-a', + ); + expect(draft.getDraft(id)?.formVersion).toBe(expected); + }, +); + +it('keeps legacy unversioned drafts discoverable without relabeling on update', async () => { + const { draft } = await services('profile-a'); + const { formplayerStorage } = await import('./ProfileStorage'); + const now = new Date().toISOString(); + formplayerStorage.setItem( + 'formulus_drafts', + JSON.stringify([ + { + id: 'legacy', + formType: 'household', + data: {}, + createdAt: now, + updatedAt: now, + observationId: null, + draftSessionKey: 'legacy-session', + }, + ]), + ); + const init = (schemaVersion: string) => ({ + formType: 'household', + observationId: null, + params: {}, + savedData: {}, + formSchema: { schemaVersion }, + }); + // Draft IDs use Date.now(); keep the two new drafts distinct deterministically. + vi.spyOn(Date, 'now').mockReturnValueOnce(100).mockReturnValueOnce(101); + const matching = draft.saveDraft('household', {}, init('2'), 'matching'); + const mismatched = draft.saveDraft('household', {}, init('3'), 'mismatched'); + expect( + draft + .getDraftsForForm('household', '2') + .map(item => item.id) + .sort(), + ).toEqual(['legacy', matching].sort()); + expect( + draft.getDraftsForForm('household', '2').map(item => item.id), + ).not.toContain(mismatched); + draft.saveDraft( + 'household', + { name: 'Resumed' }, + init('2'), + 'legacy-session', + ); + expect(draft.getDraft('legacy')?.formVersion).toBeUndefined(); + expect(draft.getDraft('legacy')?.data).toEqual({ name: 'Resumed' }); + expect( + draft.getDraftsForForm('household', '2').map(item => item.id), + ).toContain('legacy'); +}); diff --git a/formulus-formplayer/src/services/DraftService.ts b/formulus-formplayer/src/services/DraftService.ts index b0ec788e8..5549ea8e6 100644 --- a/formulus-formplayer/src/services/DraftService.ts +++ b/formulus-formplayer/src/services/DraftService.ts @@ -7,6 +7,7 @@ import { FormInitData } from '../types/FormulusInterfaceDefinition'; import { formplayerStorage } from './ProfileStorage'; +import { resolveFormSchemaVersion } from '../utils/formSchemaVersion'; /** * Interface for a saved draft @@ -199,7 +200,7 @@ export class DraftService { const newDraft: Draft = { id: draftId, formType, - formVersion: (formInitData?.formSchema as any)?.version || undefined, + formVersion: resolveFormSchemaVersion(formInitData?.formSchema), data, createdAt: now, updatedAt: now, diff --git a/formulus-formplayer/src/services/StickyService.schemaVersion.test.ts b/formulus-formplayer/src/services/StickyService.schemaVersion.test.ts new file mode 100644 index 000000000..cc1c79048 --- /dev/null +++ b/formulus-formplayer/src/services/StickyService.schemaVersion.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import { beforeEach, expect, it } from 'vitest'; +import { formplayerStorage } from './ProfileStorage'; +import { stickyService } from './StickyService'; +import { resolveFormSchemaVersion } from '../utils/formSchemaVersion'; + +beforeEach(() => { + localStorage.clear(); + formplayerStorage.initialize({ __odeProfileId: 'sticky-test' }, localStorage); +}); + +it('isolates sticky values by resolved schema version for the same form', () => { + const v2 = resolveFormSchemaVersion({ schemaVersion: '2', version: 'old' }); + const v3 = resolveFormSchemaVersion({ schemaVersion: '3', version: 'old' }); + stickyService.saveStickyValues('household', v2, { district: 'A' }); + expect(stickyService.getStickyValues('household', v3)).toEqual({}); + stickyService.saveStickyValues('household', v3, { district: 'B' }); + expect(stickyService.getStickyValues('household', v2)).toEqual({ + district: 'A', + }); + expect(stickyService.getStickyValues('household', v3)).toEqual({ + district: 'B', + }); +}); + +it('uses the 1.0 bucket for versionless forms without migrating the legacy 0 bucket', () => { + stickyService.saveStickyValues('household', undefined, { + district: 'Legacy', + }); + const version = resolveFormSchemaVersion({}); + expect(version).toBe('1.0'); + expect(stickyService.getStickyValues('household', version)).toEqual({}); + stickyService.saveStickyValues('household', version, { district: 'Current' }); + expect(stickyService.getStickyValues('household', '1.0')).toEqual({ + district: 'Current', + }); + expect(stickyService.getStickyValues('household', undefined)).toEqual({ + district: 'Legacy', + }); +}); diff --git a/formulus-formplayer/src/utils/formSchemaVersion.test.ts b/formulus-formplayer/src/utils/formSchemaVersion.test.ts new file mode 100644 index 000000000..974608564 --- /dev/null +++ b/formulus-formplayer/src/utils/formSchemaVersion.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest'; +import { resolveFormSchemaVersion } from './formSchemaVersion'; + +it.each([ + [{ 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(schemaVersion => [ + { schemaVersion, version: '3' }, + '3', + ]), + ...['', ' \t\n', null, 2, false, {}, []].map(version => [{ version }, '1.0']), + [{ schemaVersion: ' ', version: 2 }, '1.0'], + [{ schemaVersion: ' 2 ' }, ' 2 '], + [{ version: ' 3 ' }, ' 3 '], + [{ properties: { schemaVersion: '2', version: '3' } }, '1.0'], +])('resolves root metadata %j to %s', (schema, expected) => { + expect(resolveFormSchemaVersion(schema)).toBe(expected); +}); diff --git a/formulus-formplayer/src/utils/formSchemaVersion.ts b/formulus-formplayer/src/utils/formSchemaVersion.ts new file mode 100644 index 000000000..ce267ca7e --- /dev/null +++ b/formulus-formplayer/src/utils/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'; +} From d4fcef2a093bb5e565355b41b4f44a1a6dad0431 Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Wed, 11 Nov 2026 19:12:11 +0300 Subject: [PATCH 3/4] fix(desktop): preserve authored versions in form preview --- desktop/src/components/FormFinalizeDialog.tsx | 42 +-- .../lib/__tests__/formPreviewBridge.test.ts | 106 ++++++ desktop/src/lib/formPreviewBridge.ts | 53 ++- desktop/src/lib/formPreviewObservation.ts | 24 ++ desktop/src/lib/formSchemaVersion.test.ts | 30 ++ desktop/src/lib/formSchemaVersion.ts | 12 + desktop/src/pages/FormPreviewPage.test.tsx | 352 ++++++++++++++++++ desktop/src/pages/FormPreviewPage.tsx | 44 ++- 8 files changed, 630 insertions(+), 33 deletions(-) create mode 100644 desktop/src/lib/formPreviewObservation.ts create mode 100644 desktop/src/lib/formSchemaVersion.test.ts create mode 100644 desktop/src/lib/formSchemaVersion.ts create mode 100644 desktop/src/pages/FormPreviewPage.test.tsx 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 ( +