Files
ocas/packages/cli/tests/list-pagination.test.ts
T
xiaoju 92a024fc1c feat: add sorting, pagination, and timestamps to list commands
Add --sort, --limit, --offset, --desc flags to `list --type`, `list-meta`,
`list-schema`, and `var list`. Change Store.listByType to return
{hash, created, updated}[] and extend VariableStore.list with the same
sort/pagination params.

Fixes #27
2026-06-01 14:41:25 +00:00

250 lines
7.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { envValue, runCli } from "./helpers.js";
const HASH_RE = /^[0-9A-HJKMNP-TV-Z]{13}$/;
let storePath: string;
beforeEach(() => {
storePath = mkdtempSync(join(tmpdir(), "ocas-list-pagination-"));
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
rmSync(storePath, { recursive: true, force: true });
});
async function putString(text: string): Promise<string> {
// Use the @ocas/string built-in via library; CLI doesn't expose put-text but
// `put @ocas/string --pipe` works. We'll use --pipe with stdin.
const proc = Bun.spawn(
[
"bun",
join(import.meta.dir, "../src/index.ts"),
"--home",
storePath,
"put",
"@ocas/string",
"--pipe",
],
{ stdout: "pipe", stderr: "pipe", stdin: "pipe" },
);
proc.stdin.write(JSON.stringify(text));
proc.stdin.end();
await proc.exited;
const out = await new Response(proc.stdout).text();
return (JSON.parse(out) as { value: string }).value;
}
async function makeN(n: number, gap = 2): Promise<string[]> {
const hashes: string[] = [];
for (let i = 0; i < n; i++) {
hashes.push(await putString(`val-${i}-${Math.random()}`));
if (gap > 0 && i < n - 1) {
await new Promise((r) => setTimeout(r, gap));
}
}
return hashes;
}
describe("CLI list --type pagination", () => {
test("E1. entries are {hash, created, updated} objects", async () => {
await makeN(3);
const { stdout, exitCode } = await runCli(
["list", "--type", "@ocas/string"],
storePath,
);
expect(exitCode).toBe(0);
const value = envValue(stdout) as Array<{
hash: string;
created: number;
updated: number;
}>;
expect(value.length).toBeGreaterThan(0);
for (const e of value) {
expect(e.hash).toMatch(HASH_RE);
expect(typeof e.created).toBe("number");
expect(typeof e.updated).toBe("number");
}
});
test("E2. --limit 2", async () => {
await makeN(5, 0);
const { stdout, exitCode } = await runCli(
["list", "--type", "@ocas/string", "--limit", "2"],
storePath,
);
expect(exitCode).toBe(0);
const value = envValue(stdout) as unknown[];
expect(value).toHaveLength(2);
});
test("E3. --offset 1 skips first", async () => {
await makeN(3);
const { stdout: a } = await runCli(
["list", "--type", "@ocas/string"],
storePath,
);
const { stdout: b } = await runCli(
["list", "--type", "@ocas/string", "--offset", "1", "--limit", "100"],
storePath,
);
const all = envValue(a) as Array<{ hash: string }>;
const skip = envValue(b) as Array<{ hash: string }>;
expect(skip).toHaveLength(all.length - 1);
expect((skip[0] as { hash: string }).hash).toBe(
(all[1] as { hash: string }).hash,
);
});
test("E4. --desc reverses default order", async () => {
await makeN(3);
const { stdout, exitCode } = await runCli(
["list", "--type", "@ocas/string", "--desc"],
storePath,
);
expect(exitCode).toBe(0);
const value = envValue(stdout) as Array<{ created: number }>;
for (let i = 1; i < value.length; i++) {
expect((value[i] as { created: number }).created).toBeLessThanOrEqual(
(value[i - 1] as { created: number }).created,
);
}
});
test("E5. --sort updated accepted; equals created for CAS", async () => {
await makeN(3);
const { stdout: a } = await runCli(
["list", "--type", "@ocas/string", "--sort", "created"],
storePath,
);
const { stdout: b } = await runCli(
["list", "--type", "@ocas/string", "--sort", "updated"],
storePath,
);
expect(envValue(a)).toEqual(envValue(b));
});
test("E7. invalid --sort exits non-zero", async () => {
const { exitCode, stderr } = await runCli(
["list", "--type", "@ocas/string", "--sort", "foo"],
storePath,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("--sort");
});
test("E8. invalid --limit exits non-zero", async () => {
const r1 = await runCli(
["list", "--type", "@ocas/string", "--limit", "-1"],
storePath,
);
expect(r1.exitCode).not.toBe(0);
expect(r1.stderr).toContain("--limit");
const r2 = await runCli(
["list", "--type", "@ocas/string", "--limit", "abc"],
storePath,
);
expect(r2.exitCode).not.toBe(0);
expect(r2.stderr).toContain("--limit");
});
});
describe("CLI list-meta / list-schema pagination", () => {
test("F1. list-meta entries are objects", async () => {
// Bootstrap implicitly happens when running any cli command that opens store
await runCli(["list-meta"], storePath);
const { stdout } = await runCli(["list-meta"], storePath);
const value = envValue(stdout) as Array<{
hash: string;
created: number;
updated: number;
}>;
expect(value.length).toBeGreaterThanOrEqual(1);
for (const e of value) {
expect(e.hash).toMatch(HASH_RE);
expect(typeof e.created).toBe("number");
}
});
test("F2. list-schema --limit honored", async () => {
const { stdout } = await runCli(["list-schema", "--limit", "3"], storePath);
const value = envValue(stdout) as unknown[];
expect(value).toHaveLength(3);
});
test("F3. list-schema --desc reverses order", async () => {
const { stdout: asc } = await runCli(["list-schema"], storePath);
const { stdout: desc } = await runCli(["list-schema", "--desc"], storePath);
const a = envValue(asc) as Array<{ hash: string }>;
const d = envValue(desc) as Array<{ hash: string }>;
expect(d[0]?.hash).toBe(a[a.length - 1]?.hash);
});
});
describe("CLI var list pagination", () => {
test("G1./G2. --limit and --offset on var list", async () => {
for (let i = 0; i < 4; i++) {
const h = await putString(`tval-${i}`);
await runCli(["var", "set", `myvar-${i}`, h], storePath);
await new Promise((r) => setTimeout(r, 2));
}
const { stdout: lim } = await runCli(
["var", "list", "myvar-", "--limit", "2"],
storePath,
);
expect((envValue(lim) as unknown[]).length).toBe(2);
const { stdout: off } = await runCli(
["var", "list", "myvar-", "--offset", "1", "--limit", "10"],
storePath,
);
const offList = envValue(off) as Array<{ name: string }>;
expect(offList.length).toBe(3);
expect(offList[0]?.name).toBe("myvar-1");
});
test("G3. --desc reverses var list order", async () => {
for (let i = 0; i < 3; i++) {
const h = await putString(`dval-${i}`);
await runCli(["var", "set", `dv-${i}`, h], storePath);
await new Promise((r) => setTimeout(r, 2));
}
const { stdout } = await runCli(
["var", "list", "dv-", "--desc"],
storePath,
);
const list = envValue(stdout) as Array<{ name: string }>;
expect(list[0]?.name).toBe("dv-2");
});
test("G4. --sort updated", async () => {
for (let i = 0; i < 3; i++) {
const h = await putString(`sval-${i}`);
await runCli(["var", "set", `sv-${i}`, h], storePath);
await new Promise((r) => setTimeout(r, 2));
}
// Re-set sv-0 with NEW value to bump updated
await new Promise((r) => setTimeout(r, 2));
const newH = await putString("sval-0-new");
await runCli(["var", "set", "sv-0", newH], storePath);
const { stdout } = await runCli(
["var", "list", "sv-", "--sort", "updated"],
storePath,
);
const list = envValue(stdout) as Array<{ name: string }>;
expect(list[list.length - 1]?.name).toBe("sv-0");
});
test("G6. invalid --sort exits non-zero", async () => {
const r = await runCli(["var", "list", "--sort", "bogus"], storePath);
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toContain("--sort");
});
});