chore: delete dumb tests
This commit is contained in:
+1
-1
@@ -117,7 +117,7 @@
|
||||
"build:native": "bun --cwd=packages/natives run build",
|
||||
"test": "bun scripts/ci-test-ts.ts local",
|
||||
"test:ts": "bun scripts/ci-test-ts.ts local-ts",
|
||||
"test:scripts": "bun test scripts/bazel-natives.test.ts scripts/ci-concurrency.test.ts scripts/ci-release-build-binaries.test.ts scripts/ci-release-notes.test.ts scripts/ci-release-publish.test.ts scripts/fix-dts-extensions.test.ts scripts/link-omp.test.ts scripts/musl-release.test.ts",
|
||||
"test:scripts": "bun test scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts",
|
||||
"test:rs": "bun scripts/run-rs-task.ts test:rs",
|
||||
"check": "bun run --parallel check:ts check:rs",
|
||||
"check:ts": "bun run check:tools && bun run --workspaces --if-present check",
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { $ } from "bun";
|
||||
import {
|
||||
conventionOutputPaths,
|
||||
type HostInfo,
|
||||
hostTargetName,
|
||||
parseBazelFilesOutput,
|
||||
parseCliArgs,
|
||||
resolveTargetLabels,
|
||||
} from "./bazel-natives";
|
||||
|
||||
const linuxModern: HostInfo = { platform: "linux", arch: "x64", avx2: true };
|
||||
const linuxBaseline: HostInfo = { platform: "linux", arch: "x64", avx2: false };
|
||||
const macArm: HostInfo = { platform: "darwin", arch: "arm64", avx2: false };
|
||||
|
||||
describe("hostTargetName", () => {
|
||||
test("picks the x64 variant from AVX2 support", () => {
|
||||
expect(hostTargetName(linuxModern)).toBe("linux-x64-modern");
|
||||
expect(hostTargetName(linuxBaseline)).toBe("linux-x64-baseline");
|
||||
// darwin x64 ships baseline only; AVX2 must not invent a modern target.
|
||||
expect(hostTargetName({ platform: "darwin", arch: "x64", avx2: true })).toBe("darwin-x64-baseline");
|
||||
});
|
||||
|
||||
test("maps non-x64 and windows hosts", () => {
|
||||
expect(hostTargetName(macArm)).toBe("darwin-arm64");
|
||||
expect(hostTargetName({ platform: "linux", arch: "arm64", avx2: false })).toBe("linux-arm64");
|
||||
expect(hostTargetName({ platform: "win32", arch: "x64", avx2: true })).toBe("win32-x64-baseline");
|
||||
});
|
||||
|
||||
test("rejects hosts without an addon target", () => {
|
||||
expect(() => hostTargetName({ platform: "freebsd", arch: "x64", avx2: false })).toThrow(
|
||||
/No pi_natives addon target/,
|
||||
);
|
||||
expect(() => hostTargetName({ platform: "win32", arch: "arm64", avx2: false })).toThrow(
|
||||
/No pi_natives addon target/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTargetLabels", () => {
|
||||
test("maps explicit names, pseudo-targets, and aggregates to labels", () => {
|
||||
expect(resolveTargetLabels(["linux-x64-baseline", "linux-x64-modern"], macArm)).toEqual([
|
||||
"//:natives-linux-x64-baseline",
|
||||
"//:natives-linux-x64-modern",
|
||||
]);
|
||||
expect(resolveTargetLabels(["host"], linuxModern)).toEqual(["//:natives-linux-x64-modern"]);
|
||||
expect(resolveTargetLabels(["linux-all", "darwin-all"], macArm)).toEqual([
|
||||
"//:natives-linux-all",
|
||||
"//:natives-darwin-all",
|
||||
]);
|
||||
});
|
||||
|
||||
test("deduplicates and rejects unknown targets", () => {
|
||||
expect(resolveTargetLabels(["host", "darwin-arm64"], macArm)).toEqual(["//:natives-darwin-arm64"]);
|
||||
expect(() => resolveTargetLabels(["linux-x64"], macArm)).toThrow(/Unknown native target "linux-x64"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conventionOutputPaths", () => {
|
||||
test("builds bazel-bin paths with canonical filenames (musl reuses linux names)", () => {
|
||||
expect(conventionOutputPaths(["linux-musl-x64-baseline", "win32-x64-baseline"], macArm)).toEqual([
|
||||
"bazel-bin/natives-linux-musl-x64-baseline/pi_natives.linux-x64-baseline.node",
|
||||
"bazel-bin/natives-win32-x64-baseline/pi_natives.win32-x64-baseline.node",
|
||||
]);
|
||||
});
|
||||
|
||||
test("expands aggregates and the host pseudo-target", () => {
|
||||
expect(conventionOutputPaths(["darwin-all"], macArm)).toEqual([
|
||||
"bazel-bin/natives-darwin-arm64/pi_natives.darwin-arm64.node",
|
||||
"bazel-bin/natives-darwin-x64-baseline/pi_natives.darwin-x64-baseline.node",
|
||||
]);
|
||||
expect(conventionOutputPaths(["host"], linuxBaseline)).toEqual([
|
||||
"bazel-bin/natives-linux-x64-baseline/pi_natives.linux-x64-baseline.node",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBazelFilesOutput", () => {
|
||||
test("keeps only .node paths, trimmed and deduplicated", () => {
|
||||
const output = [
|
||||
"bazel-bin/natives-linux-x64-baseline/pi_natives.linux-x64-baseline.node",
|
||||
" bazel-bin/natives-linux-x64-modern/pi_natives.linux-x64-modern.node ",
|
||||
"bazel-bin/natives-linux-x64-baseline/pi_natives.linux-x64-baseline.node",
|
||||
"INFO: Analyzed 2 targets (0 packages loaded, 0 targets configured).",
|
||||
"",
|
||||
].join("\n");
|
||||
expect(parseBazelFilesOutput(output)).toEqual([
|
||||
"bazel-bin/natives-linux-x64-baseline/pi_natives.linux-x64-baseline.node",
|
||||
"bazel-bin/natives-linux-x64-modern/pi_natives.linux-x64-modern.node",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns empty for output without addon files", () => {
|
||||
expect(parseBazelFilesOutput("INFO: Build completed successfully\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCliArgs", () => {
|
||||
test("splits targets, paths, and passthrough bazel args", () => {
|
||||
expect(
|
||||
parseCliArgs(["linux-x64-baseline", "linux-x64-modern", "--dest", "out", "--", "--config=ci", "--dest"]),
|
||||
).toEqual({
|
||||
targets: ["linux-x64-baseline", "linux-x64-modern"],
|
||||
dest: "out",
|
||||
source: null,
|
||||
bazelArgs: ["--config=ci", "--dest"],
|
||||
});
|
||||
expect(parseCliArgs(["host", "--source", "artifact"])).toEqual({
|
||||
targets: ["host"],
|
||||
dest: null,
|
||||
source: "artifact",
|
||||
bazelArgs: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid build and artifact source combinations", () => {
|
||||
expect(() => parseCliArgs([])).toThrow(/Usage:/);
|
||||
expect(() => parseCliArgs(["--", "--config=ci"])).toThrow(/Usage:/);
|
||||
expect(() => parseCliArgs(["host", "--config=ci"])).toThrow(/Unknown flag --config=ci/);
|
||||
expect(() => parseCliArgs(["host", "--dest"])).toThrow(/--dest requires/);
|
||||
expect(() => parseCliArgs(["host", "--source"])).toThrow(/--source requires/);
|
||||
expect(() => parseCliArgs(["host", "--source", "artifact", "--", "--config=ci"])).toThrow(
|
||||
/--source cannot be combined/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact source install", () => {
|
||||
test("installs exact target outputs without invoking Bazel", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-native-artifacts-"));
|
||||
const source = path.join(root, "source");
|
||||
const dest = path.join(root, "dest");
|
||||
const baseline = "pi_natives.linux-x64-baseline.node";
|
||||
const modern = "pi_natives.linux-x64-modern.node";
|
||||
try {
|
||||
await fs.mkdir(path.join(source, "natives-linux-x64-baseline"), { recursive: true });
|
||||
await fs.mkdir(path.join(source, "natives-linux-x64-modern"), { recursive: true });
|
||||
await Bun.write(path.join(source, "natives-linux-x64-baseline", baseline), "baseline");
|
||||
await Bun.write(path.join(source, "natives-linux-x64-modern", modern), "modern");
|
||||
|
||||
const result =
|
||||
await $`${process.execPath} ${path.join(import.meta.dir, "bazel-natives.ts")} linux-x64-baseline linux-x64-modern --source ${source} --dest ${dest}`
|
||||
.quiet()
|
||||
.nothrow();
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(await Bun.file(path.join(dest, baseline)).text()).toBe("baseline");
|
||||
expect(await Bun.file(path.join(dest, modern)).text()).toBe("modern");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
// Regression test for #2564: the CI workflow's `concurrency` block must route
|
||||
// release runs to a per-sha group with no cancellation, so a later main push
|
||||
// can't kill the in-flight release and leave the tag unpublished. The block is
|
||||
// evaluated by GitHub at workflow-scheduling time (before any job can produce
|
||||
// the signal), so this test re-implements the small subset of GitHub
|
||||
// expression semantics the block uses and asserts the resolved group / cancel
|
||||
// flag for every event shape we care about.
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
|
||||
const WORKFLOW_PATH = path.resolve(import.meta.dir, "..", ".github", "workflows", "ci.yml");
|
||||
|
||||
type Value = string | boolean | null;
|
||||
|
||||
// `github` context fed into the evaluator. Nested objects are walked the same
|
||||
// way as in real GHA expressions; missing keys resolve to `null`.
|
||||
interface GhaCtx {
|
||||
workflow: string;
|
||||
ref: string;
|
||||
sha: string;
|
||||
event_name: string;
|
||||
event: {
|
||||
head_commit?: { message?: string };
|
||||
};
|
||||
}
|
||||
|
||||
// Single-purpose, hand-rolled evaluator for the operators / functions the
|
||||
// workflow's `concurrency` block uses: `startsWith`, `format`, `!`, `==`,
|
||||
// `&&`, `||`, parens, single-quoted strings, dotted property access. Matches
|
||||
// short-circuit semantics: `&&`/`||` return the underlying value (not a coerced
|
||||
// bool), missing identifiers resolve to `null`, and `startsWith(null, …)` is
|
||||
// false because the searchString coerces to `""`.
|
||||
class GhaEval {
|
||||
#pos = 0;
|
||||
|
||||
private constructor(
|
||||
private readonly src: string,
|
||||
private readonly ctx: { github: GhaCtx },
|
||||
) {}
|
||||
|
||||
static run(expr: string, ctx: { github: GhaCtx }): Value {
|
||||
const ev = new GhaEval(expr.trim(), ctx);
|
||||
const value = ev.#or();
|
||||
ev.#skipWs();
|
||||
if (ev.#pos !== ev.src.length) {
|
||||
throw new Error(`trailing input at offset ${ev.#pos}: ${ev.src.slice(ev.#pos)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Substitute every `${{ … }}` placeholder in a workflow template string.
|
||||
static template(template: string, ctx: { github: GhaCtx }): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < template.length) {
|
||||
const start = template.indexOf("${{", i);
|
||||
if (start === -1) {
|
||||
out += template.slice(i);
|
||||
break;
|
||||
}
|
||||
out += template.slice(i, start);
|
||||
const end = template.indexOf("}}", start);
|
||||
if (end === -1) throw new Error("unterminated ${{ expression");
|
||||
const v = GhaEval.run(template.slice(start + 3, end), ctx);
|
||||
out += v === null ? "" : String(v);
|
||||
i = end + 2;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
#or(): Value {
|
||||
let left = this.#and();
|
||||
while (this.#consume("||")) {
|
||||
const right = this.#and();
|
||||
// Truthy left wins; only null/false/"" fall through.
|
||||
if (left !== null && left !== false && left !== "") continue;
|
||||
left = right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
#and(): Value {
|
||||
let left = this.#eq();
|
||||
while (this.#consume("&&")) {
|
||||
const right = this.#eq();
|
||||
// Falsy left short-circuits and is returned verbatim.
|
||||
if (left === null || left === false || left === "") continue;
|
||||
left = right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
#eq(): Value {
|
||||
let left = this.#unary();
|
||||
while (true) {
|
||||
if (this.#consume("==")) {
|
||||
const right = this.#unary();
|
||||
left = left === right;
|
||||
continue;
|
||||
}
|
||||
if (this.#consume("!=")) {
|
||||
const right = this.#unary();
|
||||
left = left !== right;
|
||||
continue;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
}
|
||||
|
||||
#unary(): Value {
|
||||
this.#skipWs();
|
||||
if (this.src[this.#pos] === "!") {
|
||||
this.#pos++;
|
||||
const v = this.#unary();
|
||||
return v === null || v === false || v === "";
|
||||
}
|
||||
return this.#primary();
|
||||
}
|
||||
|
||||
#primary(): Value {
|
||||
this.#skipWs();
|
||||
const ch = this.src[this.#pos];
|
||||
if (ch === "(") {
|
||||
this.#pos++;
|
||||
const v = this.#or();
|
||||
this.#skipWs();
|
||||
if (this.src[this.#pos] !== ")") throw new Error("expected `)`");
|
||||
this.#pos++;
|
||||
return v;
|
||||
}
|
||||
if (ch === "'") return this.#string();
|
||||
// Identifier or function call.
|
||||
const ident = this.#identifier();
|
||||
this.#skipWs();
|
||||
if (this.src[this.#pos] === "(") return this.#call(ident);
|
||||
return this.#readPath(ident);
|
||||
}
|
||||
|
||||
#string(): string {
|
||||
// GHA single-quoted: `''` is an escaped quote.
|
||||
this.#pos++; // opening quote
|
||||
let out = "";
|
||||
while (this.#pos < this.src.length) {
|
||||
const c = this.src[this.#pos];
|
||||
if (c === "'") {
|
||||
if (this.src[this.#pos + 1] === "'") {
|
||||
out += "'";
|
||||
this.#pos += 2;
|
||||
continue;
|
||||
}
|
||||
this.#pos++;
|
||||
return out;
|
||||
}
|
||||
out += c;
|
||||
this.#pos++;
|
||||
}
|
||||
throw new Error("unterminated string literal");
|
||||
}
|
||||
|
||||
#identifier(): string {
|
||||
const start = this.#pos;
|
||||
while (this.#pos < this.src.length && /[A-Za-z0-9_.]/.test(this.src[this.#pos]!)) {
|
||||
this.#pos++;
|
||||
}
|
||||
if (start === this.#pos) throw new Error(`expected identifier at ${this.#pos}`);
|
||||
return this.src.slice(start, this.#pos);
|
||||
}
|
||||
|
||||
#call(name: string): Value {
|
||||
this.#pos++; // opening paren
|
||||
const args: Value[] = [];
|
||||
this.#skipWs();
|
||||
if (this.src[this.#pos] !== ")") {
|
||||
for (;;) {
|
||||
args.push(this.#or());
|
||||
this.#skipWs();
|
||||
if (this.src[this.#pos] === ",") {
|
||||
this.#pos++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.#skipWs();
|
||||
if (this.src[this.#pos] !== ")") throw new Error("expected `)` closing call");
|
||||
this.#pos++;
|
||||
switch (name) {
|
||||
case "startsWith": {
|
||||
const hay = args[0] === null || args[0] === false ? "" : String(args[0]);
|
||||
const needle = args[1] === null || args[1] === false ? "" : String(args[1]);
|
||||
return hay.startsWith(needle);
|
||||
}
|
||||
case "format": {
|
||||
const tmpl = args[0] === null ? "" : String(args[0]);
|
||||
return tmpl.replace(/\{(\d+)\}/g, (_, idx) => {
|
||||
const v = args[Number(idx) + 1];
|
||||
return v === null || v === false ? "" : String(v);
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`unsupported function: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
#readPath(dotted: string): Value {
|
||||
let cur: unknown = this.ctx;
|
||||
for (const seg of dotted.split(".")) {
|
||||
if (cur == null || typeof cur !== "object") return null;
|
||||
cur = (cur as Record<string, unknown>)[seg];
|
||||
}
|
||||
if (cur === undefined || cur === null) return null;
|
||||
if (typeof cur === "object") return null;
|
||||
return cur as Value;
|
||||
}
|
||||
|
||||
#consume(op: string): boolean {
|
||||
this.#skipWs();
|
||||
if (this.src.startsWith(op, this.#pos)) {
|
||||
this.#pos += op.length;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#skipWs(): void {
|
||||
while (this.#pos < this.src.length && /\s/.test(this.src[this.#pos]!)) this.#pos++;
|
||||
}
|
||||
}
|
||||
|
||||
const workflowYaml = await Bun.file(WORKFLOW_PATH).text();
|
||||
// The block sits at indent 0 immediately under the top-level `concurrency:`
|
||||
// key and uses single-line values, so a flat-line extract is unambiguous.
|
||||
// Values are double-quoted in YAML (the GitHub expression contains `: ` from
|
||||
// the `'chore: bump version to '` literal which would otherwise trip plain
|
||||
// scalar parsing), so we unwrap the wrapping `"…"` here.
|
||||
const concurrencySection = workflowYaml.slice(workflowYaml.indexOf("\nconcurrency:") + 1);
|
||||
const groupRaw = /^\s*group:\s*(\S.*?)\s*$/m.exec(concurrencySection)?.[1];
|
||||
const cancelRaw = /^\s*cancel-in-progress:\s*(\S.*?)\s*$/m.exec(concurrencySection)?.[1];
|
||||
const groupTemplate = groupRaw?.startsWith('"') && groupRaw.endsWith('"') ? groupRaw.slice(1, -1) : groupRaw;
|
||||
const cancelTemplate = cancelRaw?.startsWith('"') && cancelRaw.endsWith('"') ? cancelRaw.slice(1, -1) : cancelRaw;
|
||||
if (!groupTemplate || !cancelTemplate) {
|
||||
throw new Error("could not locate concurrency.group / cancel-in-progress in ci.yml");
|
||||
}
|
||||
|
||||
const RELEASE_SUBJECT = "chore: bump version to 15.12.6";
|
||||
|
||||
const baseCtx = (overrides: Partial<GhaCtx> = {}): { github: GhaCtx } => ({
|
||||
github: {
|
||||
workflow: "CI",
|
||||
ref: "refs/heads/main",
|
||||
sha: "deadbeefcafebabe",
|
||||
event_name: "push",
|
||||
event: {},
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
describe("ci.yml concurrency", () => {
|
||||
it("auto release push: per-sha group, no cancellation (#2564 root cause)", () => {
|
||||
const ctx = baseCtx({ event: { head_commit: { message: `${RELEASE_SUBJECT}\n\nbody` } } });
|
||||
expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-release-deadbeefcafebabe");
|
||||
expect(GhaEval.template(cancelTemplate, ctx)).toBe("false");
|
||||
});
|
||||
|
||||
it("retry release push (release subject preserved): same per-sha behavior", () => {
|
||||
const ctx = baseCtx({
|
||||
sha: "feedfacedeadbeef",
|
||||
event: { head_commit: { message: `${RELEASE_SUBJECT}\n\nretry: fix sccache 100 exit` } },
|
||||
});
|
||||
expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-release-feedfacedeadbeef");
|
||||
expect(GhaEval.template(cancelTemplate, ctx)).toBe("false");
|
||||
});
|
||||
|
||||
it("workflow_dispatch from a `v*` tag ref: per-sha group, no cancellation", () => {
|
||||
const ctx = baseCtx({
|
||||
ref: "refs/tags/v15.12.6",
|
||||
event_name: "workflow_dispatch",
|
||||
sha: "abc123",
|
||||
event: {},
|
||||
});
|
||||
expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-release-abc123");
|
||||
expect(GhaEval.template(cancelTemplate, ctx)).toBe("false");
|
||||
});
|
||||
|
||||
it("workflow_dispatch from tagged main HEAD is isolated before release_metadata can inspect tags", () => {
|
||||
const ctx = baseCtx({
|
||||
event_name: "workflow_dispatch",
|
||||
sha: "taggedmain123",
|
||||
event: {},
|
||||
});
|
||||
expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-release-taggedmain123");
|
||||
expect(GhaEval.template(cancelTemplate, ctx)).toBe("false");
|
||||
});
|
||||
|
||||
it("regular main push: branch-wide group, cancel-in-progress enabled", () => {
|
||||
const ctx = baseCtx({ event: { head_commit: { message: "fix(ux): theme tweak" } } });
|
||||
expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-refs/heads/main");
|
||||
expect(GhaEval.template(cancelTemplate, ctx)).toBe("true");
|
||||
});
|
||||
|
||||
it("pull_request (no head_commit): branch-wide group, cancel enabled", () => {
|
||||
const ctx = baseCtx({ ref: "refs/pull/42/merge", event_name: "pull_request", event: {} });
|
||||
expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-refs/pull/42/merge");
|
||||
expect(GhaEval.template(cancelTemplate, ctx)).toBe("true");
|
||||
});
|
||||
|
||||
it("two release commits with distinct shas land in disjoint groups", () => {
|
||||
const a = baseCtx({ sha: "aaaa1111", event: { head_commit: { message: RELEASE_SUBJECT } } });
|
||||
const b = baseCtx({ sha: "bbbb2222", event: { head_commit: { message: RELEASE_SUBJECT } } });
|
||||
expect(GhaEval.template(groupTemplate, a)).not.toBe(GhaEval.template(groupTemplate, b));
|
||||
});
|
||||
|
||||
it("benign commit subject that merely contains the release prefix is not a release", () => {
|
||||
// startsWith is anchored, so `revert: chore: bump version to 15.12.6` (a
|
||||
// follow-up commit) keeps the cancel-on-newer-push behavior — it has no
|
||||
// tag to publish.
|
||||
const ctx = baseCtx({
|
||||
event: { head_commit: { message: `revert: ${RELEASE_SUBJECT}` } },
|
||||
});
|
||||
expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-refs/heads/main");
|
||||
expect(GhaEval.template(cancelTemplate, ctx)).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { compareVersions, enumerateChangelogVersions, mergePackageSection } from "./ci-release-notes";
|
||||
|
||||
const FIXTURE = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## [Unreleased]",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"- Unreleased entry not in any tag yet.",
|
||||
"",
|
||||
"## [15.13.0] - 2026-06-14",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed unknown `--`-prefixed flags being silently consumed as prompt text.",
|
||||
"- Fixed something only in 15.13.0.",
|
||||
"",
|
||||
"### Removed",
|
||||
"",
|
||||
"- Removed a deprecated thing in 15.13.0.",
|
||||
"",
|
||||
"## [15.12.6] - 2026-06-14",
|
||||
"",
|
||||
"### Breaking Changes",
|
||||
"",
|
||||
"- Removed `writeLine`/`writeLineSync` from the public SessionStorageWriter contract.",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"- Added package-level exports for session context.",
|
||||
"",
|
||||
"## [15.12.5] - 2026-06-13",
|
||||
"",
|
||||
"### Changed",
|
||||
"",
|
||||
"- Changed terminal resize handling to paint only the visible viewport.",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed unknown `--`-prefixed flags being silently consumed as prompt text.",
|
||||
"",
|
||||
"## [15.12.4] - 2026-06-13",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"- Predates the silent-tag window; must not appear when floor=15.12.4.",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("compareVersions", () => {
|
||||
it("orders semver tags numerically across all components", () => {
|
||||
expect(compareVersions("15.12.5", "15.13.0") < 0).toBe(true);
|
||||
expect(compareVersions("v15.13.0", "15.12.6") > 0).toBe(true);
|
||||
expect(compareVersions("15.12.6", "15.12.6") === 0).toBe(true);
|
||||
// Numeric (not lexicographic) — 15.2.0 < 15.13.0.
|
||||
expect(compareVersions("15.2.0", "15.13.0") < 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enumerateChangelogVersions", () => {
|
||||
it("returns every semver heading in document order, skipping Unreleased", () => {
|
||||
const spans = enumerateChangelogVersions(FIXTURE);
|
||||
expect(spans.map(s => s.version)).toEqual(["15.13.0", "15.12.6", "15.12.5", "15.12.4"]);
|
||||
});
|
||||
|
||||
it("bounds each span by the next `## [` heading (Unreleased included as boundary)", () => {
|
||||
const spans = enumerateChangelogVersions(FIXTURE);
|
||||
const lines = FIXTURE.split("\n");
|
||||
for (const span of spans) {
|
||||
expect(lines[span.start]).toMatch(/^## \[\d+\.\d+\.\d+\]/);
|
||||
// Body never bleeds into the next heading.
|
||||
for (let i = span.start + 1; i < span.end; i++) {
|
||||
expect(lines[i].startsWith("## [")).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePackageSection", () => {
|
||||
it("includes every silent-tag section above floor up to target inclusive", () => {
|
||||
const merged = mergePackageSection(FIXTURE, "15.12.4", "15.13.0");
|
||||
// 15.12.6 and 15.12.5 unique fingerprints must land.
|
||||
expect(merged).toContain("Removed `writeLine`/`writeLineSync` from the public SessionStorageWriter contract.");
|
||||
expect(merged).toContain("Added package-level exports for session context.");
|
||||
expect(merged).toContain("Changed terminal resize handling to paint only the visible viewport.");
|
||||
// 15.12.4 entry stays excluded — it is the floor.
|
||||
expect(merged).not.toContain("Predates the silent-tag window");
|
||||
// Unreleased never leaks.
|
||||
expect(merged).not.toContain("Unreleased entry");
|
||||
});
|
||||
|
||||
it("dedupes bullets flattened forward into multiple versions", () => {
|
||||
const merged = mergePackageSection(FIXTURE, "15.12.4", "15.13.0");
|
||||
const dupRegex = /Fixed unknown `--`-prefixed flags being silently consumed as prompt text\./g;
|
||||
expect(merged.match(dupRegex)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("groups bullets under the canonical category order regardless of source-version order", () => {
|
||||
const merged = mergePackageSection(FIXTURE, "15.12.4", "15.13.0");
|
||||
// Expected canonical order: Breaking Changes → Added → Changed → Fixed → Removed.
|
||||
const headings = [...merged.matchAll(/^### (.+)$/gm)].map(m => m[1]);
|
||||
expect(headings).toEqual(["Breaking Changes", "Added", "Changed", "Fixed", "Removed"]);
|
||||
});
|
||||
|
||||
it("floor=null reproduces single-version (legacy) extraction for the target", () => {
|
||||
const merged = mergePackageSection(FIXTURE, null, "15.13.0");
|
||||
expect(merged).toContain("Fixed something only in 15.13.0");
|
||||
expect(merged).toContain("Removed a deprecated thing in 15.13.0");
|
||||
// Anything below the target stays out when no floor is set.
|
||||
expect(merged).not.toContain("writeLine");
|
||||
expect(merged).not.toContain("Added package-level exports");
|
||||
});
|
||||
|
||||
it("returns empty string when no version in the requested range carries body content", () => {
|
||||
const empty = ["# Changelog", "", "## [15.13.0] - 2026-06-14", "", "## [15.12.6] - 2026-06-14"].join("\n");
|
||||
expect(mergePackageSection(empty, "15.12.5", "15.13.0")).toBe("");
|
||||
});
|
||||
|
||||
it("never emits a category with only blank/whitespace bullets after dedup", () => {
|
||||
// If 15.13.0 already pulled the only Fixed bullet, an older section
|
||||
// contributing the identical bullet must not produce an empty
|
||||
// `### Fixed` heading by itself.
|
||||
const merged = mergePackageSection(FIXTURE, "15.12.4", "15.13.0");
|
||||
expect(merged).not.toMatch(/### Fixed\s*\n\s*(### |$)/);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { inspectPackedTarball, isVersionAlreadyPublished, prepareNativeCorePackage } from "./ci-release-publish.ts";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("release publish", () => {
|
||||
it("uses the packed manifest identity for an exact-version registry preflight", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-release-publish-test-"));
|
||||
temporaryDirectories.push(root);
|
||||
const packageDir = path.join(root, "package");
|
||||
await fs.mkdir(packageDir);
|
||||
await Bun.write(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "@oh-my-pi/pi-test", version: "1.2.3" }),
|
||||
);
|
||||
const tarball = path.join(root, "test.tgz");
|
||||
await $`tar -czf ${tarball} -C ${root} package`.quiet();
|
||||
|
||||
await expect(inspectPackedTarball(tarball)).resolves.toEqual({
|
||||
name: "@oh-my-pi/pi-test",
|
||||
version: "1.2.3",
|
||||
path: tarball,
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes npm's existing-version machine codes and registry-precheck prose", () => {
|
||||
expect(isVersionAlreadyPublished("npm error code E409\nnpm error Cannot publish over existing version")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isVersionAlreadyPublished("npm ERR! code E409")).toBe(true);
|
||||
expect(isVersionAlreadyPublished("npm error code EPUBLISHCONFLICT")).toBe(true);
|
||||
expect(isVersionAlreadyPublished("You cannot publish over the previously published versions: 1.2.3.")).toBe(true);
|
||||
expect(isVersionAlreadyPublished("cannot publish over the previously published version")).toBe(false);
|
||||
});
|
||||
|
||||
it("ships every file required by the lazy desktop export in the native core", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-native-core-publish-test-"));
|
||||
temporaryDirectories.push(root);
|
||||
await Bun.write(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@oh-my-pi/pi-natives",
|
||||
version: "1.2.3",
|
||||
exports: {
|
||||
"./desktop": { types: "./native/desktop.d.ts", import: "./native/desktop.js" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const manifest = await prepareNativeCorePackage(root, false);
|
||||
expect(manifest.files).toEqual(expect.arrayContaining(["native/desktop.js", "native/desktop.d.ts"]));
|
||||
});
|
||||
});
|
||||
+3
-36
@@ -111,20 +111,6 @@ const nativeAndIntegrationPackages = [
|
||||
// and is outside every CI TS bucket.
|
||||
const localOnlyWorkspacePackages = ["packages/mnemopi", "python/robomp/web"];
|
||||
|
||||
// Repo-level script tests. CI's `workspace` bucket only runs the merge gates:
|
||||
// the concurrency regression (the GHA-config guard) and the .d.ts extension
|
||||
// rewrite (guards published-type resolution; hermetic temp-dir suite). A local
|
||||
// full run also exercises the release-notes and link-omp tests. (A
|
||||
// `ci-test-ts.test.ts` entry used to sit here but the file never existed — bun
|
||||
// silently ignores unmatched filters when at least one other filter matches.)
|
||||
const repoScriptTests = [
|
||||
"scripts/ci-concurrency.test.ts",
|
||||
"scripts/bazel-natives.test.ts",
|
||||
"scripts/ci-release-notes.test.ts",
|
||||
"scripts/ci-release-publish.test.ts",
|
||||
"scripts/fix-dts-extensions.test.ts",
|
||||
"scripts/link-omp.test.ts",
|
||||
];
|
||||
|
||||
const codingAgentNativePathPatterns = [
|
||||
/(^|\/)[^/]*(bash|native|browser|cmux|mnemopi|hindsight|memory)[^/]*\.test\.ts$/i,
|
||||
@@ -342,20 +328,6 @@ async function commandsForMode(mode: Mode): Promise<TestCommand[]> {
|
||||
case "workspace":
|
||||
return [
|
||||
...fastWorkspacePackages.map(pkg => workspaceTestCommand(pkg, 8)),
|
||||
{
|
||||
label: "scripts",
|
||||
cwd: ".",
|
||||
command: [
|
||||
"bun",
|
||||
"test",
|
||||
"--parallel=4",
|
||||
...onlyFailuresArgs,
|
||||
"scripts/ci-concurrency.test.ts",
|
||||
"scripts/bazel-natives.test.ts",
|
||||
"scripts/ci-release-publish.test.ts",
|
||||
"scripts/fix-dts-extensions.test.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
case "native":
|
||||
return nativeAndIntegrationPackages.map(pkg => workspaceTestCommand(pkg, 4, { smol: true }));
|
||||
@@ -382,20 +354,15 @@ async function commandsForMode(mode: Mode): Promise<TestCommand[]> {
|
||||
];
|
||||
// `local-ts` is the full local TypeScript run that root `bun run test:ts`
|
||||
// drives: every package the old `--workspaces` fan-out covered (the CI
|
||||
// `all` set PLUS mnemopi and robomp-web, which CI omits) and every repo
|
||||
// script test, routed through this one quiet runner so the whole suite
|
||||
// shares one progress stream and one failure report.
|
||||
// `all` set PLUS mnemopi and robomp-web, which CI omits), routed through
|
||||
// this one quiet runner so the whole suite shares one progress stream and
|
||||
// one failure report. Repo script tests remain available via `test:scripts`.
|
||||
case "local-ts":
|
||||
return [
|
||||
...fastWorkspacePackages.map(pkg => workspaceTestCommand(pkg, 8, { extraArgs: onlyFailuresArgs })),
|
||||
...nativeAndIntegrationPackages.map(pkg => workspaceTestCommand(pkg, 4, { extraArgs: onlyFailuresArgs })),
|
||||
...localOnlyWorkspacePackages.map(pkg => workspaceTestCommand(pkg, 4, { extraArgs: onlyFailuresArgs })),
|
||||
...(await commandsForMode("coding-agent-heavy")),
|
||||
{
|
||||
label: "scripts",
|
||||
cwd: ".",
|
||||
command: ["bun", "test", "--parallel=4", ...onlyFailuresArgs, ...repoScriptTests],
|
||||
},
|
||||
];
|
||||
// `local` is what root `bun run test` drives: the full TS suite plus the
|
||||
// Rust task, so a single invocation reports TS and Rust together. The Rust
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fixDtsExtensions, fixDtsFile, resolveDtsSpecifier } from "./fix-dts-extensions";
|
||||
|
||||
// Each test builds a real `dist/types`-shaped tree under a fresh temp dir, runs
|
||||
// the transform, and reads the bytes the code wrote back off disk. We assert on
|
||||
// the rewritten content / return counts (the observable contract), never on the
|
||||
// source text of the helper.
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTree(files: Record<string, string>): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "fix-dts-"));
|
||||
tempDirs.push(root);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = path.join(root, rel);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, content);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function read(...segments: string[]): Promise<string> {
|
||||
return fs.readFile(path.join(...segments), "utf8");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveDtsSpecifier", () => {
|
||||
it("returns null for bare, scoped, node:, and already-suffixed specifiers", async () => {
|
||||
const root = await makeTree({ "placeholder.d.ts": "export {};\n" });
|
||||
for (const spec of ["@oh-my-pi/pi-tui", "zod/v4", "node:fs", "./x.js", "./data.json", "./m.mjs", "./c.cjs"]) {
|
||||
expect(await resolveDtsSpecifier(root, spec)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a .d.ts specifier to .js without consulting the filesystem", async () => {
|
||||
// No `foo.d.ts` planted: the .d.ts → .js mapping is a pure rewrite.
|
||||
const root = await makeTree({ "placeholder.d.ts": "export {};\n" });
|
||||
expect(await resolveDtsSpecifier(root, "./foo.d.ts")).toBe("./foo.js");
|
||||
});
|
||||
|
||||
it("resolves relative to the given fromDir, not the tree root", async () => {
|
||||
const root = await makeTree({
|
||||
"sibling.d.ts": "export declare const s: number;\n",
|
||||
"sub/index.d.ts": "export {};\n",
|
||||
});
|
||||
// From the subdir, `../sibling` reaches root/sibling.d.ts.
|
||||
expect(await resolveDtsSpecifier(path.join(root, "sub"), "../sibling")).toBe("../sibling.js");
|
||||
// From root, `./sibling` also reaches it (control: sibling resolution works).
|
||||
expect(await resolveDtsSpecifier(root, "./sibling")).toBe("./sibling.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fixDtsFile", () => {
|
||||
it("appends .js to a sibling declaration re-export and returns count 1", async () => {
|
||||
const root = await makeTree({
|
||||
"sdk.d.ts": "export declare const sdk: number;\n",
|
||||
"index.d.ts": 'export * from "./sdk";\n',
|
||||
});
|
||||
const count = await fixDtsFile(path.join(root, "index.d.ts"));
|
||||
expect(count).toBe(1);
|
||||
expect(await read(root, "index.d.ts")).toBe('export * from "./sdk.js";\n');
|
||||
});
|
||||
|
||||
it("resolves a directory barrel to its index.js", async () => {
|
||||
const root = await makeTree({
|
||||
"modes/index.d.ts": "export declare const X: string;\n",
|
||||
"index.d.ts": 'import { X } from "./modes";\nexport { X };\n',
|
||||
});
|
||||
const count = await fixDtsFile(path.join(root, "index.d.ts"));
|
||||
expect(count).toBe(1);
|
||||
expect(await read(root, "index.d.ts")).toBe('import { X } from "./modes/index.js";\nexport { X };\n');
|
||||
});
|
||||
|
||||
it("rewrites `export type * from` and `export type { A } from` like value exports", async () => {
|
||||
const root = await makeTree({
|
||||
"sdk.d.ts": "export type A = number;\n",
|
||||
"star.d.ts": 'export type * from "./sdk";\n',
|
||||
"named.d.ts": 'export type { A } from "./sdk";\n',
|
||||
});
|
||||
expect(await fixDtsFile(path.join(root, "star.d.ts"))).toBe(1);
|
||||
expect(await fixDtsFile(path.join(root, "named.d.ts"))).toBe(1);
|
||||
expect(await read(root, "star.d.ts")).toBe('export type * from "./sdk.js";\n');
|
||||
expect(await read(root, "named.d.ts")).toBe('export type { A } from "./sdk.js";\n');
|
||||
});
|
||||
|
||||
it("rewrites inline import type references", async () => {
|
||||
const inlineImport = "import" + '("./types")';
|
||||
const inlineExpected = "import" + '("./types.js")';
|
||||
const root = await makeTree({
|
||||
"types.d.ts": "export type Thing = number;\n",
|
||||
"index.d.ts": `export declare const typed: ${inlineImport}.Thing;\n`,
|
||||
});
|
||||
const count = await fixDtsFile(path.join(root, "index.d.ts"));
|
||||
expect(count).toBe(1);
|
||||
expect(await read(root, "index.d.ts")).toBe(`export declare const typed: ${inlineExpected}.Thing;\n`);
|
||||
});
|
||||
|
||||
it("rewrites relative declare module specifiers", async () => {
|
||||
const root = await makeTree({
|
||||
"types.d.ts": "export interface Custom {}\n",
|
||||
"augment.d.ts": 'declare module "./types" {\n\tinterface CustomMessages {}\n}\n',
|
||||
});
|
||||
const count = await fixDtsFile(path.join(root, "augment.d.ts"));
|
||||
expect(count).toBe(1);
|
||||
expect(await read(root, "augment.d.ts")).toBe(
|
||||
'declare module "./types.js" {\n\tinterface CustomMessages {}\n}\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a file of bare and already-suffixed specifiers byte-for-byte unchanged", async () => {
|
||||
const source =
|
||||
'export * from "@oh-my-pi/pi-tui";\n' +
|
||||
'import { z } from "zod/v4";\n' +
|
||||
'import * as nodefs from "node:fs";\n' +
|
||||
'export * from "./x.js";\n' +
|
||||
'import data from "./data.json";\n';
|
||||
const root = await makeTree({ "index.d.ts": source });
|
||||
const count = await fixDtsFile(path.join(root, "index.d.ts"));
|
||||
expect(count).toBe(0);
|
||||
expect(await read(root, "index.d.ts")).toBe(source);
|
||||
});
|
||||
|
||||
it("resolves `../sibling` against the importing file's dir at depth", async () => {
|
||||
const root = await makeTree({
|
||||
"sibling.d.ts": "export declare const s: number;\n",
|
||||
"sub/index.d.ts": 'export * from "../sibling";\n',
|
||||
});
|
||||
const count = await fixDtsFile(path.join(root, "sub", "index.d.ts"));
|
||||
expect(count).toBe(1);
|
||||
expect(await read(root, "sub", "index.d.ts")).toBe('export * from "../sibling.js";\n');
|
||||
});
|
||||
|
||||
it("is idempotent — a second pass returns 0 and never doubles the extension", async () => {
|
||||
const root = await makeTree({
|
||||
"sdk.d.ts": "export declare const sdk: number;\n",
|
||||
"index.d.ts": 'export * from "./sdk";\n',
|
||||
});
|
||||
const file = path.join(root, "index.d.ts");
|
||||
expect(await fixDtsFile(file)).toBe(1);
|
||||
const afterFirst = await read(root, "index.d.ts");
|
||||
expect(await fixDtsFile(file)).toBe(0);
|
||||
expect(await read(root, "index.d.ts")).toBe(afterFirst);
|
||||
expect(afterFirst).toBe('export * from "./sdk.js";\n');
|
||||
expect(afterFirst).not.toContain(".js.js");
|
||||
});
|
||||
|
||||
it("leaves an unresolvable relative specifier unchanged and returns 0", async () => {
|
||||
const source = 'export * from "./does-not-exist";\n';
|
||||
const root = await makeTree({ "index.d.ts": source });
|
||||
expect(await resolveDtsSpecifier(root, "./does-not-exist")).toBeNull();
|
||||
expect(await fixDtsFile(path.join(root, "index.d.ts"))).toBe(0);
|
||||
expect(await read(root, "index.d.ts")).toBe(source);
|
||||
});
|
||||
|
||||
it("counts only resolvable specifiers, excluding an unresolvable one in the same file", async () => {
|
||||
const root = await makeTree({
|
||||
"sdk.d.ts": "export declare const sdk: number;\n",
|
||||
"index.d.ts": 'export * from "./sdk";\nexport * from "./ghost";\n',
|
||||
});
|
||||
const count = await fixDtsFile(path.join(root, "index.d.ts"));
|
||||
expect(count).toBe(1);
|
||||
expect(await read(root, "index.d.ts")).toBe('export * from "./sdk.js";\nexport * from "./ghost";\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe("fixDtsExtensions", () => {
|
||||
it("recursively fixes every .d.ts and totals files + specifiers", async () => {
|
||||
const root = await makeTree({
|
||||
// 2 resolvable specifiers: ./sdk (sibling) + ./modes (barrel).
|
||||
"index.d.ts": 'export * from "./sdk";\nexport { M } from "./modes";\n',
|
||||
// 0 specifiers.
|
||||
"sdk.d.ts": "export declare const sdk: number;\n",
|
||||
// 1 resolvable specifier: ./config (sibling within modes/).
|
||||
"modes/index.d.ts": 'export * from "./config";\nexport declare const M: string;\n',
|
||||
// 0 specifiers.
|
||||
"modes/config.d.ts": "export declare const config: boolean;\n",
|
||||
});
|
||||
const totals = await fixDtsExtensions(root);
|
||||
expect(totals).toEqual({ files: 2, specifiers: 3 });
|
||||
expect(await read(root, "index.d.ts")).toBe('export * from "./sdk.js";\nexport { M } from "./modes/index.js";\n');
|
||||
expect(await read(root, "modes/index.d.ts")).toBe(
|
||||
'export * from "./config.js";\nexport declare const M: string;\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("integration: leaves the root barrel fully .js-suffixed with no bare forms remaining", async () => {
|
||||
const root = await makeTree({
|
||||
"index.d.ts": 'export * from "./sdk";\nexport { M } from "./modes";\n',
|
||||
"sdk.d.ts": "export declare const sdk: number;\n",
|
||||
"modes/index.d.ts": "export declare const M: string;\n",
|
||||
});
|
||||
await fixDtsExtensions(root);
|
||||
const barrel = await read(root, "index.d.ts");
|
||||
expect(barrel).toContain('from "./sdk.js"');
|
||||
expect(barrel).toContain('from "./modes/index.js"');
|
||||
// The trailing quote disambiguates: `from "./sdk"` is not a substring of
|
||||
// `from "./sdk.js"`, so this proves no bare specifier survived.
|
||||
expect(barrel).not.toContain('from "./sdk"');
|
||||
expect(barrel).not.toContain('from "./modes"');
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dir, "..");
|
||||
const linkScript = path.join(repoRoot, "scripts", "link-omp.sh");
|
||||
const targetWrapper = path.join(repoRoot, "packages", "coding-agent", "scripts", "omp");
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-link-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeBunShim(dir: string, body: string) {
|
||||
const shimDir = path.join(dir, "shim");
|
||||
fs.mkdirSync(shimDir, { recursive: true });
|
||||
const shim = path.join(shimDir, "bun");
|
||||
fs.writeFileSync(shim, `#!/bin/sh\n${body}`);
|
||||
fs.chmodSync(shim, 0o755);
|
||||
return shimDir;
|
||||
}
|
||||
|
||||
function runLinkScript(env: NodeJS.ProcessEnv) {
|
||||
return spawnSync("sh", [linkScript], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("scripts/link-omp.sh", () => {
|
||||
it("falls back to BUN_INSTALL/bin when bun cannot resolve the global bin", () => {
|
||||
const dir = makeTempDir();
|
||||
const bunInstall = path.join(dir, "bun-install");
|
||||
const shimDir = writeBunShim(
|
||||
dir,
|
||||
[
|
||||
'if [ "$1" = "pm" ] && [ "$2" = "-g" ] && [ "$3" = "bin" ]; then',
|
||||
" echo 'error: No package.json was found for directory' >&2",
|
||||
" exit 1",
|
||||
"fi",
|
||||
"exit 99",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = runLinkScript({
|
||||
...process.env,
|
||||
HOME: path.join(dir, "home"),
|
||||
BUN_INSTALL: bunInstall,
|
||||
PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(fs.readlinkSync(path.join(bunInstall, "bin", "omp"))).toBe(targetWrapper);
|
||||
});
|
||||
|
||||
it("uses bun pm -g bin when it succeeds", () => {
|
||||
const dir = makeTempDir();
|
||||
const globalBin = path.join(dir, "global-bin");
|
||||
const shimDir = writeBunShim(
|
||||
dir,
|
||||
[
|
||||
'if [ "$1" = "pm" ] && [ "$2" = "-g" ] && [ "$3" = "bin" ]; then',
|
||||
` echo '${globalBin}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 99",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = runLinkScript({
|
||||
...process.env,
|
||||
HOME: path.join(dir, "home"),
|
||||
BUN_INSTALL: path.join(dir, "unused-bun-install"),
|
||||
PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(fs.readlinkSync(path.join(globalBin, "omp"))).toBe(targetWrapper);
|
||||
expect(fs.existsSync(path.join(dir, "unused-bun-install", "bin", "omp"))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user