@alumnium/test 0.24.1 → 0.25.0-alpha.1

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/src/index.js CHANGED
@@ -500,6 +500,12 @@ class ToolCallMasker {
500
500
  return [];
501
501
  }
502
502
  const json = parseJsonStructure(text);
503
+ if (!json && options.secretsOnly) {
504
+ logger4.debug(
505
+ `Failed tool call ${callId} output is not JSON, ignoring it`,
506
+ );
507
+ return [];
508
+ }
503
509
  if (!json) {
504
510
  logger4.info(
505
511
  `Tool call ${callId} output is not a JSON object or array, ignoring it for masking. ` +
@@ -720,10 +726,418 @@ class Masker {
720
726
  );
721
727
  }
722
728
  }
729
+ // test/src/player/BashExecutor.ts
730
+ import { spawn } from "node:child_process";
731
+ import z2 from "zod";
732
+ import { Telemetry as Telemetry5 } from "@alumnium/core/internal";
733
+ var { logger: logger5 } = Telemetry5.get(
734
+ "packages/typescript/test/src/player/BashExecutor.ts",
735
+ );
736
+ var BASH_TOOL = "Bash";
737
+ var DEFAULT_TIMEOUT_MS = 120000;
738
+ var BashInput = z2.object({
739
+ command: z2.string(),
740
+ timeout: z2.number().positive().optional(),
741
+ });
742
+
743
+ class BashExecutor {
744
+ #props;
745
+ constructor(props) {
746
+ this.#props = props;
747
+ }
748
+ handles(call) {
749
+ return !call.server && call.tool === BASH_TOOL;
750
+ }
751
+ async execute(call) {
752
+ const { command, timeout } = BashInput.parse(call.input);
753
+ if (!this.#allows(command))
754
+ throw new Error(`Command is not allowed to replay: ${command}`);
755
+ logger5.debug(`Running command: ${command}`);
756
+ const { code, stdout, stderr } = await this.#run(command, {
757
+ cwd: this.#props.cwd,
758
+ env: { ...process.env, ...this.#props.env },
759
+ timeoutMs: timeout ?? this.#props.timeoutMs ?? DEFAULT_TIMEOUT_MS,
760
+ });
761
+ if (code === 0) return { content: stdout, isError: false };
762
+ return {
763
+ content: [stdout, stderr, `Exit code ${code}`].filter(Boolean).join(`
764
+ `),
765
+ isError: true,
766
+ };
767
+ }
768
+ #allows(command) {
769
+ return this.#props.allow.some((allowed) =>
770
+ typeof allowed === "string"
771
+ ? command.startsWith(allowed)
772
+ : allowed.test(command),
773
+ );
774
+ }
775
+ #run(command, options) {
776
+ return new Promise((resolve, reject) => {
777
+ const child = spawn("bash", ["-c", command], {
778
+ cwd: options.cwd,
779
+ env: options.env,
780
+ timeout: options.timeoutMs,
781
+ stdio: ["ignore", "pipe", "pipe"],
782
+ });
783
+ let stdout = "";
784
+ let stderr = "";
785
+ child.stdout.setEncoding("utf8").on("data", (data) => (stdout += data));
786
+ child.stderr.setEncoding("utf8").on("data", (data) => (stderr += data));
787
+ child.on("error", reject);
788
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
789
+ });
790
+ }
791
+ }
792
+ // test/src/player/McpExecutor.ts
793
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
794
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
795
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
796
+ import {
797
+ ALUMNIUM_CORE_VERSION,
798
+ Telemetry as Telemetry6,
799
+ } from "@alumnium/core/internal";
800
+ var { logger: logger6 } = Telemetry6.get(
801
+ "packages/typescript/test/src/player/McpExecutor.ts",
802
+ );
803
+ var DEFAULT_RETRIES = 2;
804
+ var DEFAULT_RETRY_DELAY_MS = 1000;
805
+
806
+ class McpExecutor {
807
+ #servers;
808
+ #retries;
809
+ #retryDelayMs;
810
+ #clients = new Map();
811
+ constructor(props) {
812
+ this.#servers = props.servers;
813
+ this.#retries = props.retries ?? DEFAULT_RETRIES;
814
+ this.#retryDelayMs = props.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
815
+ }
816
+ handles(call) {
817
+ return Boolean(call.server && call.server in this.#servers);
818
+ }
819
+ async execute(call, options = {}) {
820
+ const toolKey = Recording.toolKey(call);
821
+ const client = await this.#client(call.server);
822
+ for (let attempt = 0; ; attempt++) {
823
+ const canRetry = attempt < this.#retries;
824
+ const delayMs = this.#retryDelayMs * 2 ** attempt;
825
+ try {
826
+ const result = await client.callTool({
827
+ name: call.tool,
828
+ arguments: call.input,
829
+ ...(options.meta ? { _meta: options.meta } : {}),
830
+ });
831
+ const toolResult = {
832
+ content: result.content ?? [],
833
+ isError: Boolean(result.isError),
834
+ };
835
+ if (!toolResult.isError || !canRetry) return toolResult;
836
+ logger6.warn(`Tool '${toolKey}' reported an error, retrying`);
837
+ } catch (error) {
838
+ if (!canRetry) throw error;
839
+ logger6.warn(`Tool '${toolKey}' failed, retrying: {error}`, { error });
840
+ }
841
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
842
+ }
843
+ }
844
+ async close() {
845
+ const clients = [...this.#clients];
846
+ this.#clients.clear();
847
+ await Promise.all(
848
+ clients.map(async ([name, client]) => {
849
+ if (this.#servers[name]?.type === "client") return;
850
+ try {
851
+ await (await client).close();
852
+ } catch (error) {
853
+ logger6.warn(`Failed to close MCP server '${name}': {error}`, {
854
+ error,
855
+ });
856
+ }
857
+ }),
858
+ );
859
+ }
860
+ #client(name) {
861
+ let client = this.#clients.get(name);
862
+ if (!client) {
863
+ client = this.#connect(name);
864
+ client.catch(() => this.#clients.delete(name));
865
+ this.#clients.set(name, client);
866
+ }
867
+ return client;
868
+ }
869
+ async #connect(name) {
870
+ const server = this.#servers[name];
871
+ if (!server) throw new Error(`Unknown MCP server '${name}'`);
872
+ if (server.type === "client") return server.client;
873
+ logger6.debug(`Connecting to MCP server '${name}'`);
874
+ const transport =
875
+ server.type === "http"
876
+ ? new StreamableHTTPClientTransport(new URL(server.url), {
877
+ requestInit: { headers: server.headers ?? {} },
878
+ })
879
+ : new StdioClientTransport({
880
+ command: server.command,
881
+ args: server.args ?? [],
882
+ env: { ...this.#definedEnv(), ...server.env },
883
+ ...(server.cwd ? { cwd: server.cwd } : {}),
884
+ stderr: "inherit",
885
+ });
886
+ const client = new Client({
887
+ name: "alumnium-test-player",
888
+ version: ALUMNIUM_CORE_VERSION,
889
+ });
890
+ await client.connect(transport);
891
+ return client;
892
+ }
893
+ #definedEnv() {
894
+ return Object.fromEntries(
895
+ Object.entries(process.env).filter((entry) => entry[1] !== undefined),
896
+ );
897
+ }
898
+ }
899
+ // test/src/player/Player.ts
900
+ import z3 from "zod";
901
+ import { Env as Env2, Telemetry as Telemetry7 } from "@alumnium/core/internal";
902
+
903
+ // test/src/masker/alumniumMaskerProps.ts
904
+ var DEFAULT_ALUMNIUM_SERVER = "alumnium";
905
+ var ALUMNIUM_START_TOOL = "start";
906
+ function alumniumMaskerProps(props, server = DEFAULT_ALUMNIUM_SERVER) {
907
+ return {
908
+ ...props,
909
+ registerResponsePaths: {
910
+ ...alumniumRegisterResponsePaths(server),
911
+ ...props.registerResponsePaths,
912
+ },
913
+ wholeValueOnlyTools: [`${server}/*`, ...(props.wholeValueOnlyTools ?? [])],
914
+ };
915
+ }
916
+ function alumniumRegisterResponsePaths(server) {
917
+ return {
918
+ [`${server}/*`]: [],
919
+ [`${server}/${ALUMNIUM_START_TOOL}`]: ["id"],
920
+ };
921
+ }
922
+
923
+ // test/src/player/Player.ts
924
+ var { logger: logger7 } = Telemetry7.get(
925
+ "packages/typescript/test/src/player/Player.ts",
926
+ );
927
+ var START_TOOL = "start";
928
+ var STOP_TOOL = "stop";
929
+ var CHECK_TOOL = "check";
930
+ var NO_CACHE_META = { "alumnium.ai/cache": false };
931
+ var NO_CHANGE_ANALYSIS_META = { "alumnium.ai/change-analysis": false };
932
+ var DEFAULT_SKIP_TOOLS = ["ToolSearch", "TodoWrite"];
933
+ var DriverOutput = z3.object({ id: z3.string() });
934
+ var CheckOutput = z3.object({ result: z3.enum(["success", "failure"]) });
935
+
936
+ class Player {
937
+ #options;
938
+ #maskerProps;
939
+ constructor(props) {
940
+ const {
941
+ executor,
942
+ alumniumServer = DEFAULT_ALUMNIUM_SERVER,
943
+ skipTools = DEFAULT_SKIP_TOOLS,
944
+ checkConfirmations,
945
+ observer = {},
946
+ ...maskerProps
947
+ } = props;
948
+ this.#options = {
949
+ executor,
950
+ server: alumniumServer,
951
+ skipTools: new Set(skipTools),
952
+ checkConfirmations,
953
+ observer,
954
+ };
955
+ this.#maskerProps = alumniumMaskerProps(maskerProps, alumniumServer);
956
+ }
957
+ async play(recording) {
958
+ const run = new PlayerRun(this.#options, new Masker(this.#maskerProps));
959
+ try {
960
+ return await run.play(recording);
961
+ } finally {
962
+ await run.stopDrivers();
963
+ }
964
+ }
965
+ }
966
+
967
+ class Diverged extends Error {}
968
+
969
+ class PlayerRun {
970
+ #options;
971
+ #masker;
972
+ #driverIds = new Set();
973
+ constructor(options, masker) {
974
+ this.#options = options;
975
+ this.#masker = masker;
976
+ }
977
+ get #observer() {
978
+ return this.#options.observer;
979
+ }
980
+ async play(recording) {
981
+ const stepsCount = Recording.executableStepsCount(recording);
982
+ let stepNumber = 0;
983
+ for (const [stepIndex, step] of recording.steps.entries()) {
984
+ if (step.kind === "narration") {
985
+ this.#observer.onNarration?.(step);
986
+ continue;
987
+ }
988
+ logger7.info(`Replaying step ${++stepNumber}/${stepsCount}`);
989
+ try {
990
+ await this.#playToolStep(step);
991
+ } catch (error) {
992
+ const message = error instanceof Diverged ? error.message : `${error}`;
993
+ logger7.error(`Replay diverged at step ${stepNumber}: ${message}`);
994
+ return { status: "diverged", error: message, stepIndex };
995
+ }
996
+ }
997
+ logger7.info(`Replayed all ${stepsCount} steps`);
998
+ return { status: "replayed", verdict: recording.verdict };
999
+ }
1000
+ async stopDrivers() {
1001
+ for (const id of this.#driverIds) {
1002
+ try {
1003
+ logger7.info(`Stopping driver ${id} the replay did not stop`);
1004
+ await this.#options.executor.execute({
1005
+ server: this.#options.server,
1006
+ tool: STOP_TOOL,
1007
+ input: { id, save_cache: false },
1008
+ });
1009
+ } catch (error) {
1010
+ logger7.warn(`Failed to stop driver ${id}: {error}`, { error });
1011
+ }
1012
+ }
1013
+ this.#driverIds.clear();
1014
+ }
1015
+ async #playToolStep(step) {
1016
+ const { call: recordedCall, result: recordedResult } = step;
1017
+ const { id, ...callProps } = recordedCall;
1018
+ const toolKey = Recording.toolKey(recordedCall);
1019
+ if (recordedResult.isError)
1020
+ return this.#skip(recordedCall, "it failed when recorded");
1021
+ if (!this.#options.executor.handles(callProps)) {
1022
+ if (this.#options.skipTools.has(toolKey))
1023
+ return this.#skip(recordedCall, "it only exists inside the agent");
1024
+ throw new Diverged(`No executor for tool '${toolKey}'`);
1025
+ }
1026
+ const call = {
1027
+ ...callProps,
1028
+ input: this.#masker.unmaskInput(toolKey, recordedCall.input),
1029
+ };
1030
+ const unresolved = Masker.findUnresolvedMasks(call.input);
1031
+ if (unresolved.length)
1032
+ throw new Diverged(
1033
+ `Tool '${toolKey}' input has unresolved masks: ${unresolved.join(", ")}`,
1034
+ );
1035
+ const result = await this.#execute(
1036
+ call,
1037
+ call.server === this.#options.server && call.tool === START_TOOL
1038
+ ? { meta: NO_CHANGE_ANALYSIS_META }
1039
+ : undefined,
1040
+ );
1041
+ if (result.isError)
1042
+ throw new Diverged(
1043
+ `Tool '${toolKey}' failed: ${contentTexts(result.content).join(`
1044
+ `)}`,
1045
+ );
1046
+ this.#masker.registerOutput(id, result.content, toolKey);
1047
+ if (call.server !== this.#options.server) return;
1048
+ switch (call.tool) {
1049
+ case START_TOOL: {
1050
+ const driverId = this.#parseOutput(result.content, DriverOutput)?.id;
1051
+ if (driverId) this.#driverIds.add(driverId);
1052
+ break;
1053
+ }
1054
+ case STOP_TOOL: {
1055
+ const driverId = DriverOutput.safeParse(call.input).data?.id;
1056
+ if (driverId) this.#driverIds.delete(driverId);
1057
+ break;
1058
+ }
1059
+ case CHECK_TOOL:
1060
+ await this.#compareCheck(call, recordedResult, result);
1061
+ break;
1062
+ }
1063
+ }
1064
+ async #execute(call, options) {
1065
+ this.#observer.onToolCall?.(call);
1066
+ const result = await this.#options.executor.execute(call, options);
1067
+ this.#observer.onToolResult?.(call, result);
1068
+ return result;
1069
+ }
1070
+ #skip(call, reason) {
1071
+ logger7.info(`Skipping tool '${Recording.toolKey(call)}', ${reason}`);
1072
+ this.#observer.onToolSkipped?.(call, reason);
1073
+ }
1074
+ async #compareCheck(call, recorded, replayed) {
1075
+ const confirmations =
1076
+ this.#options.checkConfirmations ?? Env2.ALUMNIUM_CHECK_CONFIRMATIONS;
1077
+ const expected = this.#checkVerdict(recorded);
1078
+ let actual = this.#checkVerdict(replayed);
1079
+ let outcome;
1080
+ if (!expected || !actual) {
1081
+ throw new Diverged(
1082
+ `Cannot read '${Recording.toolKey(call)}' verdict to compare`,
1083
+ );
1084
+ } else if (expected === actual) {
1085
+ outcome = "agreed";
1086
+ } else if (expected === "failure") {
1087
+ outcome = "improved";
1088
+ } else {
1089
+ let attempt = 0;
1090
+ while (actual !== expected && attempt++ < confirmations) {
1091
+ logger7.info(`Re-asking check ${attempt}/${confirmations}`);
1092
+ const result = await this.#execute(call, { meta: NO_CACHE_META });
1093
+ actual = result.isError ? null : this.#checkVerdict(result);
1094
+ }
1095
+ outcome = actual === expected ? "unstable" : "disagreed";
1096
+ }
1097
+ this.#observer.onCheck?.(call, outcome);
1098
+ if (outcome === "disagreed")
1099
+ throw new Diverged(
1100
+ `Check '${call.input["statement"]}' failed where it passed when recorded` +
1101
+ (confirmations
1102
+ ? `, re-asked ${confirmations} times with the cache bypassed`
1103
+ : ""),
1104
+ );
1105
+ }
1106
+ #checkVerdict(result) {
1107
+ return this.#parseOutput(result.content, CheckOutput)?.result ?? null;
1108
+ }
1109
+ #parseOutput(content, schema) {
1110
+ for (const text of contentTexts(content)) {
1111
+ const parsed = schema.safeParse(parseJson(text));
1112
+ if (parsed.success) return parsed.data;
1113
+ }
1114
+ return null;
1115
+ }
1116
+ }
1117
+ // test/src/player/ToolExecutor.ts
1118
+ function composeExecutors(...executors) {
1119
+ const find = (call) => executors.find((executor) => executor.handles(call));
1120
+ return {
1121
+ handles: (call) => Boolean(find(call)),
1122
+ async execute(call, options) {
1123
+ const executor = find(call);
1124
+ if (!executor)
1125
+ throw new Error(
1126
+ `No executor handles tool '${Recording.toolKey(call)}'`,
1127
+ );
1128
+ return executor.execute(call, options);
1129
+ },
1130
+ async close() {
1131
+ await Promise.all(
1132
+ executors.map(async (executor) => await executor.close?.()),
1133
+ );
1134
+ },
1135
+ };
1136
+ }
723
1137
  // test/src/Recorder.ts
