@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
@@ -1,11 +1,20 @@
1
1
  import { Command, CommanderError, InvalidArgumentError } from "commander";
2
2
  import { assert } from "es-toolkit";
3
+ import { writeFile } from "node:fs/promises";
3
4
  import { resolve } from "node:path";
4
5
  import { createInterface } from "node:readline/promises";
5
6
  import { createClient } from "../client";
6
7
  import type { PotatoClient } from "../client";
7
8
  import { readTolokaState } from "../state";
8
9
  import type { TolokaState } from "../state";
10
+ import {
11
+ CHECK_RESULT_FILE_ENV,
12
+ checkProject,
13
+ watchProjectChecks,
14
+ runFreshCheck,
15
+ type CheckProjectOptions,
16
+ type CheckProjectResult,
17
+ } from "./check";
9
18
  import { authStatus, login, logout } from "./auth";
10
19
  import type { AuthPrompt } from "./auth";
11
20
  import { commandNameFromArgv, createCliAnalytics } from "./analytics";
@@ -83,6 +92,7 @@ export type ProgramContext = {
83
92
  readonly environment: TolokaEnvironment;
84
93
  readonly io: CliIo;
85
94
  readonly injectedClient?: PotatoClient;
95
+ readonly runChecks?: typeof checkProject;
86
96
  readonly analytics?: CliAnalytics;
87
97
  };
88
98
 
