@alumnium/test 0.24.2 → 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/README.md CHANGED
@@ -1,3 +1,3 @@
1
1
  # @alumnium/test
2
2
 
3
- Alumnium test record and replay: agent-agnostic recordings, masking and the test cache. See [the main `alumnium` package page](https://www.npmjs.com/package/alumnium) for more details.
3
+ Alumnium test runner. See [the main `alumnium` package page](https://www.npmjs.com/package/alumnium) for more details.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alumnium/test",
3
- "version": "0.24.2",
4
- "description": "Alumnium test record and replay: agent-agnostic recordings, masking and the test cache",
3
+ "version": "0.25.0-alpha.1",
4
+ "description": "Alumnium test runner",
5
5
  "license": "MIT",
6
6
  "author": "Alex Rodionov <p0deje@gmail.com>",
7
7
  "contributors": [
@@ -32,8 +32,9 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@alumnium/core": "0.24.1",
35
+ "@alumnium/core": "0.25.0-alpha.1",
36
36
  "@js-fns/xxhash": "^0.1.0",
37
+ "@modelcontextprotocol/sdk": "^1.29.0",
37
38
  "zod": "^4.4.1"
38
39
  }
39
40
  }
package/src/index.cjs CHANGED
@@ -68,11 +68,15 @@ var __export = (target, all) => {
68
68
  // test/src/index.ts
69
69
  var exports_src = {};
70
70
  __export(exports_src, {
71
+ BashExecutor: () => BashExecutor,
71
72
  FileTestCache: () => FileTestCache,
72
73
  Masker: () => Masker,
74
+ McpExecutor: () => McpExecutor,
75
+ Player: () => Player,
73
76
  Recorder: () => Recorder,
74
77
  Recording: () => Recording,
75
78
  RecordingLeakError: () => RecordingLeakError,
79
+ composeExecutors: () => composeExecutors,
76
80
  });
77
81
  module.exports = __toCommonJS(exports_src);
78
82
 
@@ -585,6 +589,12 @@ class ToolCallMasker {
585
589
  return [];
586
590
  }
587
591
  const json = parseJsonStructure(text);
592
+ if (!json && options.secretsOnly) {
593
+ logger4.debug(
594
+ `Failed tool call ${callId} output is not JSON, ignoring it`,
595
+ );
596
+ return [];
597
+ }
588
598
  if (!json) {
589
599
  logger4.info(
590
600
  `Tool call ${callId} output is not a JSON object or array, ignoring it for masking. ` +
@@ -805,8 +815,421 @@ class Masker {
805
815
  );
806
816
  }
807
817
  }
808
- // test/src/Recorder.ts
818
+ // test/src/player/BashExecutor.ts
819
+ var import_node_child_process = require("node:child_process");
820
+ var import_zod2 = __toESM(require("zod"), 1);
809
821
  var import_internal5 = require("@alumnium/core/internal");
822
+ var { logger: logger5 } = import_internal5.Telemetry.get(
823
+ "packages/typescript/test/src/player/BashExecutor.ts",
824
+ );
825
+ var BASH_TOOL = "Bash";
826
+ var DEFAULT_TIMEOUT_MS = 120000;
827
+ var BashInput = import_zod2.default.object({
828
+ command: import_zod2.default.string(),
829
+ timeout: import_zod2.default.number().positive().optional(),
830
+ });
831
+
832
+ class BashExecutor {
833
+ #props;
834
+ constructor(props) {
835
+ this.#props = props;
836
+ }
837
+ handles(call) {
838
+ return !call.server && call.tool === BASH_TOOL;
839
+ }
840
+ async execute(call) {
841
+ const { command, timeout } = BashInput.parse(call.input);
842
+ if (!this.#allows(command))
843
+ throw new Error(`Command is not allowed to replay: ${command}`);
844
+ logger5.debug(`Running command: ${command}`);
845
+ const { code, stdout, stderr } = await this.#run(command, {
846
+ cwd: this.#props.cwd,
847
+ env: { ...process.env, ...this.#props.env },
848
+ timeoutMs: timeout ?? this.#props.timeoutMs ?? DEFAULT_TIMEOUT_MS,
849
+ });
850
+ if (code === 0) return { content: stdout, isError: false };
851
+ return {
852
+ content: [stdout, stderr, `Exit code ${code}`].filter(Boolean).join(`
853
+ `),
854
+ isError: true,
855
+ };
856
+ }
857
+ #allows(command) {
858
+ return this.#props.allow.some((allowed) =>
859
+ typeof allowed === "string"
860
+ ? command.startsWith(allowed)
861
+ : allowed.test(command),
862
+ );
863
+ }
864
+ #run(command, options) {
865
+ return new Promise((resolve, reject) => {
866
+ const child = import_node_child_process.spawn("bash", ["-c", command], {
867
+ cwd: options.cwd,
868
+ env: options.env,
869
+ timeout: options.timeoutMs,
870
+ stdio: ["ignore", "pipe", "pipe"],
871
+ });
872
+ let stdout = "";
873
+ let stderr = "";
874
+ child.stdout.setEncoding("utf8").on("data", (data) => (stdout += data));
875
+ child.stderr.setEncoding("utf8").on("data", (data) => (stderr += data));
876
+ child.on("error", reject);
877
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
878
+ });
879
+ }
880
+ }
881
+ // test/src/player/McpExecutor.ts
882
+ var import_client = require("@modelcontextprotocol/sdk/client/index.js");
883
+ var import_stdio = require("@modelcontextprotocol/sdk/client/stdio.js");
884
+ var import_streamableHttp = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
885
+ var import_internal6 = require("@alumnium/core/internal");
886
+ var { logger: logger6 } = import_internal6.Telemetry.get(
887
+ "packages/typescript/test/src/player/McpExecutor.ts",
888
+ );
889
+ var DEFAULT_RETRIES = 2;
890
+ var DEFAULT_RETRY_DELAY_MS = 1000;
891
+
892
+ class McpExecutor {
893
+ #servers;
894
+ #retries;
895
+ #retryDelayMs;
896
+ #clients = new Map();
897
+ constructor(props) {
898
+ this.#servers = props.servers;
899
+ this.#retries = props.retries ?? DEFAULT_RETRIES;
900
+ this.#retryDelayMs = props.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
901
+ }
902
+ handles(call) {
903
+ return Boolean(call.server && call.server in this.#servers);
904
+ }
905
+ async execute(call, options = {}) {
906
+ const toolKey = Recording.toolKey(call);
907
+ const client = await this.#client(call.server);
908
+ for (let attempt = 0; ; attempt++) {
909
+ const canRetry = attempt < this.#retries;
910
+ const delayMs = this.#retryDelayMs * 2 ** attempt;
911
+ try {
912
+ const result = await client.callTool({
913
+ name: call.tool,
914
+ arguments: call.input,
915
+ ...(options.meta ? { _meta: options.meta } : {}),
916
+ });
917
+ const toolResult = {
918
+ content: result.content ?? [],
919
+ isError: Boolean(result.isError),
920
+ };
921
+ if (!toolResult.isError || !canRetry) return toolResult;
922
+ logger6.warn(`Tool '${toolKey}' reported an error, retrying`);
923
+ } catch (error) {
924
+ if (!canRetry) throw error;
925
+ logger6.warn(`Tool '${toolKey}' failed, retrying: {error}`, { error });
926
+ }
927
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
928
+ }
929
+ }
930
+ async close() {
931
+ const clients = [...this.#clients];
932
+ this.#clients.clear();
933
+ await Promise.all(
934
+ clients.map(async ([name, client]) => {
935
+ if (this.#servers[name]?.type === "client") return;
936
+ try {
937
+ await (await client).close();
938
+ } catch (error) {
939
+ logger6.warn(`Failed to close MCP server '${name}': {error}`, {
940
+ error,
941
+ });
942
+ }
943
+ }),
944
+ );
945
+ }
946
+ #client(name) {
947
+ let client = this.#clients.get(name);
948
+ if (!client) {
949
+ client = this.#connect(name);
950
+ client.catch(() => this.#clients.delete(name));
951
+ this.#clients.set(name, client);
952
+ }
953
+ return client;
954
+ }
955
+ async #connect(name) {
956
+ const server = this.#servers[name];
957
+ if (!server) throw new Error(`Unknown MCP server '${name}'`);
958
+ if (server.type === "client") return server.client;
959
+ logger6.debug(`Connecting to MCP server '${name}'`);
960
+ const transport =
961
+ server.type === "http"
962
+ ? new import_streamableHttp.StreamableHTTPClientTransport(
963
+ new URL(server.url),
964
+ {
965
+ requestInit: { headers: server.headers ?? {} },
966
+ },
967
+ )
968
+ : new import_stdio.StdioClientTransport({
969
+ command: server.command,
970
+ args: server.args ?? [],
971
+ env: { ...this.#definedEnv(), ...server.env },
972
+ ...(server.cwd ? { cwd: server.cwd } : {}),
973
+ stderr: "inherit",
974
+ });
975
+ const client = new import_client.Client({
976
+ name: "alumnium-test-player",
977
+ version: import_internal6.ALUMNIUM_CORE_VERSION,
978
+ });
979
+ await client.connect(transport);
980
+ return client;
981
+ }
982
+ #definedEnv() {
983
+ return Object.fromEntries(
984
+ Object.entries(process.env).filter((entry) => entry[1] !== undefined),
985
+ );
986
+ }
987
+ }
988
+ // test/src/player/Player.ts
989
+ var import_zod3 = __toESM(require("zod"), 1);
990
+ var import_internal7 = require("@alumnium/core/internal");
991
+
992
+ // test/src/masker/alumniumMaskerProps.ts
993
+ var DEFAULT_ALUMNIUM_SERVER = "alumnium";
994
+ var ALUMNIUM_START_TOOL = "start";
995
+ function alumniumMaskerProps(props, server = DEFAULT_ALUMNIUM_SERVER) {
996
+ return {
997
+ ...props,
998
+ registerResponsePaths: {
999
+ ...alumniumRegisterResponsePaths(server),
1000
+ ...props.registerResponsePaths,
1001
+ },
1002
+ wholeValueOnlyTools: [`${server}/*`, ...(props.wholeValueOnlyTools ?? [])],
1003
+ };
1004
+ }
1005
+ function alumniumRegisterResponsePaths(server) {
1006
+ return {
1007
+ [`${server}/*`]: [],
1008
+ [`${server}/${ALUMNIUM_START_TOOL}`]: ["id"],
1009
+ };
1010
+ }
1011
+
1012
+ // test/src/player/Player.ts
1013
+ var { logger: logger7 } = import_internal7.Telemetry.get(
1014
+ "packages/typescript/test/src/player/Player.ts",
1015
+ );
1016
+ var START_TOOL = "start";
1017
+ var STOP_TOOL = "stop";
1018
+ var CHECK_TOOL = "check";
1019
+ var NO_CACHE_META = { "alumnium.ai/cache": false };
1020
+ var NO_CHANGE_ANALYSIS_META = { "alumnium.ai/change-analysis": false };
1021
+ var DEFAULT_SKIP_TOOLS = ["ToolSearch", "TodoWrite"];
1022
+ var DriverOutput = import_zod3.default.object({
1023
+ id: import_zod3.default.string(),
1024
+ });
1025
+ var CheckOutput = import_zod3.default.object({
1026
+ result: import_zod3.default.enum(["success", "failure"]),
1027
+ });
1028
+
1029
+ class Player {
1030
+ #options;
1031
+ #maskerProps;
1032
+ constructor(props) {
1033
+ const {
1034
+ executor,
1035
+ alumniumServer = DEFAULT_ALUMNIUM_SERVER,
1036
+ skipTools = DEFAULT_SKIP_TOOLS,
1037
+ checkConfirmations,
1038
+ observer = {},
1039
+ ...maskerProps
1040
+ } = props;
1041
+ this.#options = {
1042
+ executor,
1043
+ server: alumniumServer,
1044
+ skipTools: new Set(skipTools),
1045
+ checkConfirmations,
1046
+ observer,
1047
+ };
1048
+ this.#maskerProps = alumniumMaskerProps(maskerProps, alumniumServer);
1049
+ }
1050
+ async play(recording) {
1051
+ const run = new PlayerRun(this.#options, new Masker(this.#maskerProps));
1052
+ try {
1053
+ return await run.play(recording);
1054
+ } finally {
1055
+ await run.stopDrivers();
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ class Diverged extends Error {}
1061
+
1062
+ class PlayerRun {
1063
+ #options;
1064
+ #masker;
1065
+ #driverIds = new Set();
1066
+ constructor(options, masker) {
1067
+ this.#options = options;
1068
+ this.#masker = masker;
1069
+ }
1070
+ get #observer() {
1071
+ return this.#options.observer;
1072
+ }
1073
+ async play(recording) {
1074
+ const stepsCount = Recording.executableStepsCount(recording);
1075
+ let stepNumber = 0;
1076
+ for (const [stepIndex, step] of recording.steps.entries()) {
1077
+ if (step.kind === "narration") {
1078
+ this.#observer.onNarration?.(step);
1079
+ continue;
1080
+ }
1081
+ logger7.info(`Replaying step ${++stepNumber}/${stepsCount}`);
1082
+ try {
1083
+ await this.#playToolStep(step);
1084
+ } catch (error) {
1085
+ const message = error instanceof Diverged ? error.message : `${error}`;
1086
+ logger7.error(`Replay diverged at step ${stepNumber}: ${message}`);
1087
+ return { status: "diverged", error: message, stepIndex };
1088
+ }
1089
+ }
1090
+ logger7.info(`Replayed all ${stepsCount} steps`);
1091
+ return { status: "replayed", verdict: recording.verdict };
1092
+ }
1093
+ async stopDrivers() {
1094
+ for (const id of this.#driverIds) {
1095
+ try {
1096
+ logger7.info(`Stopping driver ${id} the replay did not stop`);
1097
+ await this.#options.executor.execute({
1098
+ server: this.#options.server,
1099
+ tool: STOP_TOOL,
1100
+ input: { id, save_cache: false },
1101
+ });
1102
+ } catch (error) {
1103
+ logger7.warn(`Failed to stop driver ${id}: {error}`, { error });
1104
+ }
1105
+ }
1106
+ this.#driverIds.clear();
1107
+ }
1108
+ async #playToolStep(step) {
1109
+ const { call: recordedCall, result: recordedResult } = step;
1110
+ const { id, ...callProps } = recordedCall;
1111
+ const toolKey = Recording.toolKey(recordedCall);
1112
+ if (recordedResult.isError)
1113
+ return this.#skip(recordedCall, "it failed when recorded");
1114
+ if (!this.#options.executor.handles(callProps)) {
1115
+ if (this.#options.skipTools.has(toolKey))
1116
+ return this.#skip(recordedCall, "it only exists inside the agent");
1117
+ throw new Diverged(`No executor for tool '${toolKey}'`);
1118
+ }
1119
+ const call = {
1120
+ ...callProps,
1121
+ input: this.#masker.unmaskInput(toolKey, recordedCall.input),
1122
+ };
1123
+ const unresolved = Masker.findUnresolvedMasks(call.input);
1124
+ if (unresolved.length)
1125
+ throw new Diverged(
1126
+ `Tool '${toolKey}' input has unresolved masks: ${unresolved.join(", ")}`,
1127
+ );
1128
+ const result = await this.#execute(
1129
+ call,
1130
+ call.server === this.#options.server && call.tool === START_TOOL
1131
+ ? { meta: NO_CHANGE_ANALYSIS_META }
1132
+ : undefined,
1133
+ );
1134
+ if (result.isError)
1135
+ throw new Diverged(
1136
+ `Tool '${toolKey}' failed: ${contentTexts(result.content).join(`
1137
+ `)}`,
1138
+ );
1139
+ this.#masker.registerOutput(id, result.content, toolKey);
1140
+ if (call.server !== this.#options.server) return;
1141
+ switch (call.tool) {
1142
+ case START_TOOL: {
1143
+ const driverId = this.#parseOutput(result.content, DriverOutput)?.id;
1144
+ if (driverId) this.#driverIds.add(driverId);
1145
+ break;
1146
+ }
1147
+ case STOP_TOOL: {
1148
+ const driverId = DriverOutput.safeParse(call.input).data?.id;
1149
+ if (driverId) this.#driverIds.delete(driverId);
1150
+ break;
1151
+ }
1152
+ case CHECK_TOOL:
1153
+ await this.#compareCheck(call, recordedResult, result);
1154
+ break;
1155
+ }
1156
+ }
1157
+ async #execute(call, options) {
1158
+ this.#observer.onToolCall?.(call);
1159
+ const result = await this.#options.executor.execute(call, options);
1160
+ this.#observer.onToolResult?.(call, result);
1161
+ return result;
1162
+ }
1163
+ #skip(call, reason) {
1164
+ logger7.info(`Skipping tool '${Recording.toolKey(call)}', ${reason}`);
1165
+ this.#observer.onToolSkipped?.(call, reason);
1166
+ }
1167
+ async #compareCheck(call, recorded, replayed) {
1168
+ const confirmations =
1169
+ this.#options.checkConfirmations ??
1170
+ import_internal7.Env.ALUMNIUM_CHECK_CONFIRMATIONS;
1171
+ const expected = this.#checkVerdict(recorded);
1172
+ let actual = this.#checkVerdict(replayed);
1173
+ let outcome;
1174
+ if (!expected || !actual) {
1175
+ throw new Diverged(
1176
+ `Cannot read '${Recording.toolKey(call)}' verdict to compare`,
1177
+ );
1178
+ } else if (expected === actual) {
1179
+ outcome = "agreed";
1180
+ } else if (expected === "failure") {
1181
+ outcome = "improved";
1182
+ } else {
1183
+ let attempt = 0;
1184
+ while (actual !== expected && attempt++ < confirmations) {
1185
+ logger7.info(`Re-asking check ${attempt}/${confirmations}`);
1186
+ const result = await this.#execute(call, { meta: NO_CACHE_META });
1187
+ actual = result.isError ? null : this.#checkVerdict(result);
1188
+ }
1189
+ outcome = actual === expected ? "unstable" : "disagreed";
1190
+ }
1191
+ this.#observer.onCheck?.(call, outcome);
1192
+ if (outcome === "disagreed")
1193
+ throw new Diverged(
1194
+ `Check '${call.input["statement"]}' failed where it passed when recorded` +
1195
+ (confirmations
1196
+ ? `, re-asked ${confirmations} times with the cache bypassed`
1197
+ : ""),
1198
+ );
1199
+ }
1200
+ #checkVerdict(result) {
1201
+ return this.#parseOutput(result.content, CheckOutput)?.result ?? null;
1202
+ }
1203
+ #parseOutput(content, schema) {
1204
+ for (const text of contentTexts(content)) {
1205
+ const parsed = schema.safeParse(parseJson(text));
1206
+ if (parsed.success) return parsed.data;
1207
+ }
1208
+ return null;
1209
+ }
1210
+ }
1211
+ // test/src/player/ToolExecutor.ts
1212
+ function composeExecutors(...executors) {
1213
+ const find = (call) => executors.find((executor) => executor.handles(call));
1214
+ return {
1215
+ handles: (call) => Boolean(find(call)),
1216
+ async execute(call, options) {
1217
+ const executor = find(call);
1218
+ if (!executor)
1219
+ throw new Error(
1220
+ `No executor handles tool '${Recording.toolKey(call)}'`,
1221
+ );
1222
+ return executor.execute(call, options);
1223
+ },
1224
+ async close() {
1225
+ await Promise.all(
1226
+ executors.map(async (executor) => await executor.close?.()),
1227
+ );
1228
+ },
1229
+ };
1230
+ }
1231
+ // test/src/Recorder.ts
1232
+ var import_internal8 = require("@alumnium/core/internal");
810
1233
 