724
1138
  import {
725
- ALUMNIUM_VERSION,
726
- Telemetry as Telemetry5,
1139
+ ALUMNIUM_CORE_VERSION as ALUMNIUM_CORE_VERSION2,
1140
+ Telemetry as Telemetry8,
727
1141
  } from "@alumnium/core/internal";
728
1142
 
729
1143
  // test/src/RecordingLeakError.ts
@@ -740,11 +1154,9 @@ class RecordingLeakError extends Error {
740
1154
  }
741
1155
 
742
1156
  // test/src/Recorder.ts
743
- var { logger: logger5 } = Telemetry5.get(
1157
+ var { logger: logger8 } = Telemetry8.get(
744
1158
  "packages/typescript/test/src/Recorder.ts",
745
1159
  );
746
- var DEFAULT_ALUMNIUM_SERVER = "alumnium";
747
- var ALUMNIUM_START_TOOL = "start";
748
1160
 
749
1161
  class Recorder {
750
1162
  #text;
@@ -759,24 +1171,13 @@ class Recorder {
759
1171
  this.#version = version ?? Recording.DEFAULT_VERSION;
760
1172
  this.#text = text;
761
1173
  this.#agent = agent;
762
- const server = alumniumServer ?? DEFAULT_ALUMNIUM_SERVER;
763
- this.#masker = new Masker({
764
- ...maskerProps,
765
- registerResponsePaths: {
766
- ...this.#alumniumRegisterResponsePaths(server),
767
- ...maskerProps.registerResponsePaths,
768
- },
769
- wholeValueOnlyTools: [
770
- `${server}/*`,
771
- ...(maskerProps.wholeValueOnlyTools ?? []),
772
- ],
773
- });
1174
+ this.#masker = new Masker(alumniumMaskerProps(maskerProps, alumniumServer));
774
1175
  }
