Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Edits] Separate form load and instance creation in engine entrypoints #335

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/new-dolls-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@getodk/xforms-engine': minor
---

**BREAKING CHANGE** rename instance payload property `definition` to `SubmissionMeta`
5 changes: 5 additions & 0 deletions .changeset/pretty-grapes-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@getodk/xforms-engine': minor
---

**BREAKING CHANGE** instance payload data is always produced as a tuple. The shape for a "chunked" payload is unchanged. The shape for a "monolithic" payload is now more like a "chunked" payload, except that it is guaranteed to always have only one item.
13 changes: 13 additions & 0 deletions .changeset/ten-wolves-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@getodk/xforms-engine': minor
---

**BREAKING CHANGE**

The main engine entrypoint (formerly `initializeForm`) has been split into:

- `loadForm`, producing an intermediate result from which many instances of the same form can be created (with a `createInstance` method on that result)

- `createInstance`, a convenience wrapper composing the result from `loadForm` and its `createInstance` method to create a single instance; this entrypoint effectively restores the behavior of `initializeForm`

Some interfaces related to the former `initializeForm` have also been refined to reflect this change.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other changesets currently in the diff are part of #334 (and as I just mentioned there, I think the PR warrants a broader one as well). But I am pretty sure this changeset adequately covers the delta from that branch to this one!

98 changes: 34 additions & 64 deletions packages/scenario/src/assertion/extensions/submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,7 @@ import {
} from '@getodk/common/test/assertions/helpers.ts';
import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
import type { AssertIs } from '@getodk/common/types/assertions/AssertIs.ts';
import type {
SubmissionChunkedType,
SubmissionData,
SubmissionInstanceFile,
SubmissionResult,
} from '@getodk/xforms-engine';
import type { InstanceFile, InstancePayload, InstancePayloadType } from '@getodk/xforms-engine';
import { constants } from '@getodk/xforms-engine';
import { assert, expect } from 'vitest';
import { Scenario } from '../../jr/Scenario.ts';
Expand All @@ -38,10 +33,10 @@ const compareSubmissionXML = (actual: string, expected: string): SimpleAssertion

const assertFormData: AssertIs<FormData> = instanceAssertion(FormData);

type AnySubmissionResult = SubmissionResult<SubmissionChunkedType>;
type AnyInstancePayload = InstancePayload<InstancePayloadType>;