@@ -202,6 +212,29 @@ export type CreatedProgram = {
202
212
  export function createProgram(context: ProgramContext): CreatedProgram {
203
213
  const program = new Command("toloka");
204
214
  const exitCode = { value: 0 };
215
+ const runChecks = context.runChecks ?? checkProject;
216
+ const runChecksInterruptibly = async (
217
+ options: CheckProjectOptions,
218
+ runner: (options: CheckProjectOptions) => Promise<CheckProjectResult> = runChecks,
219
+ ): Promise<{ result: CheckProjectResult; interruptedExitCode?: number }> => {
220
+ const abortController = new AbortController();
221
+ let interruptedExitCode: number | undefined;
222
+ const interrupt = (exitCode: number) => {
223
+ interruptedExitCode = exitCode;
224
+ abortController.abort();
225
+ };
226
+ const interruptSigint = () => interrupt(130);
227
+ const interruptSigterm = () => interrupt(143);
228
+ process.on("SIGINT", interruptSigint);
229
+ process.on("SIGTERM", interruptSigterm);
230
+ try {
231
+ const result = await runner({ ...options, signal: abortController.signal });
232
+ return { result, ...(interruptedExitCode === undefined ? {} : { interruptedExitCode }) };
233
+ } finally {
234
+ process.off("SIGINT", interruptSigint);
235
+ process.off("SIGTERM", interruptSigterm);
236
+ }
237
+ };
205
238
  let warnedAboutStaleSkills = false;
206
239
 
207
240
  program
@@ -324,8 +357,9 @@ export function createProgram(context: ProgramContext): CreatedProgram {
324
357
  .description("select and manage the current platform project");
325
358
  project
326
359
  .command("list")
360
+ .option("--limit <n>", "maximum projects to list", positiveInteger)
327
361
  .description("list remote projects this key can see")
328
- .action(async () => {
362
+ .action(async (options: { limit?: number }) => {
329
363
  const flags = globals();
330
364
  const { client, stored, state } = await resolveContext(flags, context);
331
365
  const selected = resolveSelectedProject(flags, context, stored, state);
@@ -333,6 +367,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
333
367
  client,
334
368
  io: context.io,
335
369
  ...(selected === undefined ? {} : { context: selected }),
370
+ ...(options.limit === undefined ? {} : { limit: options.limit }),
336
371
  });
337
372
  });
338
373
  project
@@ -440,9 +475,12 @@ export function createProgram(context: ProgramContext): CreatedProgram {
440
475
  .action(async () => {
441
476
  const flags = globals();
442
477
  const sourceRoot = resolveInitRoot(flags);
443
- const stored = await readCommandCredentials(flags, context);
444
478
  const state = await readTolokaState(sourceRoot);
445
- const selected = resolveSelectedProject(flags, context, stored, state);
479
+ const selected = resolveProjectContext({
480
+ flags,
481
+ environment: context.environment,
482
+ ...(state === undefined ? {} : { state }),
483
+ });
446
484
  const result = await initProject(
447
485
  sourceRoot,
448
486
  selected === undefined ? {} : { projectId: selected.id },
@@ -500,13 +538,89 @@ export function createProgram(context: ProgramContext): CreatedProgram {
500
538
  if (!result.valid) exitCode.value = 1;
501
539
  });
502
540
 
541
+ program
542
+ .command("check")
543
+ .argument("[pipeline]", "pipeline source directory or name")
544
+ .option("--node <node>", "check one code or human-work node by name or id")
545
+ .option("--json", "print the structured check report", false)
546
+ .option("--watch", "rerun checks when project files change", false)
547
+ .description("run the complete local source, type, and test gate")
548
+ .action(
549
+ async (
550
+ pipeline: string | undefined,
551
+ options: { node?: string; json: boolean; watch: boolean },
552
+ ) => {
553
+ const sourceRoot = await resolveSourceRoot(globals(), context.io);
554
+ const checkOptions = {
555
+ environment: context.environment,
556
+ sourceRoot,
557
+ ...(pipeline === undefined ? {} : { pipeline }),
558
+ ...(options.node === undefined ? {} : { node: options.node }),
559
+ ...(options.json ? { json: true } : {}),
560
+ };
561
+ const writeCheckOutput = async (output: string): Promise<void> => {
562
+ const resultPath = context.environment[CHECK_RESULT_FILE_ENV];
563
+ if (resultPath === undefined) context.io.stdout(output);
564
+ else await writeFile(resultPath, output, "utf8");
565
+ };
566
+ const checkRunner =
567
+ context.runChecks === undefined &&
568
+ context.environment[CHECK_RESULT_FILE_ENV] === undefined
569
+ ? runFreshCheck
570
+ : runChecks;
571
+ if (options.watch) {
572
+ const abortController = new AbortController();
573
+ const stop = () => abortController.abort();
574
+ process.on("SIGINT", stop);
575
+ process.on("SIGTERM", stop);
576
+ try {
577
+ await watchProjectChecks(
578
+ checkOptions,
579
+ async (result) => {
580
+ await writeCheckOutput(result.output);
581
+ exitCode.value = result.exitCode;
582
+ },
583
+ undefined,
584
+ abortController.signal,
585
+ );
586
+ } finally {
587
+ process.off("SIGINT", stop);
588
+ process.off("SIGTERM", stop);
589
+ }
590
+ return;
591
+ }
592
+ const { result, interruptedExitCode } = await runChecksInterruptibly(
593
+ checkOptions,
594
+ checkRunner,
595
+ );
596
+ await writeCheckOutput(result.output);
597
+ exitCode.value = interruptedExitCode ?? result.exitCode;
598
+ },
599
+ );
600
+
503
601
  program
504
602
  .command("apply")
505
- .description("make the platform match source")
603
+ .description("check source, then make the platform match it")
506
604
  .action(async () => {
507
- const { sourceRoot, client, projectId } = await resolveContext(globals(), context);
605
+ const sourceRoot = await resolveSourceRoot(globals(), context.io);
606
+ const { result: checked, interruptedExitCode } = await runChecksInterruptibly({
607
+ sourceRoot,
608
+ environment: context.environment,
609
+ });
610
+ context.io.stdout(checked.output);
611
+ if (interruptedExitCode !== undefined) {
612
+ exitCode.value = interruptedExitCode;
613
+ return;
614
+ }
615
+ if (checked.exitCode !== 0) {
616
+ exitCode.value = checked.exitCode;
617
+ return;
618
+ }
619
+ assert(checked.source, "Passing checks must return project source");
620
+ const { client, projectId } = await resolveContext(globals(), context);
508
621
  const result = await applyProject(client, {
509
622
  sourceRoot,
623
+ source: checked.source,
510
624
  ...(projectId === undefined ? {} : { projectId }),
511
625
  });
512
626
  context.io.stdout(result.output);
@@ -699,11 +813,17 @@ export function createProgram(context: ProgramContext): CreatedProgram {
699
813
  .description("operate on a live pipeline in the selected project");
700
814
  pipeline
701
815
  .command("list")
816
+ .option("--limit <n>", "maximum pipelines to list", positiveInteger)
702
817
  .description("list live pipelines in the selected project")
703
- .action(async () => {
818
+ .action(async (options: { limit?: number }) => {
704
819
  const { client, projectId, state } = await resolveContext(globals(), context);
705
820
  assert(projectId, MISSING_PROJECT_ID_MESSAGE);
706
- const result = await listLivePipelines({ client, projectId, state });
821
+ const result = await listLivePipelines({
822
+ client,
823
+ projectId,
824
+ state,
825
+ ...(options.limit === undefined ? {} : { limit: options.limit }),
826
+ });
707
827
  context.io.stdout(result.output);
708
828
  });
709
829
  pipeline
@@ -1005,27 +1125,35 @@ export async function runTolokaCli(
1005
1125
  environment: TolokaEnvironment = process.env,
1006
1126
  io: CliIo = terminalIo(),
1007
1127
  injectedClient?: PotatoClient,
1008
- analytics: CliAnalytics = createCliAnalytics({ environment }),
1128
+ analytics?: CliAnalytics,
1129
+ runChecks?: typeof checkProject,
1009
1130
  ): Promise<number> {
1131
+ const activeAnalytics =
1132
+ environment[CHECK_RESULT_FILE_ENV] === undefined
1133
+ ? (analytics ?? createCliAnalytics({ environment }))
1134
+ : undefined;
1010
1135
  const { program, exitCode } = createProgram({
1011
1136
  environment,
1012
1137
  io,
1013
- analytics,
1138
+ ...(activeAnalytics === undefined ? {} : { analytics: activeAnalytics }),
1014
1139
  ...(injectedClient === undefined ? {} : { injectedClient }),
1140
+ ...(runChecks === undefined ? {} : { runChecks }),
1015
1141
  });
1016
1142
 
1017
1143
  const startedAt = Date.now();
1018
1144
  const result = await runProgram(program, exitCode, argv, io);
1019
- try {
1020
- analytics.captureCommand({
1021
- command: commandNameFromArgv(argv),
1022
- durationMs: Date.now() - startedAt,
1023
- exitCode: result.code,
1024
- outcome: result.outcome,
1025
- ...(result.error === undefined ? {} : { error: result.error }),
1026
- });
1027
- await analytics.shutdown();
1028
- } catch {}
1145
+ if (activeAnalytics !== undefined) {
1146
+ try {
1147
+ activeAnalytics.captureCommand({
1148
+ command: commandNameFromArgv(argv),
1149
+ durationMs: Date.now() - startedAt,
1150
+ exitCode: result.code,
1151
+ outcome: result.outcome,
1152
+ ...(result.error === undefined ? {} : { error: result.error }),
1153
+ });
1154
+ await activeAnalytics.shutdown();
1155
+ } catch {}
1156
+ }
1029
1157
 
1030
1158
  return result.code;
1031
1159
  }
@@ -11,7 +11,10 @@ type ProjectRecord = { readonly id: string; readonly name: string; readonly team
11
11
 
12
12
  export type ProjectListApi = {
13
13
  readonly projects: {
14
- readonly listAll: (params?: object) => AsyncIterable<ProjectRecord>;
14
+ readonly listPage: (params: { readonly limit: number }) => Promise<{
15
+ readonly projects: readonly ProjectRecord[];
16
+ readonly pagination: { readonly total: number; readonly hasMore: boolean };
17
+ }>;
15
18
  };
16
19
  };
17
20
 
@@ -225,15 +228,22 @@ export async function listRemoteProjects(options: {
225
228
  readonly client: ProjectListApi;
226
229
  readonly context?: ProjectContext;
227
230
  readonly io: ProjectIo;
231
+ readonly limit?: number;
228
232
  }): Promise<{ readonly output: string }> {
229
- const lines: string[] = [];
230
- for await (const project of options.client.projects.listAll()) {
233
+ const limit = options.limit ?? 20;
234
+ const page = await options.client.projects.listPage({ limit });
235
+ const lines = page.projects.map((project) => {
231
236
  const mark = options.context?.id === project.id ? "*" : " ";
232
237
  const alias =
233
238
  options.context?.id === project.id && options.context.alias !== undefined
234
239
  ? `\t${options.context.alias}`
235
240
  : "";
236
- lines.push(`${mark}\t${project.id}\t${project.name}${alias}`);
241
+ return `${mark}\t${project.id}\t${project.name}${alias}`;
242
+ });
243
+ if (page.pagination.hasMore) {
244
+ lines.push(
245
+ `Showing ${page.projects.length} of ${page.pagination.total} projects. Raise --limit to see more.`,
246
+ );
237
247
  }
238
248
  const output = lines.length === 0 ? "No projects visible." : lines.join("\n");
239
249
  options.io.stdout(output);
package/src/cli/runs.ts CHANGED
@@ -84,21 +84,31 @@ export async function listLivePipelines(options: {
84
84
  readonly client: PotatoClient;
85
85
  readonly projectId: string;
86
86
  readonly state?: TolokaState;
87
+ readonly limit?: number;
87
88
  }): Promise<RunCommandResult> {
88
- const live = await collectLivePipelineRefs(options.client, options.projectId, options.state);
89
+ const limit = options.limit ?? 20;
90
+ const page = await options.client.pipelines.listPage({ projectId: options.projectId, limit });
91
+ const sourceDirectoryById = bindingByLiveId(options.projectId, options.state);
92
+ const live = page.pipelines
93
+ .map((pipeline) => toRef(pipeline, sourceDirectoryById))
94
+ .toSorted((left, right) => {
95
+ const byName = left.name.localeCompare(right.name);
96
+ return byName === 0 ? left.pipelineId.localeCompare(right.pipelineId) : byName;
97
+ });
89
98
  if (live.length === 0) {
90
99
  return { outcome: "reported", output: "This project has no pipelines." };
91
100
  }
92
- return {
93
- outcome: "reported",
94
- output: live
95
- .map((ref) =>
96
- ref.sourceDirectory === undefined
97
- ? `${ref.name}\t${ref.pipelineId}`
98
- : `${ref.name}\t${ref.pipelineId}\t${ref.sourceDirectory}`,
99
- )
100
- .join("\n"),
101
- };
101
+ const lines = live.map((ref) =>
102
+ ref.sourceDirectory === undefined
103
+ ? `${ref.name}\t${ref.pipelineId}`
104
+ : `${ref.name}\t${ref.pipelineId}\t${ref.sourceDirectory}`,
105
+ );
106
+ if (page.pagination.hasMore) {
107
+ lines.push(
108
+ `Showing ${live.length} of ${page.pagination.total} pipelines. Raise --limit to see more.`,
109
+ );
110
+ }
111
+ return { outcome: "reported", output: lines.join("\n") };
102
112
  }
103
113
 
104
114
  const collectLivePipelineRefs = async (
package/src/cli/skill.ts CHANGED
@@ -191,12 +191,23 @@ export const searchSkills = (
191
191
  skills: readonly SkillDescriptor[],
192
192
  query: string,
193
193
  ): SkillDescriptor[] => {
194
- const needle = query.trim().toLowerCase();
195
- return skills.filter((skill) =>
196
- [skill.id, skill.name, skill.description, ...skill.tags].some((field) =>
197
- field.toLowerCase().includes(needle),
198
- ),
199
- );
194
+ const needles = query.trim().toLowerCase().split(/\s+/u).filter(Boolean);
195
+ if (needles.length === 0) return [];
196
+
197
+ return skills
198
+ .map((skill, index) => {
199
+ const searchable = [skill.id, skill.name, skill.description, ...skill.tags]
200
+ .join("\n")
201
+ .toLowerCase();
202
+ return {
203
+ skill,
204
+ index,
205
+ score: needles.filter((needle) => searchable.includes(needle)).length,
206
+ };
207
+ })
208
+ .filter(({ score }) => score > 0)
209
+ .toSorted((left, right) => right.score - left.score || left.index - right.index)
210
+ .map(({ skill }) => skill);
200
211
  };
201
212
 
202
213
  export async function skillStatus(
@@ -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 };