775
1176
  onToolUse(use) {
776
1177
  this.#assertNotFinished();
777
1178
  const { id, server, tool, input } = use;
778
1179
  const toolKey = Recording.toolKey({ server, tool });
779
- logger5.debug(`Recording tool use '${toolKey}' ({id})`, { id });
1180
+ logger8.debug(`Recording tool use '${toolKey}' ({id})`, { id });
780
1181
  this.#pendingCalls.set(id, {
781
1182
  id,
782
1183
  ...(server ? { server } : {}),
@@ -790,11 +1191,11 @@ class Recorder {
790
1191
  const isError = Boolean(toolResult.isError);
791
1192
  const call = this.#pendingCalls.get(id);
792
1193
  if (!call) {
793
- logger5.debug(`No recorded tool use for result '${id}', ignoring it`);
1194
+ logger8.debug(`No recorded tool use for result '${id}', ignoring it`);
794
1195
  return;
795
1196
  }
796
1197
  this.#pendingCalls.delete(id);
797
- logger5.debug(`Recording '${call.tool}' tool result`);
1198
+ logger8.debug(`Recording '${call.tool}' tool result`);
798
1199
  this.#masker.registerOutput(id, content, Recording.toolKey(call), {
799
1200
  secretsOnly: isError,
800
1201
  });
