From f99493b9e648abd04e185253cd2f1f3e258b13a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 09:26:30 +0000 Subject: [PATCH] =?UTF-8?q?test:=20id=20+=20shell=20=E2=80=94=20monotonic?= =?UTF-8?q?=20ID=20ordering=20and=20shell=20blacklist=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identifier (14 tests): covers prefix format, ascending/descending sort order, timestamp comparison, given-ID passthrough validation, and schema(). Shell (9 tests): covers fish/nu blacklist enforcement, preferred vs acceptable semantics, lazy cache reset, and SHELL-unset fallback. Co-Authored-By: Claude Opus 4.6 (1M context) https://claude.ai/code/session_01SA2444fMWSyZaR3EkdJ7bL --- packages/opencode/test/id/id.test.ts | 111 +++++++++++++++++++++ packages/opencode/test/shell/shell.test.ts | 96 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 packages/opencode/test/id/id.test.ts create mode 100644 packages/opencode/test/shell/shell.test.ts diff --git a/packages/opencode/test/id/id.test.ts b/packages/opencode/test/id/id.test.ts new file mode 100644 index 000000000..a2ebd6535 --- /dev/null +++ b/packages/opencode/test/id/id.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect } from "bun:test" +import { Identifier } from "../../src/id/id" + +describe("Identifier: prefix format and length", () => { + test("ascending() generates ID with correct prefix", () => { + const id = Identifier.ascending("session") + expect(id).toMatch(/^ses_/) + }) + + test("descending() generates ID with correct prefix", () => { + const id = Identifier.descending("message") + expect(id).toMatch(/^msg_/) + }) + + test("ID has expected total length (prefix + _ + 26 hex/base62 chars)", () => { + // "ses" (3) + "_" (1) + 26 = 30 + const id = Identifier.ascending("session") + expect(id.length).toBe(30) + }) + + test("tool prefix is 4 chars (outlier)", () => { + // "tool" (4) + "_" (1) + 26 = 31 + const id = Identifier.ascending("tool") + expect(id).toMatch(/^tool_/) + expect(id.length).toBe(31) + }) +}) + +describe("Identifier: ascending sort order", () => { + test("IDs with increasing timestamps sort ascending (string order)", () => { + const t = 1700000000000 + const a = Identifier.create("session", false, t) + const b = Identifier.create("session", false, t + 1) + expect(a < b).toBe(true) + }) + + test("multiple IDs at same timestamp are unique and ascending", () => { + const t = 1700000001000 + const ids = Array.from({ length: 10 }, () => Identifier.create("session", false, t)) + const unique = new Set(ids) + expect(unique.size).toBe(10) + for (let i = 1; i < ids.length; i++) { + expect(ids[i - 1] < ids[i]).toBe(true) + } + }) +}) + +describe("Identifier: descending sort order", () => { + test("IDs with increasing timestamps sort descending (string order)", () => { + const t = 1700000002000 + const a = Identifier.create("session", true, t) + const b = Identifier.create("session", true, t + 1) + // Later timestamp → smaller string for descending + expect(a > b).toBe(true) + }) +}) + +describe("Identifier: timestamp comparison", () => { + test("timestamp() preserves relative ordering for ascending IDs", () => { + const t1 = 1700000003000 + const t2 = 1700000004000 + const id1 = Identifier.create("session", false, t1) + const id2 = Identifier.create("session", false, t2) + // timestamp() may not recover the exact input due to 48-bit storage, + // but it must preserve relative ordering (used for cleanup cutoffs) + expect(Identifier.timestamp(id1)).toBeLessThan(Identifier.timestamp(id2)) + }) + + test("timestamp() returns same value for IDs created at same time", () => { + const t = 1700000005000 + const id1 = Identifier.create("session", false, t) + const id2 = Identifier.create("session", false, t) + // Both IDs at same timestamp should produce the same (or very close) extracted timestamp + // The counter increment adds at most a few units that divide away + expect(Identifier.timestamp(id1)).toBe(Identifier.timestamp(id2)) + }) +}) + +describe("Identifier: given passthrough", () => { + test("returns given ID as-is when prefix matches", () => { + const given = "ses_abcdef1234567890abcdef1234" + const result = Identifier.ascending("session", given) + expect(result).toBe(given) + }) + + test("throws when given ID has wrong prefix", () => { + expect(() => Identifier.ascending("session", "msg_abc")).toThrow( + "does not start with ses", + ) + }) +}) + +describe("Identifier: schema validation", () => { + test("schema accepts valid session ID", () => { + const s = Identifier.schema("session") + const id = Identifier.ascending("session") + expect(s.safeParse(id).success).toBe(true) + }) + + test("schema rejects ID with wrong prefix", () => { + const s = Identifier.schema("session") + expect(s.safeParse("msg_abc123").success).toBe(false) + }) + + test("schema for tool prefix works (4-char prefix)", () => { + const s = Identifier.schema("tool") + const id = Identifier.ascending("tool") + expect(s.safeParse(id).success).toBe(true) + expect(s.safeParse("ses_abc").success).toBe(false) + }) +}) diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts new file mode 100644 index 000000000..7f3f64cad --- /dev/null +++ b/packages/opencode/test/shell/shell.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { Shell } from "../../src/shell/shell" + +describe("Shell.acceptable: blacklist enforcement", () => { + let savedShell: string | undefined + + beforeEach(() => { + savedShell = process.env.SHELL + // Reset the lazy caches so each test starts fresh + Shell.acceptable.reset() + Shell.preferred.reset() + }) + + afterEach(() => { + if (savedShell !== undefined) { + process.env.SHELL = savedShell + } else { + delete process.env.SHELL + } + Shell.acceptable.reset() + Shell.preferred.reset() + }) + + test("returns SHELL when set to bash", () => { + process.env.SHELL = "/bin/bash" + expect(Shell.acceptable()).toBe("/bin/bash") + }) + + test("returns SHELL when set to zsh", () => { + process.env.SHELL = "/usr/bin/zsh" + expect(Shell.acceptable()).toBe("/usr/bin/zsh") + }) + + test("rejects fish and returns fallback", () => { + process.env.SHELL = "/usr/bin/fish" + const result = Shell.acceptable() + expect(result).not.toBe("/usr/bin/fish") + // Fallback should be a real shell path + expect(result.length).toBeGreaterThan(0) + }) + + test("rejects nu (nushell) and returns fallback", () => { + process.env.SHELL = "/usr/bin/nu" + const result = Shell.acceptable() + expect(result).not.toBe("/usr/bin/nu") + expect(result.length).toBeGreaterThan(0) + }) + + test("shell containing 'nu' in name but not basename is not blacklisted", () => { + // /opt/menu/bin/bash — basename is "bash", not "nu" + process.env.SHELL = "/opt/nushell/bin/bash" + expect(Shell.acceptable()).toBe("/opt/nushell/bin/bash") + }) + + test("returns fallback when SHELL is unset", () => { + delete process.env.SHELL + const result = Shell.acceptable() + expect(result.length).toBeGreaterThan(0) + // On Linux/macOS, fallback should be a valid shell path + expect(result).toMatch(/\/(bash|zsh|sh|cmd\.exe)$/) + }) +}) + +describe("Shell.preferred: no blacklist filtering", () => { + let savedShell: string | undefined + + beforeEach(() => { + savedShell = process.env.SHELL + Shell.preferred.reset() + }) + + afterEach(() => { + if (savedShell !== undefined) { + process.env.SHELL = savedShell + } else { + delete process.env.SHELL + } + Shell.preferred.reset() + }) + + test("returns SHELL even when blacklisted (fish)", () => { + process.env.SHELL = "/usr/bin/fish" + expect(Shell.preferred()).toBe("/usr/bin/fish") + }) + + test("returns SHELL even when blacklisted (nu)", () => { + process.env.SHELL = "/usr/bin/nu" + expect(Shell.preferred()).toBe("/usr/bin/nu") + }) + + test("returns fallback when SHELL is unset", () => { + delete process.env.SHELL + const result = Shell.preferred() + expect(result.length).toBeGreaterThan(0) + }) +})