Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,9 @@ jobs:
- name: Lint
run: npm run lint --if-present

- name: Test
run: npm test --if-present
- name: Test + coverage gate
# test:coverage runs the suite under node --test --experimental-test-coverage
# with --test-coverage-{lines,branches,functions} thresholds, so coverage
# regressions fail the build. Previously CI ran plain `npm test` (no
# coverage) and the declared thresholds were never evaluated.
run: npm run test:coverage --if-present
4 changes: 2 additions & 2 deletions packages/agent-memory-sync/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"lint": "npm run typecheck",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "node --test --experimental-strip-types tests/**/*.test.ts",
"test:coverage": "node --test --experimental-strip-types --experimental-test-coverage tests/**/*.test.ts"
"test": "node --import tsx --test 'tests/**/*.test.ts'",
"test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' --test-coverage-lines=86 --test-coverage-branches=65 --test-coverage-functions=86 'tests/**/*.test.ts'"
},
"engines": {
"node": ">=20"
Expand Down
200 changes: 200 additions & 0 deletions packages/agent-memory-sync/tests/unit/scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Unit tests for the cron scheduler.
//
// parseCron is not exported directly; it is exercised via:
// - validateCronExpression (throws on invalid input)
// - nextScheduleTick (verifies correct set membership by computing exact
// next-run times)
//
// All nextScheduleTick assertions use a fixed local-clock reference date so
// that results are deterministic regardless of the host timezone.
// waitMs = cursor.getTime() – after.getTime() is pure arithmetic and
// timezone-agnostic. Date-component assertions (getDate, getMonth, …) use
// local-time methods consistent with what the scheduler itself uses.

const test = require("node:test");
const assert = require("node:assert/strict");
// Load from TypeScript source so coverage measures src/ (the suite runs under
// tsx, which resolves scheduler.ts's extensionless `require("../errors")` that
// Node's --experimental-strip-types could not).
const { validateCronExpression, nextScheduleTick } = require("../../src/memory-sync/scheduler");

// ─── parseCron (via validateCronExpression) ──────────────────────────────────

test("parseCron: wildcard '*' in all fields is valid", () => {
assert.doesNotThrow(() => validateCronExpression("* * * * *"));
});

test("parseCron: step */15 in minute field is valid", () => {
assert.doesNotThrow(() => validateCronExpression("*/15 * * * *"));
});

test("parseCron: range 1-5 in minute field is valid", () => {
assert.doesNotThrow(() => validateCronExpression("1-5 * * * *"));
});

test("parseCron: list 1,3,5 in minute field is valid", () => {
assert.doesNotThrow(() => validateCronExpression("1,3,5 * * * *"));
});

test("parseCron: fixed value in hour and minute fields is valid", () => {
assert.doesNotThrow(() => validateCronExpression("30 12 * * *"));
});

test("parseCron: multi-field expression with range, list, step is valid", () => {
assert.doesNotThrow(() => validateCronExpression("0 2 1-15 3,6 1-5"));
});

test("parseCron: step on range token (5-30/5) is valid", () => {
assert.doesNotThrow(() => validateCronExpression("5-30/5 * * * *"));
});

test("parseCron: invalid — too few fields throws CliError", () => {
assert.throws(() => validateCronExpression("* * * *"), /invalid/);
});

test("parseCron: invalid — too many fields throws CliError", () => {
assert.throws(() => validateCronExpression("* * * * * *"), /invalid/);
});

test("parseCron: invalid — minute 60 (out of range 0-59) throws CliError", () => {
assert.throws(() => validateCronExpression("60 * * * *"), /outside the allowed range/);
});

test("parseCron: invalid — hour 24 (out of range 0-23) throws CliError", () => {
assert.throws(() => validateCronExpression("* 24 * * *"), /outside the allowed range/);
});

test("parseCron: invalid — dayOfMonth 0 (out of range 1-31) throws CliError", () => {
assert.throws(() => validateCronExpression("* * 0 * *"), /outside the allowed range/);
});

test("parseCron: invalid — dayOfMonth 32 (out of range 1-31) throws CliError", () => {
assert.throws(() => validateCronExpression("* * 32 * *"), /outside the allowed range/);
});

test("parseCron: invalid — month 0 (out of range 1-12) throws CliError", () => {
assert.throws(() => validateCronExpression("* * * 0 *"), /outside the allowed range/);
});

test("parseCron: invalid — month 13 (out of range 1-12) throws CliError", () => {
assert.throws(() => validateCronExpression("* * * 13 *"), /outside the allowed range/);
});

test("parseCron: invalid — dayOfWeek 7 (out of range 0-6) throws CliError", () => {
assert.throws(() => validateCronExpression("* * * * 7"), /outside the allowed range/);
});

test("parseCron: invalid — range with start > end throws CliError", () => {
assert.throws(() => validateCronExpression("5-3 * * * *"), /invalid/);
});

test("parseCron: invalid — non-numeric field value throws CliError", () => {
assert.throws(() => validateCronExpression("* * * * abc"), /outside the allowed range/);
});

// ─── nextScheduleTick ────────────────────────────────────────────────────────
//
// Reference date: Thursday, 2026-01-15, 10:30:00 local time.
// January 15, 2026 is a Thursday (getDay() === 4).

const REF = () => new Date(2026, 0, 15, 10, 30, 0, 0);

test("nextScheduleTick: wildcard '* * * * *' fires 1 minute after reference", () => {
const tick = nextScheduleTick("* * * * *", REF());
assert.equal(tick.waitMs, 60_000, "should wait exactly 1 minute");
const runAt = new Date(tick.runAt);
assert.equal(runAt.getMinutes(), 31);
assert.equal(runAt.getHours(), 10);
});

test("nextScheduleTick: step */15 from :30 fires at :45 — 15 minutes away", () => {
// minutes in set: {0, 15, 30, 45}. cursor starts at :31, next match = :45
const tick = nextScheduleTick("*/15 * * * *", REF());
assert.equal(tick.waitMs, 15 * 60_000, "should wait 15 minutes");
const runAt = new Date(tick.runAt);
assert.equal(runAt.getMinutes(), 45);
assert.equal(runAt.getHours(), 10);
});

test("nextScheduleTick: step */15 from :00 fires at :15 — 15 minutes away", () => {
// cursor starts at :01 (after minute+1), walks to :15
const ref = new Date(2026, 0, 15, 10, 0, 0, 0);
const tick = nextScheduleTick("*/15 * * * *", ref);
assert.equal(tick.waitMs, 15 * 60_000);
const runAt = new Date(tick.runAt);
assert.equal(runAt.getMinutes(), 15);
assert.equal(runAt.getHours(), 10);
});

test("nextScheduleTick: range 1-5 from :00 fires at :01 — 1 minute away", () => {
const ref = new Date(2026, 0, 15, 10, 0, 0, 0);
const tick = nextScheduleTick("1-5 * * * *", ref);
assert.equal(tick.waitMs, 60_000);
const runAt = new Date(tick.runAt);
assert.equal(runAt.getMinutes(), 1);
});

test("nextScheduleTick: list 1,3,5 from :02 fires at :03 — 1 minute away", () => {
const ref = new Date(2026, 0, 15, 10, 2, 0, 0);
const tick = nextScheduleTick("1,3,5 * * * *", ref);
assert.equal(tick.waitMs, 60_000);
const runAt = new Date(tick.runAt);
assert.equal(runAt.getMinutes(), 3);
});

test("nextScheduleTick: fixed hour '0 11 * * *' from 10:30 fires at 11:00", () => {
// 30 minutes to reach 11:00
const tick = nextScheduleTick("0 11 * * *", REF());
assert.equal(tick.waitMs, 30 * 60_000, "should wait 30 minutes for 11:00");
const runAt = new Date(tick.runAt);
assert.equal(runAt.getHours(), 11);
assert.equal(runAt.getMinutes(), 0);
assert.equal(runAt.getDate(), 15);
});

test("nextScheduleTick: day-of-week constraint — next Monday noon from Thursday", () => {
// REF is Thursday Jan 15; next Monday is Jan 19.
// "0 12 * * 1" = noon on Monday
const tick = nextScheduleTick("0 12 * * 1", REF());
const runAt = new Date(tick.runAt);
assert.equal(runAt.getDay(), 1, "must land on a Monday");
assert.equal(runAt.getHours(), 12);
assert.equal(runAt.getMinutes(), 0);
assert.equal(runAt.getDate(), 19, "next Monday is Jan 19");
assert.equal(runAt.getMonth(), 0, "still in January");
});

test("nextScheduleTick: month rollover — '0 0 1 * *' from Jan 31 midnight fires Feb 1", () => {
// Start at end of January; first-of-month midnight requires crossing to Feb.
const ref = new Date(2026, 0, 31, 0, 0, 0, 0);
const tick = nextScheduleTick("0 0 1 * *", ref);
// 24 hours from Jan 31 00:00 to Feb 1 00:00 (no DST between Jan and Feb)
assert.equal(tick.waitMs, 24 * 60 * 60_000, "should wait exactly 24 hours");
const runAt = new Date(tick.runAt);
assert.equal(runAt.getDate(), 1);
assert.equal(runAt.getMonth(), 1, "February = month index 1");
assert.equal(runAt.getFullYear(), 2026);
assert.equal(runAt.getHours(), 0);
assert.equal(runAt.getMinutes(), 0);
});

test("nextScheduleTick: year-end rollover — '0 0 1 1 *' from Dec 31 midnight fires Jan 1 next year", () => {
const ref = new Date(2026, 11, 31, 0, 0, 0, 0);
const tick = nextScheduleTick("0 0 1 1 *", ref);
// 24 hours from Dec 31 00:00 to Jan 1 00:00
assert.equal(tick.waitMs, 24 * 60 * 60_000);
const runAt = new Date(tick.runAt);
assert.equal(runAt.getDate(), 1);
assert.equal(runAt.getMonth(), 0, "January = month index 0");
assert.equal(runAt.getFullYear(), 2027);
assert.equal(runAt.getHours(), 0);
assert.equal(runAt.getMinutes(), 0);
});

test("nextScheduleTick: iteration cap — impossible date '0 0 30 2 *' throws after 525600 iterations", { timeout: 10_000 }, () => {
// February never has 30 days; the loop exhausts 525600 iterations and throws.
assert.throws(
() => nextScheduleTick("0 0 30 2 *", new Date(2026, 0, 1, 0, 0, 0, 0)),
(err: Error) => err.message.includes("could not compute next run")
);
});
2 changes: 1 addition & 1 deletion packages/memory-digest-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "tsx --test tests/*.test.ts",
"test:coverage": "tsx --test --experimental-test-coverage tests/*.test.ts"
"test:coverage": "tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' --test-coverage-lines=90 --test-coverage-branches=80 --test-coverage-functions=92 tests/*.test.ts"
},
"engines": {
"node": ">=20"
Expand Down
156 changes: 156 additions & 0 deletions packages/memory-digest-cli/tests/generate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Unit tests for packages/memory-digest-cli/src/commands/generate.ts
//
// Strategy: Register the command on a fresh Commander instance, then REPLACE
// the action handler with a spy before calling parseAsync. This drives the
// option-parsing layer without touching the filesystem or any external lib.
//
// The action override is the standard Commander API — calling .action() again
// replaces the previously registered handler.

import { test } from "node:test";
import assert from "node:assert/strict";
import { Command } from "commander";
import { registerGenerateCommand } from "../src/commands/generate.js";

// Helper: create a fresh program with the generate subcommand registered and
// return references to both. Each test call gets a completely isolated
// Commander tree so option-parser state never bleeds between tests.
function buildProgram(): { program: Command; generateCmd: Command } {
const program = new Command();
// Prevent Commander from calling process.exit on unknown args or --help.
program.exitOverride();
program.allowUnknownOption(false);

registerGenerateCommand(program);

const generateCmd = program.commands.find((c) => c.name() === "generate");
assert.ok(generateCmd, "generate subcommand must be registered by registerGenerateCommand");
return { program, generateCmd: generateCmd! };
}

// ─── registration ────────────────────────────────────────────────────────────

test("registerGenerateCommand: registers a subcommand named 'generate'", () => {
const { generateCmd } = buildProgram();
assert.equal(generateCmd.name(), "generate");
});

test("registerGenerateCommand: subcommand has a non-empty description", () => {
const { generateCmd } = buildProgram();
assert.ok(
generateCmd.description().length > 0,
"description should not be empty"
);
});

// ─── default option values ───────────────────────────────────────────────────

test("generate: default options are correct when no flags are passed", async () => {
const { program, generateCmd } = buildProgram();

let capturedOptions: Record<string, unknown> | undefined;
generateCmd.action((opts: Record<string, unknown>) => {
capturedOptions = opts;
});

await program.parseAsync(["generate"], { from: "user" });

assert.ok(capturedOptions !== undefined, "action must have been called");
assert.equal(capturedOptions.dir, process.cwd(), "--dir default must be process.cwd()");
assert.equal(capturedOptions.days, "7", "--days default must be '7'");
assert.equal(capturedOptions.max, "50", "--max default must be '50'");
assert.equal(capturedOptions.recursive, false, "--recursive default must be false");
assert.equal(capturedOptions.json, false, "--json default must be false");
assert.equal(capturedOptions.output, undefined, "--output default must be undefined");
});

// ─── non-default flag mappings ───────────────────────────────────────────────

test("generate: --days and --max override their defaults", async () => {
const { program, generateCmd } = buildProgram();

let capturedOptions: Record<string, unknown> | undefined;
generateCmd.action((opts: Record<string, unknown>) => {
capturedOptions = opts;
});

await program.parseAsync(
["generate", "--days", "30", "--max", "100"],
{ from: "user" }
);

assert.equal(capturedOptions!.days, "30");
assert.equal(capturedOptions!.max, "100");
// Unaffected defaults must remain intact.
assert.equal(capturedOptions!.recursive, false);
assert.equal(capturedOptions!.json, false);
});

test("generate: --recursive and --json boolean flags are set to true", async () => {
const { program, generateCmd } = buildProgram();

let capturedOptions: Record<string, unknown> | undefined;
generateCmd.action((opts: Record<string, unknown>) => {
capturedOptions = opts;
});

await program.parseAsync(["generate", "--recursive", "--json"], { from: "user" });

assert.equal(capturedOptions!.recursive, true, "--recursive must become true");
assert.equal(capturedOptions!.json, true, "--json must become true");
});

test("generate: -d short alias sets the dir option", async () => {
const { program, generateCmd } = buildProgram();

let capturedOptions: Record<string, unknown> | undefined;
generateCmd.action((opts: Record<string, unknown>) => {
capturedOptions = opts;
});

await program.parseAsync(["generate", "-d", "/tmp/my-memories"], { from: "user" });

assert.equal(capturedOptions!.dir, "/tmp/my-memories");
});

test("generate: -o short alias sets the output option", async () => {
const { program, generateCmd } = buildProgram();

let capturedOptions: Record<string, unknown> | undefined;
generateCmd.action((opts: Record<string, unknown>) => {
capturedOptions = opts;
});

await program.parseAsync(["generate", "-o", "digest.md"], { from: "user" });

assert.equal(capturedOptions!.output, "digest.md");
});

test("generate: all non-default flags can be passed together", async () => {
const { program, generateCmd } = buildProgram();

let capturedOptions: Record<string, unknown> | undefined;
generateCmd.action((opts: Record<string, unknown>) => {
capturedOptions = opts;
});

await program.parseAsync(
[
"generate",
"--dir", "/tmp/memories",
"--output", "out.json",
"--days", "14",
"--max", "200",
"--recursive",
"--json",
],
{ from: "user" }
);

assert.equal(capturedOptions!.dir, "/tmp/memories");
assert.equal(capturedOptions!.output, "out.json");
assert.equal(capturedOptions!.days, "14");
assert.equal(capturedOptions!.max, "200");
assert.equal(capturedOptions!.recursive, true);
assert.equal(capturedOptions!.json, true);
});
Loading
Loading