@@ -816,7 +1217,7 @@ class Recorder {
816
1217
  finish(verdict) {
817
1218
  this.#assertNotFinished();
818
1219
  if (this.#pendingCalls.size)
819
- logger5.warn(
1220
+ logger8.warn(
820
1221
  `Dropping ${this.#pendingCalls.size} tool calls with no result: ${[
821
1222
  ...this.#pendingCalls.values(),
822
1223
  ]
@@ -829,7 +1230,7 @@ class Recorder {
829
1230
  version: this.#version,
830
1231
  text: this.#text,
831
1232
  agent: this.#agent,
832
- alumnium: { version: ALUMNIUM_VERSION },
1233
+ alumnium: { version: ALUMNIUM_CORE_VERSION2 },
833
1234
  hostVars: [],
834
1235
  steps: this.#steps.map((step) => this.#maskStep(step)),
835
1236
  verdict: {
@@ -864,17 +1265,21 @@ class Recorder {
864
1265
  };
865
1266
  }
866
1267
  }
867
- #alumniumRegisterResponsePaths(server = DEFAULT_ALUMNIUM_SERVER) {
868
- return {
869
- [`${server}/*`]: [],
870
- [`${server}/${ALUMNIUM_START_TOOL}`]: ["id"],
871
- };
872
- }
873
1268
  #assertNotFinished() {
874
1269
  if (this.#recording) throw new Error("Recording is already finished");
875
1270
  }
876
1271
  }
877
- export { FileTestCache, Masker, Recorder, Recording, RecordingLeakError };
1272
+ export {
1273
+ BashExecutor,
1274
+ FileTestCache,
1275
+ Masker,
1276
+ McpExecutor,
1277
+ Player,
1278
+ Recorder,
1279
+ Recording,
1280
+ RecordingLeakError,
1281
+ composeExecutors,
1282
+ };
878
1283
 
879
- //# debugId=E1FEB499A312812964756E2164756E21
1284
+ //# debugId=88B58893F83F47D164756E2164756E21
880
1285
  //# sourceMappingURL=index.js.map