@voeu/cli 0.1.0 → 0.1.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
@@ -124,6 +124,10 @@ That seam is deliberate: a harness that supplies its own session store proves on
124
124
  it supplied works. A partly-filled adapter still gives honest answers, because scenarios it cannot
125
125
  exercise report UNPROVEN rather than passing.
126
126
 
127
+ A TypeScript adapter runs as written: `verify` loads `.ts` modules itself, following `.js`-suffixed
128
+ imports, extensionless ones and tsconfig `paths` the way your compiler would, with no build step and
129
+ no type checking. In CI it reads `DATABASE_URL` when `voeu.config.json` names no database.
130
+
127
131
  `npx @voeu/cli verify --reference` runs the whole suite against a built-in application, so you can see
128
132
  the output before writing anything.
129
133
 
@@ -1,8 +1,9 @@
1
1
  {
2
- "version": "0.1.0",
3
- "bytes": 252644,
2
+ "version": "0.1.1",
3
+ "bytes": 256864,
4
+ "hooksBytes": 6800,
4
5
  "externals": [
5
6
  "ts-parser"
6
7
  ],
7
- "inputs": 43
8
+ "inputs": 45
8
9
  }
@@ -0,0 +1,186 @@
1
+ import { createRequire as __voeuCreateRequire } from 'node:module';
2
+ const require = __voeuCreateRequire(import.meta.url);
3
+
4
+ // packages/cli/src/ts-loader-hooks.ts
5
+ import { readFileSync, statSync } from "node:fs";
6
+ import { dirname, extname, isAbsolute, join, resolve as resolvePath } from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import ts from "ts-parser";
9
+ var repoRoot = "";
10
+ function initialize(data) {
11
+ repoRoot = data.repoRoot;
12
+ }
13
+ var TS_SOURCE = /\.(ts|tsx|mts|cts)$/;
14
+ var SIBLINGS = {
15
+ ".js": [".ts", ".tsx"],
16
+ ".mjs": [".mts"],
17
+ ".cjs": [".cts"],
18
+ ".jsx": [".tsx"]
19
+ };
20
+ var EXTENSIONS = [".ts", ".tsx", ".mts", ".js", ".mjs", ".cjs", ".json"];
21
+ var INDEXES = EXTENSIONS.map((e) => `index${e}`);
22
+ var NOT_FOUND = /* @__PURE__ */ new Set(["ERR_MODULE_NOT_FOUND", "ERR_UNSUPPORTED_DIR_IMPORT"]);
23
+ function isFile(path) {
24
+ try {
25
+ return statSync(path).isFile();
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+ function probe(base) {
31
+ if (isFile(base)) return base;
32
+ const ext = extname(base);
33
+ for (const sibling of SIBLINGS[ext] ?? []) {
34
+ const candidate = base.slice(0, -ext.length) + sibling;
35
+ if (isFile(candidate)) return candidate;
36
+ }
37
+ for (const extension of EXTENSIONS) if (isFile(base + extension)) return base + extension;
38
+ for (const index of INDEXES) if (isFile(join(base, index))) return join(base, index);
39
+ return null;
40
+ }
41
+ var pathMaps = /* @__PURE__ */ new Map();
42
+ function pathMapFor(dir) {
43
+ const cached = pathMaps.get(dir);
44
+ if (cached !== void 0) return cached;
45
+ const visited = [];
46
+ let current = dir;
47
+ let found = null;
48
+ for (; ; ) {
49
+ visited.push(current);
50
+ const file = join(current, "tsconfig.json");
51
+ if (isFile(file)) {
52
+ found = readPathMap(file);
53
+ break;
54
+ }
55
+ const parent = dirname(current);
56
+ if (current === repoRoot || parent === current) break;
57
+ current = parent;
58
+ }
59
+ for (const d of visited) pathMaps.set(d, found);
60
+ return found;
61
+ }
62
+ function readPathMap(file) {
63
+ const read = ts.readConfigFile(file, ts.sys.readFile);
64
+ if (read.error || typeof read.config !== "object" || read.config === null) return null;
65
+ const raw = { ...read.config, files: [], include: [] };
66
+ const parsed = ts.parseJsonConfigFileContent(raw, ts.sys, dirname(file), void 0, file);
67
+ const options = parsed.options;
68
+ const paths = options.paths ?? {};
69
+ const patterns = Object.entries(paths).map(([key, targets]) => ({ key, targets })).sort((a, b) => b.key.length - a.key.length);
70
+ if (patterns.length === 0 && options.baseUrl === void 0) return null;
71
+ return { base: options.baseUrl ?? options.pathsBasePath ?? dirname(file), patterns };
72
+ }
73
+ function mapThroughPaths(specifier, map) {
74
+ const out = [];
75
+ for (const { key, targets } of map.patterns) {
76
+ const star = key.indexOf("*");
77
+ if (star === -1) {
78
+ if (specifier === key) out.push(...targets.map((t) => resolvePath(map.base, t)));
79
+ continue;
80
+ }
81
+ const prefix = key.slice(0, star);
82
+ const suffix = key.slice(star + 1);
83
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
84
+ if (specifier.length < prefix.length + suffix.length) continue;
85
+ const matched = specifier.slice(prefix.length, specifier.length - suffix.length);
86
+ out.push(...targets.map((t) => resolvePath(map.base, t.replace("*", matched))));
87
+ }
88
+ if (out.length === 0 && map.patterns.length === 0) out.push(resolvePath(map.base, specifier));
89
+ return out;
90
+ }
91
+ function parentPath(parentURL) {
92
+ if (!parentURL || !parentURL.startsWith("file:")) return null;
93
+ const path = fileURLToPath(parentURL);
94
+ if (path.split(/[\\/]/).includes("node_modules")) return null;
95
+ return path;
96
+ }
97
+ function findTypeScriptTarget(specifier, parent) {
98
+ if (specifier.startsWith("./") || specifier.startsWith("../") || specifier === "." || specifier === "..") {
99
+ return probe(resolvePath(dirname(parent), specifier));
100
+ }
101
+ if (specifier.startsWith("file:")) return probe(fileURLToPath(specifier));
102
+ if (isAbsolute(specifier)) return probe(specifier);
103
+ if (specifier.startsWith("node:") || specifier.startsWith("data:")) return null;
104
+ const map = pathMapFor(dirname(parent));
105
+ if (map === null) return null;
106
+ for (const candidate of mapThroughPaths(specifier, map)) {
107
+ const found = probe(candidate);
108
+ if (found !== null) return found;
109
+ }
110
+ return null;
111
+ }
112
+ async function resolve(specifier, context, nextResolve) {
113
+ try {
114
+ return await nextResolve(specifier, context);
115
+ } catch (error) {
116
+ const code = error.code ?? "";
117
+ if (!NOT_FOUND.has(code)) throw error;
118
+ const parent = parentPath(context.parentURL);
119
+ if (parent === null) throw error;
120
+ const found = findTypeScriptTarget(specifier, parent);
121
+ if (found === null) throw error;
122
+ return { url: pathToFileURL(found).href, shortCircuit: true };
123
+ }
124
+ }
125
+ var packageTypes = /* @__PURE__ */ new Map();
126
+ function packageType(dir) {
127
+ const cached = packageTypes.get(dir);
128
+ if (cached !== void 0) return cached;
129
+ let type = null;
130
+ const file = join(dir, "package.json");
131
+ if (isFile(file)) {
132
+ try {
133
+ type = JSON.parse(readFileSync(file, "utf8")).type ?? "commonjs";
134
+ } catch {
135
+ type = null;
136
+ }
137
+ } else {
138
+ const parent = dirname(dir);
139
+ type = parent === dir ? null : packageType(parent);
140
+ }
141
+ packageTypes.set(dir, type);
142
+ return type;
143
+ }
144
+ var ESM_SYNTAX = /^\s*(?:import\s+[\w{*"'\s]|import\s*\(|export\s+)/m;
145
+ function formatFor(path, source) {
146
+ if (path.endsWith(".mts")) return "module";
147
+ if (path.endsWith(".cts")) return "commonjs";
148
+ if (packageType(dirname(path)) === "module") return "module";
149
+ return ESM_SYNTAX.test(source) ? "module" : "commonjs";
150
+ }
151
+ function transpile(path, source, format) {
152
+ return ts.transpileModule(source, {
153
+ fileName: path,
154
+ reportDiagnostics: false,
155
+ compilerOptions: {
156
+ module: format === "module" ? ts.ModuleKind.ESNext : ts.ModuleKind.CommonJS,
157
+ target: ts.ScriptTarget.ES2022,
158
+ jsx: ts.JsxEmit.ReactJSX,
159
+ esModuleInterop: true,
160
+ isolatedModules: true,
161
+ // Type-only imports vanish; an import used only in type positions is
162
+ // elided too, so a module that exports a type and nothing else is never
163
+ // asked for a runtime binding it does not have.
164
+ verbatimModuleSyntax: false,
165
+ useDefineForClassFields: true,
166
+ inlineSourceMap: true,
167
+ inlineSources: false
168
+ }
169
+ }).outputText;
170
+ }
171
+ async function load(url, context, nextLoad) {
172
+ if (!url.startsWith("file:") || !TS_SOURCE.test(new URL(url).pathname)) return nextLoad(url, context);
173
+ const path = fileURLToPath(url);
174
+ const source = readFileSync(path, "utf8");
175
+ const format = formatFor(path, source);
176
+ return { format, source: transpile(path, source, format), shortCircuit: true };
177
+ }
178
+ export {
179
+ findTypeScriptTarget,
180
+ formatFor,
181
+ initialize,
182
+ load,
183
+ probe,
184
+ resolve,
185
+ transpile
186
+ };
package/dist/voeu.js CHANGED
@@ -159,6 +159,8 @@ function snapshotFromFiles(files, options = {}) {
159
159
  byPath: new Map(parsed.map((f) => [f.path, f])),
160
160
  packageJson: options.packageJson ?? null,
161
161
  lockfileHash: options.lockfileHash ?? null,
162
+ // A test that hands over a lockfile by content means the lockfile exists.
163
+ lockfile: options.lockfile ?? LOCKFILE_NAMES.find((name) => files.has(name)) ?? null,
162
164
  truncated: options.truncated ?? false,
163
165
  unparsed,
164
166
  nestedPackages: [...options.nestedPackages ?? []]
@@ -226,9 +228,11 @@ function loadRepo(root) {
226
228
  }
227
229
  }
228
230
  let lockfileHash = null;
231
+ let lockfile = null;
229
232
  for (const name of LOCKFILE_NAMES) {
230
233
  try {
231
234
  lockfileHash = hashString(readFileSync(join(root, name), "utf8"));
235
+ lockfile = name;
232
236
  break;
233
237
  } catch {
234
238
  }
@@ -239,6 +243,7 @@ function loadRepo(root) {
239
243
  byPath: new Map(files.map((f) => [f.path, f])),
240
244
  packageJson,
241
245
  lockfileHash,
246
+ lockfile,
242
247
  truncated,
243
248
  unparsed,
244
249
  nestedPackages
@@ -2020,6 +2025,22 @@ var SCENARIOS = [
2020
2025
 
2021
2026
  // packages/harness/src/run.ts
2022
2027
  var DEFAULT_START = Date.parse("2026-09-08T12:00:00.000Z");
2028
+ var DEFAULT_SCENARIO_TIMEOUT_MS = 6e4;
2029
+ var STOP_TIMEOUT_MS = 5e3;
2030
+ var Timeout = class extends Error {
2031
+ constructor(message) {
2032
+ super(message);
2033
+ this.name = "Timeout";
2034
+ }
2035
+ };
2036
+ function within(ms, work, what) {
2037
+ let timer;
2038
+ const expiry = new Promise((_, reject) => {
2039
+ timer = setTimeout(() => reject(new Timeout(what)), ms);
2040
+ });
2041
+ work.catch(() => void 0);
2042
+ return Promise.race([work, expiry]).finally(() => clearTimeout(timer));
2043
+ }
2023
2044
  var OPTIONAL_CAPABILITIES = [
2024
2045
  "peekQueue",
2025
2046
  "withSecondProcess",
@@ -2030,6 +2051,7 @@ var OPTIONAL_CAPABILITIES = [
2030
2051
  async function runSuite(options) {
2031
2052
  const scenarios = options.scenarios ?? SCENARIOS;
2032
2053
  const outcomes = [];
2054
+ const budget = options.scenarioTimeoutMs ?? DEFAULT_SCENARIO_TIMEOUT_MS;
2033
2055
  for (const scenario of scenarios) {
2034
2056
  let clock = options.startedAt ?? DEFAULT_START;
2035
2057
  const now = () => clock;
@@ -2041,18 +2063,33 @@ async function runSuite(options) {
2041
2063
  const url = await simulator.listen();
2042
2064
  const app = await options.makeApp();
2043
2065
  try {
2044
- await app.start({ simulatorUrl: url, databaseUrl: options.databaseUrl ?? "", now });
2045
- options.observe?.(app);
2046
- const capabilities = new Set(
2047
- OPTIONAL_CAPABILITIES.filter((name) => typeof app[name] === "function")
2048
- );
2049
- const outcome = await runScenario(scenario, {
2050
- app,
2051
- simulator,
2052
- advance,
2053
- now,
2054
- faults: simulator.faults,
2055
- capabilities
2066
+ const started = Date.now();
2067
+ const outcome = await within(
2068
+ budget,
2069
+ (async () => {
2070
+ await app.start({ simulatorUrl: url, databaseUrl: options.databaseUrl ?? "", now });
2071
+ options.observe?.(app);
2072
+ const capabilities = new Set(
2073
+ OPTIONAL_CAPABILITIES.filter((name) => typeof app[name] === "function")
2074
+ );
2075
+ return runScenario(scenario, {
2076
+ app,
2077
+ simulator,
2078
+ advance,
2079
+ now,
2080
+ faults: simulator.faults,
2081
+ capabilities
2082
+ });
2083
+ })(),
2084
+ scenario.id
2085
+ ).catch((error) => {
2086
+ if (!(error instanceof Timeout)) throw error;
2087
+ return {
2088
+ scenario,
2089
+ verdict: "not_verified",
2090
+ detail: `The scenario was still running after ${Math.round(budget / 1e3)} s and was abandoned. Scenarios move a simulated clock, so real time goes only to the application's own I/O; a scenario that does not finish is stuck on it - most often a queue or database client waiting for a connection that will never come. Nothing was established either way.`,
2091
+ durationMs: Date.now() - started
2092
+ };
2056
2093
  });
2057
2094
  const internal = simulator.requests.filter((r) => r.outcome === "internal");
2058
2095
  outcomes.push(
@@ -2070,7 +2107,7 @@ async function runSuite(options) {
2070
2107
  durationMs: 0
2071
2108
  });
2072
2109
  } finally {
2073
- await app.stop().catch(() => void 0);
2110
+ await within(STOP_TIMEOUT_MS, Promise.resolve().then(() => app.stop()), `${scenario.id} stop`).catch(() => void 0);
2074
2111
  await simulator.close();
2075
2112
  }
2076
2113
  }
@@ -4867,6 +4904,22 @@ function renderHtml(run) {
4867
4904
  `;
4868
4905
  }
4869
4906
 
4907
+ // packages/cli/src/ts-loader.ts
4908
+ import { existsSync } from "node:fs";
4909
+ import { register } from "node:module";
4910
+ import { fileURLToPath } from "node:url";
4911
+ var registered = false;
4912
+ function registerTypeScriptLoader(repoRoot) {
4913
+ if (registered) return { registered: true, reason: null };
4914
+ const hooks = new URL("./ts-loader-hooks.js", import.meta.url);
4915
+ if (!existsSync(fileURLToPath(hooks))) {
4916
+ return { registered: false, reason: `no ${fileURLToPath(hooks)} beside the CLI, so TypeScript adapters load only if the host can` };
4917
+ }
4918
+ register(hooks, { parentURL: import.meta.url, data: { repoRoot } });
4919
+ registered = true;
4920
+ return { registered: true, reason: null };
4921
+ }
4922
+
4870
4923
  // packages/fixer/src/diff.ts
4871
4924
  var MAX_CELLS = 4e6;
4872
4925
  function lcsOps(before, after) {
@@ -5756,6 +5809,24 @@ async function runVerifyStripe(args) {
5756
5809
  emit(run, args, null);
5757
5810
  return verdictExit(run);
5758
5811
  }
5812
+ async function recordingBackgroundErrors(work) {
5813
+ const errors = [];
5814
+ const describe2 = (error) => error instanceof Error ? error.message : String(error);
5815
+ const onRejection = (reason) => {
5816
+ errors.push(`unhandled rejection: ${describe2(reason)}`);
5817
+ };
5818
+ const onException = (error) => {
5819
+ errors.push(`uncaught exception: ${describe2(error)}`);
5820
+ };
5821
+ process.on("unhandledRejection", onRejection);
5822
+ process.on("uncaughtException", onException);
5823
+ try {
5824
+ return { result: await work(), errors };
5825
+ } finally {
5826
+ process.off("unhandledRejection", onRejection);
5827
+ process.off("uncaughtException", onException);
5828
+ }
5829
+ }
5759
5830
  async function runVerify(args) {
5760
5831
  if (args.provider === "stripe") return runVerifyStripe(args);
5761
5832
  if (args.reference) {
@@ -5778,27 +5849,54 @@ async function runVerify(args) {
5778
5849
  );
5779
5850
  return EXIT.UNSUPPORTED;
5780
5851
  }
5852
+ let loader;
5853
+ try {
5854
+ loader = registerTypeScriptLoader(root);
5855
+ } catch (error) {
5856
+ process.stderr.write(
5857
+ `voeu: could not start the TypeScript loader: ${error instanceof Error ? error.message : error}
5858
+ `
5859
+ );
5860
+ return EXIT.INFRASTRUCTURE;
5861
+ }
5781
5862
  let makeApp;
5782
5863
  try {
5783
5864
  makeApp = await loadAdapter(root, config.adapter);
5784
5865
  } catch (error) {
5785
5866
  process.stderr.write(
5786
5867
  `voeu: could not load adapter ${config.adapter}: ${error instanceof Error ? error.message : error}
5787
- `
5868
+ ${loader.registered ? "" : ` (${loader.reason})
5869
+ `}`
5788
5870
  );
5789
5871
  return EXIT.UNSUPPORTED;
5790
5872
  }
5791
5873
  try {
5792
- const run = await verify({
5793
- makeApp,
5794
- group: "independent",
5795
- targetName: args.name ?? basename(root),
5796
- targetPath: root,
5797
- databaseUrl: config.databaseUrl,
5798
- // A verify run reported from CI is matched to the hosted inspect of the
5799
- // same commit; without this it arrives as a run about no commit at all.
5800
- baseCommit: commitOf(root)
5801
- });
5874
+ const { result: verified, errors } = await recordingBackgroundErrors(
5875
+ () => verify({
5876
+ makeApp,
5877
+ group: "independent",
5878
+ targetName: args.name ?? basename(root),
5879
+ targetPath: root,
5880
+ // The generated workflow starts Postgres as a job service and names it in
5881
+ // DATABASE_URL; an adapter written against the reference reads
5882
+ // `env.databaseUrl` and would otherwise be handed "" in the one place it
5883
+ // matters most. The config still wins when it says.
5884
+ databaseUrl: config.databaseUrl ?? process.env["DATABASE_URL"],
5885
+ // A verify run reported from CI is matched to the hosted inspect of the
5886
+ // same commit; without this it arrives as a run about no commit at all.
5887
+ baseCommit: commitOf(root)
5888
+ })
5889
+ );
5890
+ const run = errors.length === 0 ? verified : {
5891
+ ...verified,
5892
+ limitations: [
5893
+ ...verified.limitations,
5894
+ {
5895
+ scope: "application",
5896
+ reason: `The application raised ${errors.length} error(s) outside any scenario's control flow - an unhandled promise rejection or an uncaught exception - which would have ended this process before the report existed. Recorded rather than fatal: the scenarios decided what they decided, and the errors are the application's to explain. First: ${errors[0]}`
5897
+ }
5898
+ ]
5899
+ };
5802
5900
  emit(run, args, null);
5803
5901
  return verdictExit(run);
5804
5902
  } catch (error) {
@@ -6176,13 +6274,12 @@ async function runCli(argv) {
6176
6274
  }
6177
6275
 
6178
6276
  // packages/cli/src/bin.ts
6179
- runCli(process.argv.slice(2)).then(
6180
- (code) => {
6181
- process.exitCode = code;
6182
- },
6183
- (error) => {
6184
- process.stderr.write(`voeu: ${error instanceof Error ? error.message : String(error)}
6277
+ function exit(code) {
6278
+ process.exitCode = code;
6279
+ process.stdout.write("", () => process.exit(code));
6280
+ }
6281
+ runCli(process.argv.slice(2)).then(exit, (error) => {
6282
+ process.stderr.write(`voeu: ${error instanceof Error ? error.message : String(error)}
6185
6283
  `);
6186
- process.exitCode = 3;
6187
- }
6188
- );
6284
+ exit(3);
6285
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voeu/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Keep integrations working when providers change. Assess and verify Shopify apps against the expiring offline access token contract.",
5
5
  "type": "module",
6
6
  "bin": {