@toloka-platform/sdk 0.1.0-preview.6 → 0.1.0-preview.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +7 -11
  2. package/dist/cli/toloka.js +208 -123
  3. package/dist/cli/toloka.js.jsc +0 -0
  4. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  5. package/dist/cli/toloka.js.sha256 +1 -1
  6. package/docs/authoring.md +17 -4
  7. package/docs/cli-v2.md +3 -3
  8. package/docs/cli.md +29 -16
  9. package/docs/getting-started.md +7 -12
  10. package/docs/limitations.md +6 -6
  11. package/docs/testing-examples.md +248 -0
  12. package/docs/testing.md +115 -0
  13. package/examples/face-image-collection/README.md +7 -1
  14. package/examples/face-image-collection/fields.ts +20 -8
  15. package/examples/face-image-collection/nodes/annotate-faces/node.ts +1 -9
  16. package/examples/face-image-collection/nodes/order-faces/node.ts +1 -1
  17. package/examples/face-image-collection/pipeline.browser.integration.spec.ts +78 -0
  18. package/examples/video-pair-rating/README.md +14 -6
  19. package/examples/video-pair-rating/nodes/adjudicate/node.ts +1 -3
  20. package/examples/video-pair-rating/nodes/adjudicate_op/node.ts +1 -3
  21. package/examples/video-pair-rating/nodes/overall_preference_pick/node.ts +1 -1
  22. package/examples/video-pair-rating/nodes/rate_pair/node.ts +1 -1
  23. package/examples/video-pair-rating/pipeline.browser.integration.spec.ts +267 -0
  24. package/openapi/gen/api.ts +48 -11
  25. package/openapi/gen/models/createDatasetExportBody.ts +3 -3
  26. package/openapi/gen/models/createDatasetExportBodyOneOf.ts +6 -0
  27. package/openapi/gen/models/createDatasetExportBodyOneOfFormat.ts +2 -0
  28. package/openapi/gen/models/createDatasetExportBodyOneOfThree.ts +10 -0
  29. package/openapi/gen/models/createDatasetExportBodyOneOfThreeContract.ts +2 -0
  30. package/openapi/gen/models/createDatasetExportBodyOneOfThreeFormat.ts +2 -0
  31. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilter.ts +9 -0
  32. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldSourcesItem.ts +6 -0
  33. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldSourcesItemSourcesItem.ts +4 -0
  34. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldSourcesItemSourcesItemOneOf.ts +5 -0
  35. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldSourcesItemSourcesItemOneOfTwo.ts +5 -0
  36. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItem.ts +12 -0
  37. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemOperator.ts +2 -0
  38. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemSourcesItem.ts +4 -0
  39. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemSourcesItemOneOf.ts +5 -0
  40. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemSourcesItemOneOfTwo.ts +5 -0
  41. package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemValue.ts +2 -0
  42. package/openapi/gen/models/datasetExportReport.ts +9 -0
  43. package/openapi/gen/models/datasetExportReportErrorsItem.ts +5 -0
  44. package/openapi/gen/models/datasetExportReportTokens.ts +7 -0
  45. package/openapi/gen/models/exportJob.ts +2 -0
  46. package/openapi/gen/models/exportJobReport.ts +3 -0
  47. package/openapi/gen/models/index.ts +48 -0
  48. package/openapi/gen/models/sampleDatasetItemsBody.ts +4 -0
  49. package/openapi/gen/models/sampleDatasetItemsBodyAnyOf.ts +8 -0
  50. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilter.ts +9 -0
  51. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldSourcesItem.ts +6 -0
  52. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldSourcesItemSourcesItem.ts +4 -0
  53. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldSourcesItemSourcesItemOneOf.ts +5 -0
  54. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldSourcesItemSourcesItemOneOfTwo.ts +5 -0
  55. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItem.ts +12 -0
  56. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemOperator.ts +2 -0
  57. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemSourcesItem.ts +4 -0
  58. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemSourcesItemOneOf.ts +5 -0
  59. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemSourcesItemOneOfTwo.ts +5 -0
  60. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemValue.ts +2 -0
  61. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethree.ts +8 -0
  62. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilter.ts +9 -0
  63. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldSourcesItem.ts +6 -0
  64. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldSourcesItemSourcesItem.ts +4 -0
  65. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldSourcesItemSourcesItemOneOf.ts +5 -0
  66. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldSourcesItemSourcesItemOneOfTwo.ts +5 -0
  67. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItem.ts +12 -0
  68. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemOperator.ts +2 -0
  69. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemSourcesItem.ts +4 -0
  70. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemSourcesItemOneOf.ts +5 -0
  71. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemSourcesItemOneOfTwo.ts +5 -0
  72. package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemValue.ts +2 -0
  73. package/openapi/gen/models/sampleJob.ts +13 -0
  74. package/openapi/gen/models/sampleJobStatus.ts +2 -0
  75. package/openapi/gen/models/sampleResult.ts +5 -0
  76. package/package.json +23 -2
  77. package/skills/catalog.json +14 -6
  78. package/skills/toloka-build/SKILL.md +34 -12
  79. package/skills/toloka-build/references/node-semantics.md +27 -24
  80. package/skills/toloka-build/references/workflow-and-cli.md +55 -26
  81. package/skills/toloka-report-issue/references/report-template.md +1 -1
  82. package/src/checks/ast.ts +32 -0
  83. package/src/checks/index.ts +14 -0
  84. package/src/checks/postprocess-collisions.ts +145 -0
  85. package/src/checks/task-ui-declarations.generated.ts +19 -0
  86. package/src/checks/task-ui-typecheck.ts +345 -0
  87. package/src/checks/typescript-vfs.d.ts +26 -0
  88. package/src/checks/typescript-vfs.js +5 -0
  89. package/src/cli/analytics.ts +1 -0
  90. package/src/cli/check.ts +977 -0
  91. package/src/cli/program.ts +147 -19
  92. package/src/cli/project-context.ts +14 -4
  93. package/src/cli/runs.ts +21 -11
  94. package/src/cli/skill.ts +17 -6
  95. package/src/testing/code-node.ts +160 -0
  96. package/src/testing/index.ts +15 -0
  97. package/src/testing/task-ui-browser.ts +120 -0
  98. package/src/testing/task-ui-validation.ts +135 -0
  99. package/src/testing/task-ui.ts +557 -0
  100. package/src/testing/test-evidence.ts +170 -0
  101. package/src/testing/types.ts +141 -0
