@toloka-platform/sdk 0.1.0-preview.6 → 0.1.0-preview.8
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.
- package/README.md +10 -15
- package/dist/cli/toloka.js +209 -121
- package/dist/cli/toloka.js.jsc +0 -0
- package/dist/cli/toloka.js.jsc.sha256 +1 -1
- package/dist/cli/toloka.js.sha256 +1 -1
- package/docs/authoring.md +20 -9
- package/docs/cli-v2.md +3 -3
- package/docs/cli.md +44 -16
- package/docs/getting-started.md +7 -12
- package/docs/limitations.md +6 -6
- package/docs/testing-examples.md +248 -0
- package/docs/testing.md +115 -0
- package/examples/face-image-collection/README.md +7 -1
- package/examples/face-image-collection/fields.ts +20 -8
- package/examples/face-image-collection/nodes/annotate-faces/node.ts +1 -9
- package/examples/face-image-collection/nodes/order-faces/node.ts +3 -5
- package/examples/face-image-collection/pipeline.browser.integration.spec.ts +78 -0
- package/examples/video-pair-rating/README.md +14 -6
- package/examples/video-pair-rating/nodes/adjudicate/node.ts +1 -3
- package/examples/video-pair-rating/nodes/adjudicate_op/node.ts +1 -3
- package/examples/video-pair-rating/nodes/adjudication_match/node.ts +15 -17
- package/examples/video-pair-rating/nodes/assemble/node.ts +25 -27
- package/examples/video-pair-rating/nodes/cascade/node.ts +24 -26
- package/examples/video-pair-rating/nodes/finalize_overall/node.ts +17 -19
- package/examples/video-pair-rating/nodes/merge_adjudication/node.ts +13 -15
- package/examples/video-pair-rating/nodes/merge_op_adjudication/node.ts +12 -14
- package/examples/video-pair-rating/nodes/op_quorum_resolve/node.ts +13 -15
- package/examples/video-pair-rating/nodes/overall_preference_pick/node.ts +1 -1
- package/examples/video-pair-rating/nodes/rate_pair/node.ts +1 -1
- package/examples/video-pair-rating/nodes/route_adjudication/node.ts +1 -3
- package/examples/video-pair-rating/nodes/route_op_adjudication/node.ts +5 -7
- package/examples/video-pair-rating/nodes/route_overall/node.ts +1 -3
- package/examples/video-pair-rating/nodes/shuffle_ab/node.ts +11 -13
- package/examples/video-pair-rating/pipeline.browser.integration.spec.ts +267 -0
- package/openapi/gen/api.ts +48 -11
- package/openapi/gen/models/createDatasetExportBody.ts +3 -3
- package/openapi/gen/models/createDatasetExportBodyOneOf.ts +6 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfFormat.ts +2 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThree.ts +10 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeContract.ts +2 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeFormat.ts +2 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilter.ts +9 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldSourcesItem.ts +6 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldSourcesItemSourcesItem.ts +4 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldSourcesItemSourcesItemOneOf.ts +5 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldSourcesItemSourcesItemOneOfTwo.ts +5 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItem.ts +12 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemOperator.ts +2 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemSourcesItem.ts +4 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemSourcesItemOneOf.ts +5 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemSourcesItemOneOfTwo.ts +5 -0
- package/openapi/gen/models/createDatasetExportBodyOneOfThreeItemFilterFieldValuesItemValue.ts +2 -0
- package/openapi/gen/models/datasetExportReport.ts +9 -0
- package/openapi/gen/models/datasetExportReportErrorsItem.ts +5 -0
- package/openapi/gen/models/datasetExportReportTokens.ts +7 -0
- package/openapi/gen/models/datasetItem.ts +0 -2
- package/openapi/gen/models/exportJob.ts +2 -0
- package/openapi/gen/models/exportJobReport.ts +3 -0
- package/openapi/gen/models/index.ts +48 -1
- package/openapi/gen/models/sampleDatasetItemsBody.ts +4 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOf.ts +8 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilter.ts +9 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldSourcesItem.ts +6 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldSourcesItemSourcesItem.ts +4 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldSourcesItemSourcesItemOneOf.ts +5 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldSourcesItemSourcesItemOneOfTwo.ts +5 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItem.ts +12 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemOperator.ts +2 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemSourcesItem.ts +4 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemSourcesItemOneOf.ts +5 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemSourcesItemOneOfTwo.ts +5 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfItemFilterFieldValuesItemValue.ts +2 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethree.ts +8 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilter.ts +9 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldSourcesItem.ts +6 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldSourcesItemSourcesItem.ts +4 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldSourcesItemSourcesItemOneOf.ts +5 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldSourcesItemSourcesItemOneOfTwo.ts +5 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItem.ts +12 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemOperator.ts +2 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemSourcesItem.ts +4 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemSourcesItemOneOf.ts +5 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemSourcesItemOneOfTwo.ts +5 -0
- package/openapi/gen/models/sampleDatasetItemsBodyAnyOfOnethreeItemFilterFieldValuesItemValue.ts +2 -0
- package/openapi/gen/models/sampleJob.ts +13 -0
- package/openapi/gen/models/sampleJobStatus.ts +2 -0
- package/openapi/gen/models/sampleResult.ts +5 -0
- package/package.json +23 -2
- package/skills/catalog.json +14 -6
- package/skills/toloka-build/SKILL.md +35 -12
- package/skills/toloka-build/references/node-semantics.md +32 -29
- package/skills/toloka-build/references/workflow-and-cli.md +61 -27
- package/skills/toloka-report-issue/references/report-template.md +1 -1
- package/src/authoring/index.ts +27 -26
- package/src/checks/ast.ts +32 -0
- package/src/checks/index.ts +14 -0
- package/src/checks/postprocess-collisions.ts +145 -0
- package/src/checks/task-ui-declarations.generated.ts +19 -0
- package/src/checks/task-ui-typecheck.ts +345 -0
- package/src/checks/typescript-vfs.d.ts +26 -0
- package/src/checks/typescript-vfs.js +5 -0
- package/src/cli/analytics.ts +9 -19
- package/src/cli/auto-update.ts +367 -0
- package/src/cli/boolean-flag.ts +13 -0
- package/src/cli/check.ts +977 -0
- package/src/cli/credentials.ts +3 -0
- package/src/cli/program.ts +184 -20
- package/src/cli/project-context.ts +14 -4
- package/src/cli/runs.ts +21 -11
- package/src/cli/settings.ts +93 -0
- package/src/cli/skill.ts +17 -6
- package/src/importer.ts +12 -16
- package/src/testing/code-node.ts +160 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/task-ui-browser.ts +120 -0
- package/src/testing/task-ui-validation.ts +135 -0
- package/src/testing/task-ui.ts +557 -0
- package/src/testing/test-evidence.ts +170 -0
- package/src/testing/types.ts +141 -0
- package/openapi/gen/models/datasetItemDuplicateOfSubmissionId.ts +0 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { AuthoredCodeNode } from "../authoring";
|
|
6
|
+
|
|
7
|
+
export type RunCodeNodeOptions = {
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
pythonExecutable?: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class CodeNodeExecutionError extends Error {
|
|
14
|
+
readonly exitCode: number | null;
|
|
15
|
+
readonly stderr: string;
|
|
16
|
+
|
|
17
|
+
constructor(message: string, exitCode: number | null, stderr: string) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "CodeNodeExecutionError";
|
|
20
|
+
this.exitCode = exitCode;
|
|
21
|
+
this.stderr = stderr;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class CodeNodeInfrastructureError extends Error {
|
|
26
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
27
|
+
super(message, options);
|
|
28
|
+
this.name = "CodeNodeInfrastructureError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const collectOutput = async (stream: NodeJS.ReadableStream): Promise<string> => {
|
|
33
|
+
const chunks: Buffer[] = [];
|
|
34
|
+
for await (const chunk of stream) {
|
|
35
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
36
|
+
}
|
|
37
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const staticEnvironment = (node: AuthoredCodeNode): Record<string, string> =>
|
|
41
|
+
Object.fromEntries(
|
|
42
|
+
(node.settings.envVars ?? [])
|
|
43
|
+
.filter((entry) => entry.type === "static")
|
|
44
|
+
.map((entry) => [entry.name, entry.value]),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const decodeCodeNodeOutput = (
|
|
48
|
+
nodeId: string,
|
|
49
|
+
serialized: string,
|
|
50
|
+
exitCode: number | null,
|
|
51
|
+
stderr: string,
|
|
52
|
+
): Record<string, unknown> => {
|
|
53
|
+
let output: unknown;
|
|
54
|
+
try {
|
|
55
|
+
output = JSON.parse(serialized);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new CodeNodeExecutionError(
|
|
58
|
+
`Code node ${nodeId} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
59
|
+
exitCode,
|
|
60
|
+
stderr,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (output === null || typeof output !== "object" || Array.isArray(output)) {
|
|
64
|
+
throw new CodeNodeExecutionError(
|
|
65
|
+
`Code node ${nodeId} returned a non-object value`,
|
|
66
|
+
exitCode,
|
|
67
|
+
stderr,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return output as Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const runCodeNode = async (
|
|
74
|
+
node: AuthoredCodeNode,
|
|
75
|
+
inputs: Record<string, unknown>,
|
|
76
|
+
{ env = {}, pythonExecutable = "python3", timeoutMs = 10_000 }: RunCodeNodeOptions = {},
|
|
77
|
+
): Promise<Record<string, unknown>> => {
|
|
78
|
+
const directory = await mkdtemp(join(tmpdir(), "toloka-code-node-"));
|
|
79
|
+
const sourcePath = join(directory, "node.py");
|
|
80
|
+
const mainTypesPath = join(directory, "main_types.py");
|
|
81
|
+
const runnerPath = join(directory, "runner.py");
|
|
82
|
+
const outputPath = join(directory, "output.json");
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await Promise.all([
|
|
86
|
+
writeFile(sourcePath, node.settings.code.code, "utf8"),
|
|
87
|
+
writeFile(
|
|
88
|
+
mainTypesPath,
|
|
89
|
+
"from typing import Any\n\nInputs = dict[str, Any]\nOutputs = dict[str, Any]\n",
|
|
90
|
+
"utf8",
|
|
91
|
+
),
|
|
92
|
+
writeFile(
|
|
93
|
+
runnerPath,
|
|
94
|
+
`import asyncio\nimport json\nimport sys\n\nfrom node import main\n\nasync def run():\n result = await main(json.load(sys.stdin))\n with open(sys.argv[1], "w", encoding="utf-8") as output:\n json.dump(result, output)\n\nasyncio.run(run())\n`,
|
|
95
|
+
"utf8",
|
|
96
|
+
),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const child = spawn(pythonExecutable, [runnerPath, outputPath], {
|
|
100
|
+
cwd: directory,
|
|
101
|
+
env: { ...process.env, ...staticEnvironment(node), ...env },
|
|
102
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
103
|
+
});
|
|
104
|
+
const {
|
|
105
|
+
promise: closed,
|
|
106
|
+
resolve: resolveClose,
|
|
107
|
+
reject: rejectClose,
|
|
108
|
+
} = Promise.withResolvers<number | null>();
|
|
109
|
+
child.once("close", resolveClose);
|
|
110
|
+
child.once("error", (error) =>
|
|
111
|
+
rejectClose(
|
|
112
|
+
new CodeNodeInfrastructureError(
|
|
113
|
+
`Unable to start Python executable ${JSON.stringify(pythonExecutable)}`,
|
|
114
|
+
{ cause: error },
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
let timedOut = false;
|
|
119
|
+
const timeout = setTimeout(() => {
|
|
120
|
+
timedOut = true;
|
|
121
|
+
child.kill("SIGKILL");
|
|
122
|
+
}, timeoutMs);
|
|
123
|
+
child.stdin.end(JSON.stringify(inputs));
|
|
124
|
+
|
|
125
|
+
const [exitCode, , stderr] = await Promise.all([
|
|
126
|
+
closed,
|
|
127
|
+
collectOutput(child.stdout),
|
|
128
|
+
collectOutput(child.stderr),
|
|
129
|
+
]).finally(() => clearTimeout(timeout));
|
|
130
|
+
|
|
131
|
+
if (timedOut) {
|
|
132
|
+
throw new CodeNodeExecutionError(
|
|
133
|
+
`Code node ${node.id} exceeded the ${timeoutMs}ms timeout`,
|
|
134
|
+
exitCode,
|
|
135
|
+
stderr,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (exitCode !== 0) {
|
|
139
|
+
throw new CodeNodeExecutionError(
|
|
140
|
+
`Code node ${node.id} exited with code ${exitCode ?? "unknown"}`,
|
|
141
|
+
exitCode,
|
|
142
|
+
stderr,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let serialized: string;
|
|
147
|
+
try {
|
|
148
|
+
serialized = await readFile(outputPath, "utf8");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw new CodeNodeExecutionError(
|
|
151
|
+
`Code node ${node.id} did not produce an output file: ${error instanceof Error ? error.message : String(error)}`,
|
|
152
|
+
exitCode,
|
|
153
|
+
stderr,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return decodeCodeNodeOutput(node.id, serialized, exitCode, stderr);
|
|
157
|
+
} finally {
|
|
158
|
+
await rm(directory, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { CodeNodeExecutionError, CodeNodeInfrastructureError, runCodeNode } from "./code-node";
|
|
2
|
+
export type { RunCodeNodeOptions } from "./code-node";
|
|
3
|
+
export { expect, test } from "./task-ui";
|
|
4
|
+
export { TaskUiInfrastructureError, TaskUiValidationError } from "./types";
|
|
5
|
+
export type {
|
|
6
|
+
RenderTaskUiOptions,
|
|
7
|
+
TaskUiComponentDescription,
|
|
8
|
+
TaskUiCoverage,
|
|
9
|
+
TaskUiEntityError,
|
|
10
|
+
TaskUiFixture,
|
|
11
|
+
TaskUiFormError,
|
|
12
|
+
TaskUiNode,
|
|
13
|
+
TaskUiSession,
|
|
14
|
+
TypedTestOptions,
|
|
15
|
+
} from "./types";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { assert } from "es-toolkit";
|
|
2
|
+
import { chromium, type Browser, type Page } from "playwright";
|
|
3
|
+
import {
|
|
4
|
+
TaskUiInfrastructureError,
|
|
5
|
+
type TaskUiComponentDescription,
|
|
6
|
+
type TbxBrowserRequest,
|
|
7
|
+
type TbxTemplate,
|
|
8
|
+
type TbxWindowApi,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_TBX_LAUNCHER_URL =
|
|
12
|
+
"https://tlkfrontprodpublic.blob.core.windows.net/template-builder-production/tb-jsx.agents/index.html?postprocess=true";
|
|
13
|
+
|
|
14
|
+
export function evaluateWithApi(
|
|
15
|
+
page: Page,
|
|
16
|
+
request: Extract<TbxBrowserRequest, { operation: "setConfig" }>,
|
|
17
|
+
): Promise<string>;
|
|
18
|
+
export function evaluateWithApi(
|
|
19
|
+
page: Page,
|
|
20
|
+
request: Extract<TbxBrowserRequest, { operation: "getComponentTree" }>,
|
|
21
|
+
): Promise<string>;
|
|
22
|
+
export function evaluateWithApi(
|
|
23
|
+
page: Page,
|
|
24
|
+
request: Extract<TbxBrowserRequest, { operation: "getComponentState" }>,
|
|
25
|
+
): Promise<Record<string, unknown>>;
|
|
26
|
+
export function evaluateWithApi(
|
|
27
|
+
page: Page,
|
|
28
|
+
request: Extract<TbxBrowserRequest, { operation: "getComponentValue" }>,
|
|
29
|
+
): Promise<unknown>;
|
|
30
|
+
export function evaluateWithApi(
|
|
31
|
+
page: Page,
|
|
32
|
+
request: Extract<TbxBrowserRequest, { operation: "setComponentValue" }>,
|
|
33
|
+
): Promise<void>;
|
|
34
|
+
export function evaluateWithApi(
|
|
35
|
+
page: Page,
|
|
36
|
+
request: Extract<TbxBrowserRequest, { operation: "getValidationErrors" }>,
|
|
37
|
+
): Promise<Array<{ componentId: string; error: string }>>;
|
|
38
|
+
export function evaluateWithApi(
|
|
39
|
+
page: Page,
|
|
40
|
+
request: Extract<TbxBrowserRequest, { operation: "getComponentsDescriptions" }>,
|
|
41
|
+
): Promise<TaskUiComponentDescription[]>;
|
|
42
|
+
export function evaluateWithApi(
|
|
43
|
+
page: Page,
|
|
44
|
+
request: Extract<TbxBrowserRequest, { operation: "getData" }>,
|
|
45
|
+
): Promise<Record<string, unknown> | undefined>;
|
|
46
|
+
export async function evaluateWithApi(page: Page, request: TbxBrowserRequest): Promise<unknown> {
|
|
47
|
+
assert(!page.isClosed(), "Task UI browser page is closed");
|
|
48
|
+
|
|
49
|
+
return page.evaluate(async (input) => {
|
|
50
|
+
const api: TbxWindowApi | undefined = Reflect.get(globalThis, "LAUNCHER_EMBED_API");
|
|
51
|
+
if (api === undefined) throw new Error("Task UI launcher API is unavailable");
|
|
52
|
+
|
|
53
|
+
switch (input.operation) {
|
|
54
|
+
case "setConfig":
|
|
55
|
+
return api.agents.setConfig(input.config);
|
|
56
|
+
case "getComponentTree":
|
|
57
|
+
return api.agents.getComponentTree("xml");
|
|
58
|
+
case "getComponentState":
|
|
59
|
+
return api.agents.getComponentState(input.componentId);
|
|
60
|
+
case "getComponentValue":
|
|
61
|
+
return api.agents.getComponentValue(input.componentId);
|
|
62
|
+
case "setComponentValue":
|
|
63
|
+
return api.agents.setComponentValue(input.componentId, input.value);
|
|
64
|
+
case "getValidationErrors":
|
|
65
|
+
return api.agents.getValidationErrors();
|
|
66
|
+
case "getComponentsDescriptions":
|
|
67
|
+
return api.agents.getComponentsDescriptions();
|
|
68
|
+
case "getData":
|
|
69
|
+
return api.getData();
|
|
70
|
+
}
|
|
71
|
+
}, request);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const selectComponentDocumentation = (
|
|
75
|
+
descriptions: TaskUiComponentDescription[],
|
|
76
|
+
names?: string[],
|
|
77
|
+
): Record<string, TaskUiComponentDescription> => {
|
|
78
|
+
const selected =
|
|
79
|
+
names === undefined
|
|
80
|
+
? descriptions
|
|
81
|
+
: descriptions.filter((description) => names.includes(description.name));
|
|
82
|
+
const byName = Object.fromEntries(selected.map((description) => [description.name, description]));
|
|
83
|
+
if (names !== undefined) {
|
|
84
|
+
const missing = names.filter((name) => !(name in byName));
|
|
85
|
+
assert(missing.length === 0, `Unknown Task UI components: ${missing.join(", ")}`);
|
|
86
|
+
}
|
|
87
|
+
return byName;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const readDataPath = (state: Record<string, unknown>): string | undefined => {
|
|
91
|
+
const path = state.dataPath;
|
|
92
|
+
return typeof path === "string" ? path : undefined;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const componentIds = (tree: string): string[] =>
|
|
96
|
+
[...tree.matchAll(/\bid="([^"]+)"/gu)].map((match) => match[1]!);
|
|
97
|
+
|
|
98
|
+
export const launchDefaultBrowser = async (): Promise<Browser> => {
|
|
99
|
+
try {
|
|
100
|
+
return await chromium.launch({ headless: true });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new TaskUiInfrastructureError(
|
|
103
|
+
"Local Task UI testing requires Playwright and Chromium. Run `bun add --dev playwright vitest` and `bunx playwright install chromium`.",
|
|
104
|
+
{ cause: error },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const waitForTaskUiApi = async (page: Page): Promise<void> => {
|
|
110
|
+
await page.waitForFunction(
|
|
111
|
+
() => {
|
|
112
|
+
const api: TbxWindowApi | undefined = Reflect.get(globalThis, "LAUNCHER_EMBED_API");
|
|
113
|
+
return api?.agents !== undefined;
|
|
114
|
+
},
|
|
115
|
+
undefined,
|
|
116
|
+
{ timeout: 15_000 },
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type { Browser, Page, TbxTemplate };
|
|
@@ -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
|
+
};
|