Skip to content

Commit 8e81872

Browse files
authored
feat(checkpoint-validation): migrate CLI to Vitest (#1346)
1 parent 9546477 commit 8e81872

File tree

15 files changed

+229
-10
lines changed

15 files changed

+229
-10
lines changed

libs/checkpoint-validation/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@langchain/langgraph-checkpoint-validation",
3-
"version": "0.0.2",
3+
"version": "0.1.0",
44
"description": "Library for validating LangGraph checkpoint saver implementations.",
55
"type": "module",
66
"engines": {
@@ -14,7 +14,7 @@
1414
},
1515
"scripts": {
1616
"build": "yarn turbo:command build:internal --filter=@langchain/langgraph-checkpoint-validation",
17-
"build:internal": "yarn clean && yarn lc_build --create-entrypoints --pre --tree-shaking",
17+
"build:internal": "yarn clean && yarn lc_build --create-entrypoints --pre --tree-shaking && cp src/runner.ts dist/runner.ts",
1818
"clean": "rm -rf dist/ dist-cjs/ .turbo/",
1919
"lint:eslint": "NODE_OPTIONS=--max-old-space-size=4096 eslint --cache --ext .ts,.js src/",
2020
"lint:dpdm": "dpdm --exit-code circular:1 --no-warning --no-tree src/*.ts src/**/*.ts",
@@ -49,6 +49,7 @@
4949
"@testcontainers/postgresql": "^11.0.3",
5050
"@tsconfig/recommended": "^1.0.3",
5151
"@types/uuid": "^10",
52+
"@types/yargs": "^17.0.33",
5253
"@typescript-eslint/eslint-plugin": "^6.12.0",
5354
"@typescript-eslint/parser": "^6.12.0",
5455
"better-sqlite3": "^11.7.0",

libs/checkpoint-validation/src/cli.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as url from "node:url";
2+
import * as path from "node:path";
3+
import { startVitest } from "vitest/node";
4+
import yargs from "yargs";
5+
import {
6+
isTestTypeFilter,
7+
isTestTypeFilterArray,
8+
testTypeFilters,
9+
} from "./types.js";
10+
import { resolveImportPath } from "./import_utils.js";
11+
12+
const builder = yargs()
13+
.command("* <initializer-import-path> [filters..]", "Validate a checkpointer")
14+
.positional("initializerImportPath", {
15+
type: "string",
16+
describe:
17+
"The import path of the CheckpointSaverTestInitializer for the checkpointer (passed to 'import()'). " +
18+
"Must be the default export.",
19+
demandOption: true,
20+
})
21+
.positional("filters", {
22+
array: true,
23+
choices: ["getTuple", "put", "putWrites", "list"],
24+
default: [],
25+
describe:
26+
"Only run the specified suites. Valid values are 'getTuple', 'put', 'putWrites', and 'list'",
27+
demandOption: false,
28+
})
29+
.help()
30+
.alias("h", "help")
31+
.wrap(yargs().terminalWidth())
32+
.strict();
33+
34+
export async function main() {
35+
const parsed = await builder.parse(process.argv.slice(2));
36+
37+
try {
38+
resolveImportPath(parsed.initializerImportPath);
39+
} catch (e) {
40+
console.error(
41+
`Failed to resolve import path '${parsed.initializerImportPath}': ${e}`
42+
);
43+
process.exit(1);
44+
}
45+
46+
if (!isTestTypeFilterArray(parsed.filters)) {
47+
console.error(
48+
`Invalid filters: '${parsed.filters
49+
.filter((f) => !isTestTypeFilter(f))
50+
.join("', '")}'. Expected only values from '${testTypeFilters.join(
51+
"', '"
52+
)}'`
53+
);
54+
process.exit(1);
55+
}
56+
57+
const rootDir = path.resolve(
58+
path.dirname(url.fileURLToPath(import.meta.url)),
59+
"..",
60+
"dist"
61+
);
62+
const runner = path.resolve(rootDir, "runner.ts");
63+
64+
await startVitest("test", [runner], {
65+
globals: true,
66+
include: [runner],
67+
exclude: [],
68+
provide: { LANGGRAPH_ARGS: parsed },
69+
dir: rootDir,
70+
});
71+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {
2+
isAbsolute as pathIsAbsolute,
3+
resolve as pathResolve,
4+
dirname,
5+
} from "node:path";
6+
import { existsSync } from "node:fs";
7+
import { createRequire } from "node:module";
8+
9+
export function findPackageRoot(path: string): string {
10+
const packageJsonPath = pathResolve(path, "package.json");
11+
if (existsSync(packageJsonPath)) {
12+
return path;
13+
}
14+
15+
if (pathResolve(dirname(path)) === pathResolve(path)) {
16+
throw new Error("Could not find package root");
17+
}
18+
19+
return findPackageRoot(pathResolve(dirname(path)));
20+
}
21+
22+
export function resolveImportPath(path: string) {
23+
// absolute path
24+
if (pathIsAbsolute(path)) {
25+
return path;
26+
}
27+
28+
// relative path
29+
if (/^\.\.?(\/|\\)/.test(path)) {
30+
return pathResolve(path);
31+
} else {
32+
const resolvedPath = pathResolve(process.cwd(), path);
33+
// try it as a relative path, anyway
34+
if (existsSync(resolvedPath)) {
35+
return resolvedPath;
36+
}
37+
}
38+
39+
// module name
40+
const packageRoot = findPackageRoot(process.cwd());
41+
if (packageRoot === undefined) {
42+
console.error(
43+
"Could not find package root to resolve initializer import path."
44+
);
45+
process.exit(1);
46+
}
47+
48+
const localRequire = createRequire(pathResolve(packageRoot, "package.json"));
49+
return localRequire.resolve(path);
50+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// This file is used by the CLI to dynamically execute tests against the user-provided checkpointer. It's written as a
2+
// Vitest test file because unfortunately there's no good way to just pass Vitest a test definition function and tell it to
3+
// run it.
4+
import { inject } from "vitest";
5+
import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint";
6+
import { resolve as pathResolve } from "node:path";
7+
import { createRequire } from "node:module";
8+
import { readFileSync } from "node:fs";
9+
import {
10+
checkpointerTestInitializerSchema,
11+
isTestTypeFilter,
12+
isTestTypeFilterArray,
13+
testTypeFilters,
14+
type CheckpointerTestInitializer,
15+
} from "./types.js";
16+
import { specTest } from "./spec/index.js";
17+
import { findPackageRoot, resolveImportPath } from "./import_utils.js";
18+
19+
declare module "vitest" {
20+
interface ProvidedContext {
21+
LANGGRAPH_ARGS: { initializerImportPath: string; filters: string[] };
22+
}
23+
}
24+
25+
export function isESM(path: string) {
26+
if (path.endsWith(".mjs") || path.endsWith(".mts")) {
27+
return true;
28+
}
29+
30+
if (path.endsWith(".cjs") || path.endsWith(".cts")) {
31+
return false;
32+
}
33+
34+
const packageJsonPath = pathResolve(findPackageRoot(path), "package.json");
35+
const packageConfig = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
36+
return packageConfig.type === "module";
37+
}
38+
39+
async function dynamicImport(modulePath: string) {
40+
if (isESM(modulePath)) return import(modulePath);
41+
const localRequire = createRequire(pathResolve(modulePath, "package.json"));
42+
return localRequire(modulePath);
43+
}
44+
45+
const { initializerImportPath, filters } = inject("LANGGRAPH_ARGS");
46+
const resolvedImportPath = resolveImportPath(initializerImportPath);
47+
48+
let initializerExport: unknown;
49+
try {
50+
initializerExport = await dynamicImport(resolvedImportPath);
51+
} catch (e) {
52+
console.error(
53+
`Failed to import initializer from import path '${initializerImportPath}' (resolved to '${resolvedImportPath}'): ${e}`
54+
);
55+
process.exit(1);
56+
}
57+
58+
let initializer: CheckpointerTestInitializer<BaseCheckpointSaver>;
59+
try {
60+
initializer = checkpointerTestInitializerSchema.parse(
61+
(initializerExport as { default?: unknown }).default ?? initializerExport
62+
) as CheckpointerTestInitializer<BaseCheckpointSaver>;
63+
} catch (e) {
64+
console.error(
65+
`Initializer imported from '${initializerImportPath}' does not conform to the expected schema. Make sure " +
66+
"it is the default export, and that implements the CheckpointSaverTestInitializer interface. Error: ${e}`
67+
);
68+
process.exit(1);
69+
}
70+
71+
if (!isTestTypeFilterArray(filters)) {
72+
console.error(
73+
`Invalid filters: '${filters
74+
.filter((f) => !isTestTypeFilter(f))
75+
.join("', '")}'. Expected only values from '${testTypeFilters.join(
76+
"', '"
77+
)}'`
78+
);
79+
process.exit(1);
80+
}
81+
82+
if (!initializer) {
83+
throw new Error("Test configuration error: initializer is not set.");
84+
}
85+
86+
specTest(initializer, filters);

libs/checkpoint-validation/src/spec/get_tuple.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { describe, it, expect, beforeAll, afterAll } from "vitest";
21
import {
32
CheckpointTuple,
43
PendingWrite,

libs/checkpoint-validation/src/spec/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { describe, beforeAll, afterAll } from "vitest";
21
import { type BaseCheckpointSaver } from "@langchain/langgraph-checkpoint";
32

43
import { CheckpointerTestInitializer, TestTypeFilter } from "../types.js";

libs/checkpoint-validation/src/spec/list.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { describe, it, expect, beforeAll, afterAll } from "vitest";
21
import {
32
CheckpointTuple,
43
PendingWrite,

libs/checkpoint-validation/src/spec/put.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
21
import {
32
Checkpoint,
43
CheckpointMetadata,

libs/checkpoint-validation/src/spec/put_writes.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
21
import {
32
Checkpoint,
43
CheckpointMetadata,

libs/checkpoint-validation/src/test_utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { it, expect } from "vitest";
21
import {
32
BaseCheckpointSaver,
43
ChannelVersions,

0 commit comments

Comments
 (0)