@@ -0,0 +1,135 @@
1
+ import Ajv2020 from "ajv/dist/2020.js";
2
+ import addFormats from "ajv-formats";
3
+ import { assert } from "es-toolkit";
4
+ import { query, type JsonValue } from "jsonpath-rfc9535";
5
+ import { Script } from "node:vm";
6
+ import type { PipelineDefinition } from "../authoring";
7
+ import type { TaskUiEntityError, TaskUiNode } from "./types";
8
+
9
+ export const resolveTaskNode = (pipeline: PipelineDefinition, node: TaskUiNode) => {
10
+ const graphNode = pipeline.graph.nodes.find((candidate) => candidate.id === node.id);
11
+ assert(graphNode, `Node "${node.id}" is not part of this pipeline`);
12
+ assert(
13
+ graphNode.type === "generation" || graphNode.type === "quorum",
14
+ `Node "${node.id}" does not have a Task UI`,
15
+ );
16
+ return graphNode;
17
+ };
18
+
19
+ export type ResolvedTaskNode = ReturnType<typeof resolveTaskNode>;
20
+ type TaskUiOutputEntity = ResolvedTaskNode["settings"]["entities"]["output"][number];
21
+ type PipelineField = PipelineDefinition["graph"]["fields"][number];
22
+
23
+ export const runtimeJavaScriptSyntaxError = (
24
+ source: string,
25
+ block: "preprocess" | "postprocess",
26
+ ): SyntaxError | null => {
27
+ try {
28
+ new Script(`(function (data) {\n${source}\n})`, { filename: `${block}.js` });
29
+ return null;
30
+ } catch (error) {
31
+ return new SyntaxError(
32
+ `${block} is not valid runtime JavaScript: ${error instanceof Error ? error.message : String(error)}`,
33
+ );
34
+ }
35
+ };
36
+
37
+ export const normalizePath = (path: string): string =>
38
+ path.replace(/^\$\.?/u, "").replace(/\[\*\]$/u, "");
39
+
40
+ const resolveEntitySchema = (node: ResolvedTaskNode, field: PipelineField): unknown => {
41
+ const schema = field.jsonSchema;
42
+ if (
43
+ node.type !== "quorum" ||
44
+ schema === null ||
45
+ typeof schema !== "object" ||
46
+ Array.isArray(schema) ||
47
+ Reflect.get(schema, "type") !== "array"
48
+ ) {
49
+ return schema;
50
+ }
51
+ return Reflect.get(schema, "items");
52
+ };
53
+
54
+ const extractEntityValues = (
55
+ data: Record<string, unknown>,
56
+ entity: TaskUiOutputEntity,
57
+ field: PipelineField,
58
+ ): JsonValue[] => {
59
+ if (entity.jsonPath !== "$") return query(data as JsonValue, entity.jsonPath);
60
+ return Object.hasOwn(data, field.name) ? [data[field.name] as JsonValue] : [];
61
+ };
62
+
63
+ const validateEntity = ({
64
+ ajv,
65
+ node,
66
+ data,
67
+ entity,
68
+ field,
69
+ }: {
70
+ ajv: Ajv2020;
71
+ node: ResolvedTaskNode;
72
+ data: Record<string, unknown>;
73
+ entity: TaskUiOutputEntity;
74
+ field: PipelineField;
75
+ }): TaskUiEntityError | undefined => {
76
+ const expectedSchema = resolveEntitySchema(node, field);
77
+ const extracted = extractEntityValues(data, entity, field);
78
+ if (extracted.length === 0) {
79
+ return entity.optional
80
+ ? undefined
81
+ : {
82
+ entityName: field.name,
83
+ path: entity.jsonPath,
84
+ reason: `No data found at path "${entity.jsonPath}"`,
85
+ submittedValue: null,
86
+ expectedSchema,
87
+ };
88
+ }
89
+
90
+ assert(
91
+ typeof expectedSchema === "boolean" ||
92
+ (expectedSchema !== null &&
93
+ typeof expectedSchema === "object" &&
94
+ !Array.isArray(expectedSchema)),
95
+ `Invalid schema for "${field.name}"`,
96
+ );
97
+ const submittedValue = entity.dynamic ? extracted : extracted[0];
98
+ const validate = ajv.compile(expectedSchema as boolean | object);
99
+ if (validate(submittedValue)) return undefined;
100
+
101
+ return {
102
+ entityName: field.name,
103
+ path: entity.jsonPath,
104
+ reason:
105
+ validate.errors
106
+ ?.map((error) => error.message)
107
+ .filter(Boolean)
108
+ .join(", ") || "Schema mismatch",
109
+ submittedValue,
110
+ expectedSchema,
111
+ };
112
+ };
113
+
114
+ export const validateEntities = (
115
+ pipeline: PipelineDefinition,
116
+ node: ResolvedTaskNode,
117
+ data: Record<string, unknown>,
118
+ ignoredPaths: Set<string>,
119
+ ): TaskUiEntityError[] => {
120
+ const fieldsById = new Map(pipeline.graph.fields.map((field) => [field.id, field]));
121
+ const ajv = new Ajv2020({ strict: false });
122
+ addFormats(ajv);
123
+ const errors: TaskUiEntityError[] = [];
124
+
125
+ for (const entity of node.settings.entities.output) {
126
+ const field = fieldsById.get(entity.fieldProxyId);
127
+ assert(field, `Output entity "${entity.fieldProxyId}" has no declared field`);
128
+ const path = entity.jsonPath === "$" ? field.name : normalizePath(entity.jsonPath);
129
+ if (ignoredPaths.has(path)) continue;
130
+ const error = validateEntity({ ajv, node, data, entity, field });
131
+ if (error !== undefined) errors.push(error);
132
+ }
133
+
134
+ return errors;
135
+ };
@@ -0,0 +1,557 @@
1
+ import { assert } from "es-toolkit";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import type { Browser } from "playwright";
5
+ import { expect, test as baseTest, type TestAPI } from "vitest";
6
+ import type { AuthoredCodeNode, PipelineDefinition } from "../authoring";
7
+ import {
8
+ DEFAULT_TBX_LAUNCHER_URL,
9
+ componentIds,
10
+ evaluateWithApi,
11
+ launchDefaultBrowser,
12
+ readDataPath,
13
+ selectComponentDocumentation,
14
+ waitForTaskUiApi,
15
+ } from "./task-ui-browser";
16
+ import {
17
+ normalizePath,
18
+ resolveTaskNode,
19
+ runtimeJavaScriptSyntaxError,
20
+ validateEntities,
21
+ type ResolvedTaskNode,
22
+ } from "./task-ui-validation";
23
+ import {
24
+ TaskUiInfrastructureError,
25
+ TaskUiValidationError,
26
+ type RenderTaskUiOptions,
27
+ type TaskUiCoverage,
28
+ type TaskUiFormError,
29
+ type TaskUiNode,
30
+ type TaskUiSession,
31
+ type TbxTemplate,
32
+ type TypedTestOptions,
33
+ type UiTestFixtures,
34
+ type UiWorkerFixtures,
35
+ } from "./types";
36
+ import { CodeNodeInfrastructureError, runCodeNode, type RunCodeNodeOptions } from "./code-node";
37
+ import {
38
+ assertTestNodeInPipeline,
39
+ assertTypedTestCompletion,
40
+ matchesTestFilter,
41
+ pipelineFingerprint,
42
+ writeTestEvidence,
43
+ typedTestNamePrefix,
44
+ TEST_TYPED_ONLY_ENV,
45
+ type TestEvidenceKind,
46
+ } from "./test-evidence";
47
+
48
+ const FILE_COMPONENT_PATTERN = /<Field\.(?:Video(?:Record|Upload)|FileUpload)\b[^>]*>/gu;
49
+ const FIELD_PATH_PATTERN = /\bpath=\{?['"]([^'"]+)['"]\}?/u;
50
+ const FILE_ENTITY_TYPES = new Set(["VIDEO", "IMAGE", "AUDIO", "DOCUMENT"]);
51
+
52
+ type CreateTaskUiTestOptions = {
53
+ launcherUrl?: string;
54
+ launchBrowser?: () => Promise<Browser>;
55
+ };
56
+
57
+ const createTaskUiTemplate = (
58
+ graphNode: ResolvedTaskNode,
59
+ options: RenderTaskUiOptions,
60
+ ): TbxTemplate => {
61
+ const template = {
62
+ ...graphNode.settings.ui,
63
+ data: options.data === undefined ? graphNode.settings.ui.data : JSON.stringify(options.data),
64
+ postprocess: graphNode.settings.ui.postprocess ?? "return {};",
65
+ } satisfies TbxTemplate;
66
+ const syntaxError =
67
+ runtimeJavaScriptSyntaxError(template.preprocess, "preprocess") ??
68
+ runtimeJavaScriptSyntaxError(template.postprocess, "postprocess");
69
+ if (syntaxError) throw syntaxError;
70
+ return template;
71
+ };
72
+
73
+ const analyzeHardwareCoverage = (
74
+ pipeline: PipelineDefinition,
75
+ graphNode: ResolvedTaskNode,
76
+ template: TbxTemplate,
77
+ ) => {
78
+ const hardwareComponents = [...template.code.matchAll(FILE_COMPONENT_PATTERN)];
79
+ const hasHardwareComponents = hardwareComponents.length > 0;
80
+ const fieldsById = new Map(pipeline.graph.fields.map((field) => [field.id, field]));
81
+ const hardwareEntityPaths = new Set(
82
+ hasHardwareComponents
83
+ ? graphNode.settings.entities.output
84
+ .filter((entity) => FILE_ENTITY_TYPES.has(entity.entityType))
85
+ .map((entity) => {
86
+ const field = fieldsById.get(entity.fieldProxyId);
87
+ assert(field, `Output entity "${entity.fieldProxyId}" has no declared field`);
88
+ return entity.jsonPath === "$" ? field.name : normalizePath(entity.jsonPath);
89
+ })
90
+ : [],
91
+ );
92
+ const hardwareFieldPaths = new Set(
93
+ hardwareComponents
94
+ .map((component) => component[0].match(FIELD_PATH_PATTERN)?.[1])
95
+ .filter((path): path is string => path !== undefined)
96
+ .map(normalizePath),
97
+ );
98
+ return {
99
+ coverage: {
100
+ complete: !hasHardwareComponents,
101
+ untestedHardwareFields: hasHardwareComponents ? [...hardwareEntityPaths] : [],
102
+ } satisfies TaskUiCoverage,
103
+ hardwareEntityPaths,
104
+ hardwareFieldPaths,
105
+ };
106
+ };
107
+
108
+ const openTaskUiSession = async ({
109
+ browser,
110
+ launcherUrl,
111
+ pipeline,
112
+ node,
113
+ options,
114
+ }: {
115
+ browser: Browser;
116
+ launcherUrl: string;
117
+ pipeline: PipelineDefinition;
118
+ node: TaskUiNode;
119
+ options: RenderTaskUiOptions;
120
+ }): Promise<TaskUiSession> => {
121
+ const graphNode = resolveTaskNode(pipeline, node);
122
+ const template = createTaskUiTemplate(graphNode, options);
123
+ const context = await browser.newContext();
124
+ const page = await context.newPage();
125
+ let closed = false;
126
+
127
+ try {
128
+ try {
129
+ await page.goto(launcherUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
130
+ await waitForTaskUiApi(page);
131
+ } catch (error) {
132
+ throw new TaskUiInfrastructureError(`Unable to load the Task UI launcher at ${launcherUrl}`, {
133
+ cause: error,
134
+ });
135
+ }
136
+ let tree = await evaluateWithApi(page, { operation: "setConfig", config: template });
137
+ const { coverage, hardwareEntityPaths, hardwareFieldPaths } = analyzeHardwareCoverage(
138
+ pipeline,
139
+ graphNode,
140
+ template,
141
+ );
142
+
143
+ const componentForPath = async (componentTree: string, path: string) => {
144
+ const states = await Promise.all(
145
+ componentIds(componentTree).map(async (componentId) => ({
146
+ componentId,
147
+ state: await evaluateWithApi(page, { operation: "getComponentState", componentId }),
148
+ })),
149
+ );
150
+ return states.find(({ state }) => readDataPath(state) === path);
151
+ };
152
+ const resolveComponentId = async (path: string): Promise<string> => {
153
+ const deadline = Date.now() + 5_000;
154
+ await new Promise<void>((resolve) => {
155
+ setTimeout(resolve, 500);
156
+ });
157
+ do {
158
+ try {
159
+ tree = await evaluateWithApi(page, { operation: "getComponentTree" });
160
+ const component = await componentForPath(tree, path);
161
+ if (component !== undefined) return component.componentId;
162
+ } catch (error) {
163
+ if (Date.now() >= deadline) throw error;
164
+ }
165
+ await new Promise<void>((resolve) => {
166
+ setTimeout(resolve, 250);
167
+ });
168
+ } while (Date.now() < deadline);
169
+ assert(false, `No Task UI component is bound to path "${path}"`);
170
+ };
171
+
172
+ const validationErrors = async (): Promise<TaskUiFormError[]> => {
173
+ const errors = await evaluateWithApi(page, { operation: "getValidationErrors" });
174
+ return Promise.all(
175
+ errors.map(async (error) => {
176
+ const state = await evaluateWithApi(page, {
177
+ operation: "getComponentState",
178
+ componentId: error.componentId,
179
+ });
180
+ const path = readDataPath(state);
181
+ return { ...error, ...(path === undefined ? {} : { path }) };
182
+ }),
183
+ );
184
+ };
185
+
186
+ return {
187
+ componentTree: async () => {
188
+ tree = await evaluateWithApi(page, { operation: "getComponentTree" });
189
+ return tree;
190
+ },
191
+ componentDocumentation: async (names) =>
192
+ selectComponentDocumentation(
193
+ await evaluateWithApi(page, { operation: "getComponentsDescriptions" }),
194
+ names,
195
+ ),
196
+ getValue: async (path) => {
197
+ const state = await evaluateWithApi(page, {
198
+ operation: "getComponentState",
199
+ componentId: await resolveComponentId(path),
200
+ });
201
+ return Reflect.get(state, "value");
202
+ },
203
+ setValue: async (path, value) => {
204
+ await evaluateWithApi(page, {
205
+ operation: "setComponentValue",
206
+ componentId: await resolveComponentId(path),
207
+ value,
208
+ });
209
+ },
210
+ validationErrors,
211
+ submit: async () => {
212
+ if (!coverage.complete && options.allowPartialCoverage !== true) {
213
+ throw new TaskUiValidationError({
214
+ message: `Task UI contains hardware-backed fields that were not tested: ${coverage.untestedHardwareFields.join(", ") || "unknown"}`,
215
+ coverage,
216
+ });
217
+ }
218
+
219
+ const formErrors = (await validationErrors()).filter(
220
+ (error) => error.path === undefined || !hardwareFieldPaths.has(normalizePath(error.path)),
221
+ );
222
+ if (formErrors.length > 0) {
223
+ throw new TaskUiValidationError({
224
+ message: "Task UI browser validation failed",
225
+ formErrors,
226
+ coverage,
227
+ });
228
+ }
229
+
230
+ const data = await evaluateWithApi(page, { operation: "getData" });
231
+ assert(
232
+ data && typeof data === "object" && !Array.isArray(data),
233
+ "Task UI produced no submission data",
234
+ );
235
+ const { _context, ...output } = data;
236
+ void _context;
237
+ const entityErrors = validateEntities(pipeline, graphNode, output, hardwareEntityPaths);
238
+ if (entityErrors.length > 0) {
239
+ throw new TaskUiValidationError({
240
+ message: `Task UI output entity validation failed: ${entityErrors
241
+ .map((error) => `${error.entityName} (${error.path}): ${error.reason}`)
242
+ .join("; ")}`,
243
+ entityErrors,
244
+ coverage,
245
+ });
246
+ }
247
+ return output;
248
+ },
249
+ screenshot: async (path) => {
250
+ await mkdir(dirname(path), { recursive: true });
251
+ await writeFile(path, await page.screenshot({ type: "png", fullPage: true }));
252
+ return path;
253
+ },
254
+ coverage,
255
+ close: async () => {
256
+ if (closed) return;
257
+ closed = true;
258
+ await page.close();
259
+ await context.close();
260
+ },
261
+ };
262
+ } catch (error) {
263
+ await page.close().catch(() => undefined);
264
+ await context.close().catch(() => undefined);
265
+ throw error;
266
+ }
267
+ };
268
+
269
+ const createTaskUiTest = ({
270
+ launcherUrl = DEFAULT_TBX_LAUNCHER_URL,
271
+ launchBrowser = launchDefaultBrowser,
272
+ }: CreateTaskUiTestOptions = {}): TestAPI<UiTestFixtures & UiWorkerFixtures> =>
273
+ baseTest.extend<UiTestFixtures & UiWorkerFixtures>({
274
+ _uiConfig: [{ launcherUrl, launchBrowser }, { scope: "worker" }],
275
+ _uiBrowser: [
276
+ async ({ _uiConfig }, use) => {
277
+ const browser = await _uiConfig.launchBrowser();
278
+ await use(browser);
279
+ await browser.close();
280
+ },
281
+ { scope: "worker" },
282
+ ],
283
+ ui: async ({ _uiBrowser, _uiConfig }, use) => {
284
+ const sessions = new Set<TaskUiSession>();
285
+ await use({
286
+ componentDocumentation: async (names) => {
287
+ const context = await _uiBrowser.newContext();
288
+ const page = await context.newPage();
289
+ try {
290
+ await page.goto(_uiConfig.launcherUrl, {
291
+ waitUntil: "domcontentloaded",
292
+ timeout: 30_000,
293
+ });
294
+ await waitForTaskUiApi(page);
295
+ return selectComponentDocumentation(
296
+ await evaluateWithApi(page, { operation: "getComponentsDescriptions" }),
297
+ names,
298
+ );
299
+ } finally {
300
+ await page.close();
301
+ await context.close();
302
+ }
303
+ },
304
+ render: async (pipeline, node, options = {}) => {
305
+ const session = await openTaskUiSession({
306
+ browser: _uiBrowser,
307
+ launcherUrl: _uiConfig.launcherUrl,
308
+ pipeline,
309
+ node,
310
+ options,
311
+ });
312
+ sessions.add(session);
313
+ return session;
314
+ },
315
+ });
316
+ await Promise.all([...sessions].map((session) => session.close()));
317
+ },
318
+ });
319
+
320
+ const vitestTest = createTaskUiTest();
321
+
322
+ type CodeTestContext<Node extends AuthoredCodeNode> = {
323
+ readonly node: Node;
324
+ readonly run: (
325
+ inputs: Record<string, unknown>,
326
+ options?: RunCodeNodeOptions,
327
+ ) => Promise<Record<string, unknown>>;
328
+ };
329
+
330
+ type UiTestContext<Node extends TaskUiNode> = {
331
+ readonly node: Node;
332
+ readonly pipeline: PipelineDefinition;
333
+ readonly render: (options?: RenderTaskUiOptions) => Promise<TaskUiSession>;
334
+ };
335
+
336
+ type PipelineTestContext = {
337
+ readonly pipeline: PipelineDefinition;
338
+ readonly runCode: (
339
+ node: AuthoredCodeNode,
340
+ inputs: Record<string, unknown>,
341
+ options?: RunCodeNodeOptions,
342
+ ) => Promise<Record<string, unknown>>;
343
+ readonly renderUi: (node: TaskUiNode, options?: RenderTaskUiOptions) => Promise<TaskUiSession>;
344
+ };
345
+
346
+ const errorMessage = (error: unknown): string =>
347
+ error instanceof Error ? error.message : String(error);
348
+
349
+ const runTypedTest = async ({
350
+ pipeline,
351
+ nodeId,
352
+ kind,
353
+ name,
354
+ execute,
355
+ wasExecuted,
356
+ coverage,
357
+ }: {
358
+ pipeline: PipelineDefinition;
359
+ nodeId?: string;
360
+ kind: TestEvidenceKind;
361
+ name: string;
362
+ execute: () => Promise<void>;
363
+ wasExecuted: () => boolean;
364
+ coverage?: () => TaskUiCoverage[];
365
+ }) => {
366
+ const assertionsBefore = expect.getState().assertionCalls;
367
+ try {
368
+ await execute();
369
+ assertTypedTestCompletion({
370
+ kind,
371
+ name,
372
+ executed: wasExecuted(),
373
+ assertionsBefore,
374
+ assertionsAfter: expect.getState().assertionCalls,
375
+ });
376
+ const sessions = coverage?.() ?? [];
377
+ const untestedHardwareFields = [
378
+ ...new Set(sessions.flatMap((entry) => entry.untestedHardwareFields)),
379
+ ];
380
+ await writeTestEvidence({
381
+ version: 1,
382
+ pipelineFingerprint: pipelineFingerprint(pipeline),
383
+ kind,
384
+ status: "passed",
385
+ testName: name,
386
+ ...(nodeId === undefined ? {} : { nodeId }),
387
+ ...(sessions.length === 0
388
+ ? {}
389
+ : {
390
+ partial: sessions.some((entry) => !entry.complete),
391
+ untestedHardwareFields,
392
+ }),
393
+ });
394
+ } catch (error) {
395
+ await writeTestEvidence({
396
+ version: 1,
397
+ pipelineFingerprint: pipelineFingerprint(pipeline),
398
+ kind,
399
+ status:
400
+ error instanceof TaskUiInfrastructureError || error instanceof CodeNodeInfrastructureError
401
+ ? "infrastructure_error"
402
+ : "failed",
403
+
404
+ testName: name,
405
+ diagnostic: errorMessage(error),
406
+ ...(nodeId === undefined ? {} : { nodeId }),
407
+ });
408
+ throw error;
409
+ }
410
+ };
411
+ const DEFAULT_CODE_TEST_TIMEOUT_MS = 30_000;
412
+
413
+ const codeTest = <Node extends AuthoredCodeNode>(
414
+ pipeline: PipelineDefinition,
415
+ node: Node,
416
+ name: string,
417
+ callback: (context: CodeTestContext<Node>) => Promise<void> | void,
418
+ options: TypedTestOptions = {},
419
+ ) => {
420
+ const register = matchesTestFilter(pipeline, node.id) ? vitestTest : vitestTest.skip;
421
+ register(
422
+ `${typedTestNamePrefix(pipelineFingerprint(pipeline), node.id)} ${name}`,
423
+ { timeout: options.timeout ?? DEFAULT_CODE_TEST_TIMEOUT_MS },
424
+ async () => {
425
+ assertTestNodeInPipeline(pipeline, node);
426
+ let executed = false;
427
+ await runTypedTest({
428
+ pipeline,
429
+ nodeId: node.id,
430
+ kind: "code",
431
+ name,
432
+ wasExecuted: () => executed,
433
+ execute: async () =>
434
+ callback({
435
+ node,
436
+ run: async (inputs, runOptions) => {
437
+ executed = true;
438
+ return runCodeNode(node, inputs, runOptions);
439
+ },
440
+ }),
441
+ });
442
+ },
443
+ );
444
+ };
445
+
446
+ const wrapUiSession = (session: TaskUiSession, onSubmit: () => void): TaskUiSession => ({
447
+ ...session,
448
+ submit: async () => {
449
+ const output = await session.submit();
450
+ onSubmit();
451
+ return output;
452
+ },
453
+ });
454
+ const DEFAULT_UI_TEST_TIMEOUT_MS = 60_000;
455
+ const DEFAULT_PIPELINE_TEST_TIMEOUT_MS = 120_000;
456
+
457
+ const uiTest = <Node extends TaskUiNode>(
458
+ pipeline: PipelineDefinition,
459
+ node: Node,
460
+ name: string,
461
+ callback: (context: UiTestContext<Node>) => Promise<void> | void,
462
+ options: TypedTestOptions = {},
463
+ ) => {
464
+ const register = matchesTestFilter(pipeline, node.id) ? vitestTest : vitestTest.skip;
465
+ register(
466
+ `${typedTestNamePrefix(pipelineFingerprint(pipeline), node.id)} ${name}`,
467
+ { timeout: options.timeout ?? DEFAULT_UI_TEST_TIMEOUT_MS },
468
+ async ({ ui }) => {
469
+ assertTestNodeInPipeline(pipeline, node);
470
+ let submitted = false;
471
+ const sessions: TaskUiCoverage[] = [];
472
+ await runTypedTest({
473
+ pipeline,
474
+ nodeId: node.id,
475
+ kind: "ui",
476
+ name,
477
+ wasExecuted: () => submitted,
478
+ coverage: () => sessions,
479
+ execute: async () =>
480
+ callback({
481
+ node,
482
+ pipeline,
483
+ render: async (options) => {
484
+ const session = await ui.render(pipeline, node, options);
485
+ sessions.push(session.coverage);
486
+ return wrapUiSession(session, () => {
487
+ submitted = true;
488
+ });
489
+ },
490
+ }),
491
+ });
492
+ },
493
+ );
494
+ };
495
+
496
+ const pipelineTest = (
497
+ pipeline: PipelineDefinition,
498
+ name: string,
499
+ callback: (context: PipelineTestContext) => Promise<void> | void,
500
+ options: TypedTestOptions = {},
501
+ ) => {
502
+ const register = matchesTestFilter(pipeline, undefined) ? vitestTest : vitestTest.skip;
503
+ const execute = async (ui?: UiTestFixtures["ui"]) => {
504
+ let executed = pipeline.graph.nodes.every(
505
+ (node) => node.type !== "code" && node.type !== "generation" && node.type !== "quorum",
506
+ );
507
+ const sessions: TaskUiCoverage[] = [];
508
+ await runTypedTest({
509
+ pipeline,
510
+ kind: "pipeline",
511
+ name,
512
+ wasExecuted: () => executed,
513
+ coverage: () => sessions,
514
+ execute: async () =>
515
+ callback({
516
+ pipeline,
517
+ runCode: async (node, inputs, options) => {
518
+ assertTestNodeInPipeline(pipeline, node);
519
+ executed = true;
520
+ return runCodeNode(node, inputs, options);
521
+ },
522
+ renderUi: async (node, options) => {
523
+ assertTestNodeInPipeline(pipeline, node);
524
+ assert(ui, `Pipeline test "${name}" cannot render UI without a human-work node`);
525
+ const session = await ui.render(pipeline, node, options);
526
+ sessions.push(session.coverage);
527
+ return wrapUiSession(session, () => {
528
+ executed = true;
529
+ });
530
+ },
531
+ }),
532
+ });
533
+ };
534
+ const hasUiNode = pipeline.graph.nodes.some(
535
+ (node) => node.type === "generation" || node.type === "quorum",
536
+ );
537
+ const registeredName = `${typedTestNamePrefix(pipelineFingerprint(pipeline))} ${name}`;
538
+ if (hasUiNode)
539
+ register(
540
+ registeredName,
541
+ { timeout: options.timeout ?? DEFAULT_PIPELINE_TEST_TIMEOUT_MS },
542
+ async ({ ui }) => execute(ui),
543
+ );
544
+ else
545
+ register(registeredName, { timeout: options.timeout ?? DEFAULT_PIPELINE_TEST_TIMEOUT_MS }, () =>
546
+ execute(),
547
+ );
548
+ };
549
+
550
+ const exportedTest = process.env[TEST_TYPED_ONLY_ENV] === "1" ? vitestTest.skip : vitestTest;
551
+
552
+ export const test = Object.assign(exportedTest, {
553
+ code: codeTest,
554
+ ui: uiTest,
555
+ pipeline: pipelineTest,
556
+ });
557
+ export { expect };