811
1234
  // test/src/RecordingLeakError.ts
812
1235
  class RecordingLeakError extends Error {
@@ -822,11 +1245,9 @@ class RecordingLeakError extends Error {
822
1245
  }
823
1246
 
824
1247
  // test/src/Recorder.ts
825
- var { logger: logger5 } = import_internal5.Telemetry.get(
1248
+ var { logger: logger8 } = import_internal8.Telemetry.get(
826
1249
  "packages/typescript/test/src/Recorder.ts",
827
1250
  );
828
- var DEFAULT_ALUMNIUM_SERVER = "alumnium";
829
- var ALUMNIUM_START_TOOL = "start";
830
1251
 
831
1252
  class Recorder {
832
1253
  #text;
@@ -841,24 +1262,13 @@ class Recorder {
841
1262
  this.#version = version ?? Recording.DEFAULT_VERSION;
842
1263
  this.#text = text;
843
1264
  this.#agent = agent;
844
- const server = alumniumServer ?? DEFAULT_ALUMNIUM_SERVER;
845
- this.#masker = new Masker({
846
- ...maskerProps,
847
- registerResponsePaths: {
848
- ...this.#alumniumRegisterResponsePaths(server),
849
- ...maskerProps.registerResponsePaths,
850
- },
851
- wholeValueOnlyTools: [
852
- `${server}/*`,
853
- ...(maskerProps.wholeValueOnlyTools ?? []),
854
- ],
855
- });
1265
+ this.#masker = new Masker(alumniumMaskerProps(maskerProps, alumniumServer));
856
1266
  }
857
1267
  onToolUse(use) {
858
1268
  this.#assertNotFinished();
859
1269
  const { id, server, tool, input } = use;
860
1270
  const toolKey = Recording.toolKey({ server, tool });
861
- logger5.debug(`Recording tool use '${toolKey}' ({id})`, { id });
1271
+ logger8.debug(`Recording tool use '${toolKey}' ({id})`, { id });
862
1272
  this.#pendingCalls.set(id, {
863
1273
  id,
864
1274
  ...(server ? { server } : {}),
@@ -872,11 +1282,11 @@ class Recorder {
872
1282
  const isError = Boolean(toolResult.isError);
873
1283
  const call = this.#pendingCalls.get(id);
874
1284
  if (!call) {
875
- logger5.debug(`No recorded tool use for result '${id}', ignoring it`);
1285
+ logger8.debug(`No recorded tool use for result '${id}', ignoring it`);
876
1286
  return;
877
1287
  }
878
1288
  this.#pendingCalls.delete(id);
879
- logger5.debug(`Recording '${call.tool}' tool result`);
1289
+ logger8.debug(`Recording '${call.tool}' tool result`);
880
1290
  this.#masker.registerOutput(id, content, Recording.toolKey(call), {
881
1291
  secretsOnly: isError,
882
1292
  });
@@ -898,7 +1308,7 @@ class Recorder {
898
1308
  finish(verdict) {
899
1309
  this.#assertNotFinished();
900
1310
  if (this.#pendingCalls.size)
901
- logger5.warn(
1311
+ logger8.warn(
902
1312
  `Dropping ${this.#pendingCalls.size} tool calls with no result: ${[
903
1313
  ...this.#pendingCalls.values(),
904
1314
  ]
@@ -911,7 +1321,7 @@ class Recorder {
911
1321
  version: this.#version,
912
1322
  text: this.#text,
913
1323
  agent: this.#agent,
914
- alumnium: { version: import_internal5.ALUMNIUM_VERSION },
1324
+ alumnium: { version: import_internal8.ALUMNIUM_CORE_VERSION },
915
1325
  hostVars: [],
916
1326
  steps: this.#steps.map((step) => this.#maskStep(step)),
917
1327
  verdict: {
@@ -946,16 +1356,10 @@ class Recorder {
946
1356
  };
947
1357
  }
948
1358
  }
949
- #alumniumRegisterResponsePaths(server = DEFAULT_ALUMNIUM_SERVER) {
950
- return {
951
- [`${server}/*`]: [],
952
- [`${server}/${ALUMNIUM_START_TOOL}`]: ["id"],
953
- };
954
- }
955
1359
  #assertNotFinished() {
956
1360
  if (this.#recording) throw new Error("Recording is already finished");
957
1361
  }
958
1362
  }
959
1363
 
960
- //# debugId=1012AF1D16D4DEA864756E2164756E21
1364
+ //# debugId=18EDAFFEFAD0500964756E2164756E21
961
1365
  //# sourceMappingURL=index.cjs.map