/**
* Validating the full {@link SubmissionResult} type is fairly involved. We
* Validating the full {@link InstancePayload} type is fairly involved. We
* check the basic object shape (expected keys present, gut check a few easy to
* check property types), on the assumption that downstream assertions will fail
* if the runtime and static types disagree.
Expand All @@ -50,13 +45,13 @@ type AnySubmissionResult = SubmissionResult<SubmissionChunkedType>;
* more complete validation here, serving as a smoke test for all tests
* exercising aspects of a prepared submission result.
*/
const assertSubmissionResult: AssertIs<AnySubmissionResult> = (value) => {
const assertInstancePayload: AssertIs<AnyInstancePayload> = (value) => {
assertUnknownObject(value);
assertString(value.status);
if (value.violations !== null) {
assertUnknownArray(value.violations);
}
assertUnknownObject(value.definition);
assertUnknownObject(value.submissionMeta);

if (Array.isArray(value.data)) {
value.data.forEach((item) => {
Expand All @@ -69,47 +64,25 @@ const assertSubmissionResult: AssertIs<AnySubmissionResult> = (value) => {

const assertFile: AssertIs<File> = instanceAssertion(File);

const { SUBMISSION_INSTANCE_FILE_NAME, SUBMISSION_INSTANCE_FILE_TYPE } = constants;
const { INSTANCE_FILE_NAME, INSTANCE_FILE_TYPE } = constants;

const assertSubmissionInstanceFile: AssertIs<SubmissionInstanceFile> = (value) => {
const assertInstanceFile: AssertIs<InstanceFile> = (value) => {
assertFile(value);

if (value.name !== SUBMISSION_INSTANCE_FILE_NAME) {
throw new Error(`Expected file named ${SUBMISSION_INSTANCE_FILE_NAME}, got ${value.name}`);
if (value.name !== INSTANCE_FILE_NAME) {
throw new Error(`Expected file named ${INSTANCE_FILE_NAME}, got ${value.name}`);
}

if (value.type !== SUBMISSION_INSTANCE_FILE_TYPE) {
throw new Error(`Expected file of type ${SUBMISSION_INSTANCE_FILE_TYPE}, got ${value.type}`);
if (value.type !== INSTANCE_FILE_TYPE) {
throw new Error(`Expected file of type ${INSTANCE_FILE_TYPE}, got ${value.type}`);
}
};

type ChunkedSubmissionData = readonly [SubmissionData, ...SubmissionData[]];
const getInstanceFile = (payload: AnyInstancePayload): InstanceFile => {
const [instanceData] = payload.data;
const file = instanceData.get(INSTANCE_FILE_NAME);

const isChunkedSubmissionData = (
data: ChunkedSubmissionData | SubmissionData
): data is ChunkedSubmissionData => {
return Array.isArray(data);
};

const getSubmissionData = (submissionResult: AnySubmissionResult): SubmissionData => {
const { data } = submissionResult;

if (isChunkedSubmissionData(data)) {
const [first] = data;

return first;
}

return data;
};

const getSubmissionInstanceFile = (
submissionResult: AnySubmissionResult
): SubmissionInstanceFile => {
const submissionData = getSubmissionData(submissionResult);
const file = submissionData.get(SUBMISSION_INSTANCE_FILE_NAME);

assertSubmissionInstanceFile(file);
assertInstanceFile(file);

return file;
};
Expand All @@ -125,30 +98,27 @@ export const submissionExtensions = extendExpect(expect, {
}
),

toBeReadyForSubmission: new ArbitraryConditionExpectExtension(
assertSubmissionResult,
(result) => {
try {
expect(result).toMatchObject({
status: 'ready',
violations: null,
});

return true;
} catch (error) {
if (error instanceof Error) {
return error;
}

// eslint-disable-next-line no-console
console.error(error);
return new Error('Unknown error');
toBeReadyForSubmission: new ArbitraryConditionExpectExtension(assertInstancePayload, (result) => {
try {
expect(result).toMatchObject({
status: 'ready',
violations: null,
});

return true;
} catch (error) {
if (error instanceof Error) {
return error;
}

// eslint-disable-next-line no-console
console.error(error);
return new Error('Unknown error');
}
),
}),

toBePendingSubmissionWithViolations: new ArbitraryConditionExpectExtension(
assertSubmissionResult,
assertInstancePayload,
(result) => {
try {
expect(result.status).toBe('pending');
Expand All @@ -173,10 +143,10 @@ export const submissionExtensions = extendExpect(expect, {
),

toHavePreparedSubmissionXML: new AsyncAsymmetricTypedExpectExtension(
assertSubmissionResult,
assertInstancePayload,
assertString,
async (actual, expected): Promise<SimpleAssertionResult> => {
const instanceFile = getSubmissionInstanceFile(actual);
const instanceFile = getInstanceFile(actual);
const actualText = await getBlobText(instanceFile);

return compareSubmissionXML(actualText, expected);
Expand Down
16 changes: 9 additions & 7 deletions packages/scenario/src/client/init.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { JRResourceService } from '@getodk/common/jr-resources/JRResourceService.ts';
import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts';
import type {
EngineConfig,
FormResource,
LoadFormOptions,
OpaqueReactiveObjectFactory,
RootNode,
} from '@getodk/xforms-engine';
import { initializeForm } from '@getodk/xforms-engine';
import { createInstance } from '@getodk/xforms-engine';
import type { Owner } from 'solid-js';
import { createRoot, getOwner, runWithOwner } from 'solid-js';
import type { MissingResourceBehavior } from '../../../xforms-engine/dist/client/constants';
Expand Down Expand Up @@ -62,7 +62,7 @@ export interface InitializeTestFormOptions {

const defaultConfig = {
fetchFormDefinition: fetchFormDefinitionStub,
} as const satisfies Omit<EngineConfig, 'stateFactory'>;
} as const satisfies LoadFormOptions;

interface InitializedTestForm {
readonly instanceRoot: RootNode;
Expand All @@ -82,19 +82,21 @@ export const initializeTestForm = async (
}

const formResource = await getFormResource(testForm);
const instanceRoot = await runWithOwner(owner, async () => {
return initializeForm(formResource, {
config: {
const instance = await runWithOwner(owner, async () => {
return createInstance(formResource, {
form: {
...defaultConfig,
fetchFormAttachment: options.resourceService.handleRequest,
missingResourceBehavior: options.missingResourceBehavior,
},
instance: {
stateFactory: options.stateFactory,
},
});
})!;

return {
instanceRoot,
instanceRoot: instance.root,
owner,
dispose,
};
Expand Down
8 changes: 4 additions & 4 deletions packages/scenario/src/jr/Scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { constants as ENGINE_CONSTANTS } from '@getodk/xforms-engine';
import type { Accessor, Setter } from 'solid-js';
import { createMemo, createSignal, runWithOwner } from 'solid-js';
import { afterEach, assert, expect } from 'vitest';
import { SelectValuesAnswer } from '../answer/SelectValuesAnswer.ts';
import { RankValuesAnswer } from '../answer/RankValuesAnswer.ts';
import { SelectValuesAnswer } from '../answer/SelectValuesAnswer.ts';
import type { ValueNodeAnswer } from '../answer/ValueNodeAnswer.ts';
import { answerOf } from '../client/answerOf.ts';
import type { InitializeTestFormOptions, TestFormResource } from '../client/init.ts';
Expand Down Expand Up @@ -964,7 +964,7 @@ export class Scenario {
}

proposed_serializeInstance(): string {
return this.instanceRoot.submissionState.submissionXML;
return this.instanceRoot.instanceState.instanceXML;
}

/**
Expand All @@ -974,8 +974,8 @@ export class Scenario {
* more about Collect's responsibility for submission (beyond serialization,
* already handled by {@link proposed_serializeInstance}).
*/
prepareWebFormsSubmission() {
return this.instanceRoot.prepareSubmission();
prepareWebFormsInstancePayload() {
return this.instanceRoot.prepareInstancePayload();
}

// TODO: consider adapting tests which use the following interfaces to use
Expand Down
5 changes: 2 additions & 3 deletions packages/scenario/test/serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
t,
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
import type { EngineConfig, InitializeFormOptions } from '@getodk/xforms-engine';
import type { LoadFormOptions } from '@getodk/xforms-engine';
import { describe, expect, it } from 'vitest';
import { stringAnswer } from '../src/answer/ExpectedStringAnswer.ts';
import { Scenario } from '../src/jr/Scenario.ts';
Expand Down Expand Up @@ -175,8 +175,7 @@ describe('ExternalSecondaryInstanceParseTest.java', () => {
* - Insofar as we may find ourselves implementing similar logic (albeit
* serving other purposes), how can we establish a clear interface
* contract around behaviors like this? Should it be more consistent? Does
* our current {@link EngineConfig.fetchFormDefinition}
* option—configurable in {@link InitializeFormOptions}—provide enough
* our current {@link LoadFormOptions.fetchFormDefinition} provide enough
* informational surface area to communicate such intent (and allow both
* clients and engine alike to have clarity of that intent at
* call/handling sites)?
Expand Down
34 changes: 17 additions & 17 deletions packages/scenario/test/submission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,26 +750,26 @@ describe('Form submission', () => {
return scenario;
};

describe('submission definition', () => {
it('includes a default submission definition', async () => {
describe('submission meta', () => {
it('includes default submission meta', async () => {
const scenario = await buildSubmissionPayloadScenario();
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult.definition).toMatchObject({
expect(submissionResult.submissionMeta).toMatchObject({
submissionAction: null,
submissionMethod: 'post',
encryptionKey: null,
});
});

it('includes a form-specified submission definition URL', async () => {
it('includes a form-specified submission URL', async () => {
const submissionAction = 'https://example.org';
const scenario = await buildSubmissionPayloadScenario({
submissionElements: [t(`submission action="${submissionAction}"`)],
});
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult.definition).toMatchObject({
expect(submissionResult.submissionMeta).toMatchObject({
submissionAction: new URL(submissionAction),
});
});
Expand All @@ -778,9 +778,9 @@ describe('Form submission', () => {
const scenario = await buildSubmissionPayloadScenario({
submissionElements: [t('submission method="post"')],
});
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult.definition).toMatchObject({
expect(submissionResult.submissionMeta).toMatchObject({
submissionMethod: 'post',
});
});
Expand All @@ -789,9 +789,9 @@ describe('Form submission', () => {
const scenario = await buildSubmissionPayloadScenario({
submissionElements: [t('submission method="form-data-post"')],
});
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult.definition).toMatchObject({
expect(submissionResult.submissionMeta).toMatchObject({
submissionMethod: 'post',
});
});
Expand Down Expand Up @@ -823,9 +823,9 @@ describe('Form submission', () => {
),
],
});
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult.definition).toMatchObject({
expect(submissionResult.submissionMeta).toMatchObject({
encryptionKey: base64RsaPublicKey,
});
});
Expand Down Expand Up @@ -858,13 +858,13 @@ describe('Form submission', () => {
});

it('is ready for submission when instance state is valid', async () => {
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult).toBeReadyForSubmission();
});

it('includes submission instance XML file data', async () => {
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

await expect(submissionResult).toHavePreparedSubmissionXML(validSubmissionXML);
});
Expand Down Expand Up @@ -895,13 +895,13 @@ describe('Form submission', () => {
});

it('is pending submission with violations', async () => {
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult).toBePendingSubmissionWithViolations();
});

it('produces submission instance XML file data even when current instance state is invalid', async () => {
const submissionResult = await scenario.prepareWebFormsSubmission();
const submissionResult = await scenario.prepareWebFormsInstancePayload();

await expect(submissionResult).toHavePreparedSubmissionXML(invalidSubmissionXML);
});
Expand Down
Loading
Loading