mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-22 04:38:24 +08:00
* fix(build-app): escape sed replacement metachars in Chromium rebrand
build-app.sh injects \$APP_NAME directly into the replacement half of
sed's s/// when patching Chromium's localized InfoPlist.strings. If
\$APP_NAME ever carries '/', '&', or '\\' — the command either breaks
or starts interpreting input as sed syntax. The trailing '|| true'
would then silently hide the failure and ship a DMG that still says
'Google Chrome for Testing' in the menu bar.
Escape replacement metachars before substitution. No change for the
default name 'GStack Browser'.
* fix(build-app): bail out if 'mktemp -d' fails instead of cp-ing into '/'
The DMG creation step sets DMG_TMP from 'mktemp -d' with no error check.
If mktemp fails (tmpfs full, permissions, TMPDIR misconfigured), DMG_TMP
is empty and the very next line — 'cp -a "\$APP_DIR" "\$DMG_TMP/"' —
expands to 'cp -a "<app>" "/"', which copies the bundle into the root of
the filesystem.
Refuse to continue unless mktemp produced a real directory. Defensive
second check catches the (rare) case where mktemp succeeds but returns
something that isn't a directory we can cp into.
* fix(telemetry-sync): drop predictable $$ tmp-file fallback
gstack-telemetry-sync tried 'mktemp /tmp/gstack-sync-XXXXXX' and on
failure fell back to '/tmp/gstack-sync-$$'. $$ is the PID — predictable
and reusable, so on shared hosts another user can pre-create or symlink
the path and either steal the response body or clobber an unrelated
file when curl writes through it.
Drop the fallback. If mktemp cannot produce a unique file we just skip
this sync cycle — the events stay on disk and the next run picks them
up. Also install an EXIT trap so the response file is cleaned up on
unexpected exit, not just on the happy path.
* fix(verify-rls): drop predictable $$-based tmp file fallback
Same shape as gstack-telemetry-sync: on mktemp failure the script fell
back to '/tmp/verify-rls-$$-$TOTAL', which is fully predictable from the
PID and a per-check counter. On a shared box another user can pre-create
or symlink the path and either capture the HTTP response body (which may
leak what the RLS tests revealed) or corrupt an unrelated file that curl
writes through.
Make mktemp strict. On failure return from the check function; the caller
tallies a FAIL and the run moves on.
* fix(security-classifier): close writer + delete tmp on download error
downloadFile() opens an fs.WriteStream to '<dest>.tmp.<pid>' and drives
it from a fetch body reader, but if reader.read() or writer.write()
throws mid-download the writer is never closed. That leaks an FD per
failed attempt and leaves the half-written tmp on disk. A later retry
can land in renameSync(tmp, dest) with a truncated TestSavantAI /
DeBERTa ONNX file — which then loads but produces garbage classifier
verdicts until the user manually nukes the models cache.
Wrap the download loop in try/catch. On failure, destroy() the writer
and unlink the tmp before rethrowing, so the next attempt starts from a
clean slate.
* fix(meta-commands): guard JSON.parse in pdf --from-file parser
parsePdfFromFile() runs JSON.parse on user-supplied file contents with
no try/catch. A malformed payload surfaces as an uncaught SyntaxError
from the 'pdf' command handler and the user sees an opaque stack trace
instead of "this file isn't valid JSON". Worse, the same call path is
used by make-pdf when header/footer HTML would overflow Windows'
CreateProcess argv cap, so a corrupt payload file there can take down
the make-pdf run.
Wrap JSON.parse. Re-throw with a message that names the offending file
and echoes the parser's own explanation. Also reject top-level non-
objects (null, array, primitive) since the rest of the function treats
json as an object — catching that here produces a clear error instead
of a TypeError further down.
* fix(global-discover): stop dropping sessions when header >8KB
extractCwdFromJsonl() reads the first 8KB of each JSONL session file and
runs JSON.parse on every newline-split line. When a session record
happens to straddle the 8KB cap, the last line ends in a truncated JSON
fragment, JSON.parse throws, the catch block 'continue's silently, and
if that was the only line carrying 'cwd' the whole project gets dropped
from the discovery output without a warning.
Two independent hardening steps:
1. Raise the read cap to 64KB. Session headers observed in Claude
Code / Codex / Gemini transcripts fit comfortably; this just moves
the cliff out of the normal range.
2. Drop the final segment after splitting on '\\n'. If the read hit
the cap mid-line, that segment is guaranteed incomplete; if the
file ended inside the buffer, the split produces an empty final
segment and dropping it is a no-op.
Together these make the parser robust regardless of how verbose the
leading records are.
* test: export downloadFile, parsePdfFromFile, extractCwdFromJsonl
These three internal helpers are now imported by regression tests
landing in the next commits (PR #1169 follow-up). Pattern matches the
existing normalizeRemoteUrl export in gstack-global-discover.ts which
test/global-discover.test.ts already imports side-effect-free.
No change to runtime behavior; gstack has no public package entrypoint
that would re-export these, so the in-repo surface is unchanged for
callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security-classifier): await writer close before unlinking tmp on error
The earlier downloadFile() error-path cleanup hit a race: Node's
createWriteStream lazily opens the FD and flushes buffered writes during
destroy(), so a naive `fs.unlinkSync(tmp)` immediately after `writer.destroy()`
hits ENOENT (file not yet on disk), then the writer's destroy finishes on the
next tick and creates the file fresh — leaving the half-written tmp behind
exactly as the original fix tried to prevent.
The new sequence awaits the writer's 'close' event before unlinking, so the FD
is fully torn down and no subsequent flush can re-create the path.
Caught by browse/test/security-classifier-download-cleanup.test.ts in the
next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): regression tests for downloadFile cleanup + parsePdfFromFile guard
Covers PR #1169 bugs #6 and #7:
- security-classifier-download-cleanup.test.ts pins downloadFile error-path
cleanup against three failure shapes: reader rejects mid-stream, non-2xx
response, missing body. Asserts the dest file is not created and no
<dest>.tmp.* siblings remain (glob-matched, not exact path — codex push:
if the fix later switches to mkdtempSync, the assertion still holds).
Includes a happy-path case so the cleanup isn't fighting a correct download.
- regression-pr1169-pdf-from-file-invalid-json.test.ts pins parsePdfFromFile
to throw a helpful error for: invalid JSON, empty file, top-level array,
top-level number, top-level string, top-level null, top-level boolean.
Codex push: JSON.parse accepts primitives too, so Array.isArray + typeof
guard must be tested separately from the JSON.parse try/catch.
Both files use mkdtempSync(process.cwd()/...) for fixture isolation since
SAFE_DIRECTORIES allows TEMP_DIR or cwd; cwd is universal across CI hosts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(global-discover): regression for extractCwdFromJsonl 64KB cap
PR #1169 bug #8: the 8KB read cap landed mid-line on Claude Code session
headers, JSON.parse threw on the truncated tail, the catch silently
continued, and the project disappeared from /gstack discovery output.
Six new cases under describe("extractCwdFromJsonl 64KB cap"):
- happy path: small JSONL with obj.cwd returns it
- 12KB first line with obj.cwd: returns cwd (the bug case)
- 80KB single line overflowing 64KB: returns null without crashing
- complete line followed by partial second line: trailing-partial-drop
must not poison the result; returns first line's cwd
- missing file: returns null (file read error swallowed)
- malformed first line + valid second line within cap: skips bad,
returns second's cwd
Tests use the exported extractCwdFromJsonl (added in earlier export
commit) and live in a separate describe block from the existing
"4KB / 128KB buffer" tests, which exercise the unrelated scanCodex
meta.payload.cwd path at L338 — different function, different bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: regression tests for shell-script bugs in PR #1169 (#2-#5)
Two new test files pinning the four shell-script invariants from the
external audit:
regression-pr1169-build-app-sed.test.ts — bugs #2 + #3
- Runtime isolation: extracts the sed-escape sequence from build-app.sh
and runs it against hostile $APP_NAME values ("Foo/Bar&Baz", "Cool\App",
"A/B\C&D"). Asserts the literal hostile name round-trips through a real
`sed s///` invocation, locking the metachar safety end-to-end.
- Static check: the rebrand block must contain both the escape line AND
the sed line referencing $APP_NAME_SED_ESCAPED; bare $APP_NAME
interpolation directly into the s/// replacement is rejected.
- Static check: DMG_TMP=$(mktemp -d) is followed by an explicit `|| { ... exit }`
failure handler AND a `[ -z "$DMG_TMP" ] || [ ! -d "$DMG_TMP" ]` validation
AND the cp -a appears AFTER both guards.
- Runtime fake-bin: extracts the guard shape, runs with a fake mktemp that
exits 1, asserts the script exits non-zero before any cp block can reach.
regression-pr1169-mktemp-fallbacks.test.ts — bugs #4 + #5
- Per codex pushback, the invariant is "no `mktemp ... || echo <path>`
fallback shape" — not just "no $$ token." That's a stronger invariant
that catches future swaps to $RANDOM or hardcoded paths.
- For each of bin/gstack-telemetry-sync and supabase/verify-rls.sh:
- no echo-based fallback after mktemp
- no $$ inside any /tmp path literal
- mktemp failure path explicitly exits / returns non-zero
- telemetry-sync also pins the `trap rm -f $RESP_FILE EXIT` cleanup
so success paths don't leak the tmp on normal exit.
All seven new test files are gate-tier (deterministic, sub-second, no LLM,
no network). Runtime shell tests use fake-bin PATH stubs in temp dirs;
no $HOME mutation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.41.1.0)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: RagavRida <ragavrida@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
|
|
import { join } from "path";
|
|
import { tmpdir } from "os";
|
|
import { spawnSync } from "child_process";
|
|
|
|
// Import normalizeRemoteUrl for unit testing
|
|
// We test the script end-to-end via CLI and normalizeRemoteUrl via import
|
|
const scriptPath = join(import.meta.dir, "..", "bin", "gstack-global-discover.ts");
|
|
|
|
describe("gstack-global-discover", () => {
|
|
describe("normalizeRemoteUrl", () => {
|
|
// Dynamically import to test the exported function
|
|
let normalizeRemoteUrl: (url: string) => string;
|
|
|
|
beforeEach(async () => {
|
|
const mod = await import("../bin/gstack-global-discover.ts");
|
|
normalizeRemoteUrl = mod.normalizeRemoteUrl;
|
|
});
|
|
|
|
test("strips .git suffix", () => {
|
|
expect(normalizeRemoteUrl("https://github.com/user/repo.git")).toBe(
|
|
"https://github.com/user/repo"
|
|
);
|
|
});
|
|
|
|
test("converts SSH to HTTPS", () => {
|
|
expect(normalizeRemoteUrl("git@github.com:user/repo.git")).toBe(
|
|
"https://github.com/user/repo"
|
|
);
|
|
});
|
|
|
|
test("converts SSH without .git to HTTPS", () => {
|
|
expect(normalizeRemoteUrl("git@github.com:user/repo")).toBe(
|
|
"https://github.com/user/repo"
|
|
);
|
|
});
|
|
|
|
test("lowercases host", () => {
|
|
expect(normalizeRemoteUrl("https://GitHub.COM/user/repo")).toBe(
|
|
"https://github.com/user/repo"
|
|
);
|
|
});
|
|
|
|
test("SSH and HTTPS for same repo normalize to same URL", () => {
|
|
const ssh = normalizeRemoteUrl("git@github.com:garrytan/gstack.git");
|
|
const https = normalizeRemoteUrl("https://github.com/garrytan/gstack.git");
|
|
const httpsNoDotGit = normalizeRemoteUrl("https://github.com/garrytan/gstack");
|
|
expect(ssh).toBe(https);
|
|
expect(https).toBe(httpsNoDotGit);
|
|
});
|
|
|
|
test("handles local: URLs consistently", () => {
|
|
const result = normalizeRemoteUrl("local:/tmp/my-repo");
|
|
// local: gets parsed as a URL scheme — the important thing is consistency
|
|
expect(result).toContain("/tmp/my-repo");
|
|
});
|
|
|
|
test("handles GitLab SSH URLs", () => {
|
|
expect(normalizeRemoteUrl("git@gitlab.com:org/project.git")).toBe(
|
|
"https://gitlab.com/org/project"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("CLI", () => {
|
|
test("--help exits 0 and prints usage", () => {
|
|
const result = spawnSync("bun", ["run", scriptPath, "--help"], {
|
|
encoding: "utf-8",
|
|
timeout: 10000,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain("--since");
|
|
});
|
|
|
|
test("no args exits 1 with error", () => {
|
|
const result = spawnSync("bun", ["run", scriptPath], {
|
|
encoding: "utf-8",
|
|
timeout: 10000,
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("--since is required");
|
|
});
|
|
|
|
test("invalid window format exits 1", () => {
|
|
const result = spawnSync("bun", ["run", scriptPath, "--since", "abc"], {
|
|
encoding: "utf-8",
|
|
timeout: 10000,
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("Invalid window format");
|
|
});
|
|
|
|
test("--since 7d produces valid JSON", () => {
|
|
const result = spawnSync(
|
|
"bun",
|
|
["run", scriptPath, "--since", "7d", "--format", "json"],
|
|
{ encoding: "utf-8", timeout: 30000 }
|
|
);
|
|
expect(result.status).toBe(0);
|
|
const json = JSON.parse(result.stdout);
|
|
expect(json).toHaveProperty("window", "7d");
|
|
expect(json).toHaveProperty("repos");
|
|
expect(json).toHaveProperty("total_sessions");
|
|
expect(json).toHaveProperty("total_repos");
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.repos)).toBe(true);
|
|
});
|
|
|
|
test("--since 7d --format summary produces readable output", () => {
|
|
const result = spawnSync(
|
|
"bun",
|
|
["run", scriptPath, "--since", "7d", "--format", "summary"],
|
|
{ encoding: "utf-8", timeout: 30000 }
|
|
);
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("Window: 7d");
|
|
expect(result.stdout).toContain("Sessions:");
|
|
expect(result.stdout).toContain("Repos:");
|
|
});
|
|
|
|
test("--since 1h returns results (may be empty)", () => {
|
|
const result = spawnSync(
|
|
"bun",
|
|
["run", scriptPath, "--since", "1h", "--format", "json"],
|
|
{ encoding: "utf-8", timeout: 30000 }
|
|
);
|
|
expect(result.status).toBe(0);
|
|
const json = JSON.parse(result.stdout);
|
|
expect(json.total_sessions).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
describe("codex large session_meta parsing", () => {
|
|
let codexDir: string;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), "gstack-codex-test-"));
|
|
// Build a realistic ~/.codex/sessions/YYYY/MM/DD structure
|
|
const now = new Date();
|
|
const y = now.getFullYear().toString();
|
|
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
const d = String(now.getDate()).padStart(2, "0");
|
|
codexDir = join(tmpDir, "codex-home", "sessions", y, m, d);
|
|
mkdirSync(codexDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeCodexSession(
|
|
dir: string,
|
|
cwd: string,
|
|
baseInstructionsSize: number
|
|
): string {
|
|
const padding = "x".repeat(baseInstructionsSize);
|
|
const line = JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
type: "session_meta",
|
|
payload: {
|
|
id: `test-${Date.now()}`,
|
|
timestamp: new Date().toISOString(),
|
|
cwd,
|
|
originator: "codex_exec",
|
|
cli_version: "0.118.0",
|
|
source: "exec",
|
|
model_provider: "openai",
|
|
base_instructions: { text: padding },
|
|
},
|
|
});
|
|
const name = `rollout-${new Date().toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2)}.jsonl`;
|
|
const filePath = join(dir, name);
|
|
writeFileSync(filePath, line + "\n");
|
|
return filePath;
|
|
}
|
|
|
|
test("discovers codex sessions with >4KB session_meta via CLI", () => {
|
|
// Create a git repo as the session target
|
|
const repoDir = join(tmpDir, "fake-repo");
|
|
mkdirSync(repoDir);
|
|
spawnSync("git", ["init"], { cwd: repoDir, stdio: "pipe" });
|
|
spawnSync("git", ["commit", "--allow-empty", "-m", "init"], {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
|
|
// Write a session with a 20KB first line (simulates Codex v0.117+)
|
|
writeCodexSession(codexDir, repoDir, 20000);
|
|
|
|
// Run discovery with CODEX_SESSIONS_DIR override
|
|
const result = spawnSync(
|
|
"bun",
|
|
["run", scriptPath, "--since", "1h", "--format", "json"],
|
|
{
|
|
encoding: "utf-8",
|
|
timeout: 30000,
|
|
env: {
|
|
...process.env,
|
|
CODEX_SESSIONS_DIR: join(tmpDir, "codex-home", "sessions"),
|
|
},
|
|
}
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
const json = JSON.parse(result.stdout);
|
|
expect(json.tools.codex.total_sessions).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test("4KB buffer truncates session_meta, 128KB buffer parses it", () => {
|
|
const padding = "x".repeat(20000);
|
|
const sessionMeta = JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
type: "session_meta",
|
|
payload: {
|
|
id: "test-id",
|
|
timestamp: new Date().toISOString(),
|
|
cwd: "/tmp/test-repo",
|
|
originator: "codex_exec",
|
|
cli_version: "0.118.0",
|
|
source: "exec",
|
|
model_provider: "openai",
|
|
base_instructions: { text: padding },
|
|
},
|
|
});
|
|
|
|
expect(sessionMeta.length).toBeGreaterThan(4096);
|
|
|
|
const filePath = join(codexDir, "test.jsonl");
|
|
writeFileSync(filePath, sessionMeta + "\n");
|
|
|
|
// 4KB buffer: JSON.parse fails (the old bug)
|
|
const { openSync, readSync, closeSync } = require("fs");
|
|
const fd4k = openSync(filePath, "r");
|
|
const buf4k = Buffer.alloc(4096);
|
|
readSync(fd4k, buf4k, 0, 4096, 0);
|
|
closeSync(fd4k);
|
|
expect(() =>
|
|
JSON.parse(buf4k.toString("utf-8").split("\n")[0])
|
|
).toThrow();
|
|
|
|
// 128KB buffer: JSON.parse succeeds (the fix)
|
|
const fd128k = openSync(filePath, "r");
|
|
const buf128k = Buffer.alloc(131072);
|
|
const bytesRead = readSync(fd128k, buf128k, 0, 131072, 0);
|
|
closeSync(fd128k);
|
|
const firstLine = buf128k.toString("utf-8", 0, bytesRead).split("\n")[0];
|
|
const meta = JSON.parse(firstLine);
|
|
expect(meta.type).toBe("session_meta");
|
|
expect(meta.payload.cwd).toBe("/tmp/test-repo");
|
|
});
|
|
|
|
test("regression: session_meta beyond 128KB still needs streaming parse", () => {
|
|
// This test documents the current limitation: 128KB buffer is a heuristic.
|
|
// If Codex ever embeds >128KB in session_meta, this test will fail,
|
|
// signaling that the buffer needs to increase or be replaced with streaming.
|
|
const padding = "x".repeat(140000); // ~140KB payload
|
|
const sessionMeta = JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
type: "session_meta",
|
|
payload: {
|
|
id: "test-large",
|
|
timestamp: new Date().toISOString(),
|
|
cwd: "/tmp/large-test",
|
|
originator: "codex_exec",
|
|
cli_version: "0.200.0",
|
|
source: "exec",
|
|
model_provider: "openai",
|
|
base_instructions: { text: padding },
|
|
},
|
|
});
|
|
|
|
expect(sessionMeta.length).toBeGreaterThan(131072);
|
|
|
|
const filePath = join(codexDir, "large-test.jsonl");
|
|
writeFileSync(filePath, sessionMeta + "\n");
|
|
|
|
// 128KB buffer: JSON.parse FAILS for >128KB lines (current limitation)
|
|
const { openSync, readSync, closeSync } = require("fs");
|
|
const fd = openSync(filePath, "r");
|
|
const buf = Buffer.alloc(131072);
|
|
readSync(fd, buf, 0, 131072, 0);
|
|
closeSync(fd);
|
|
expect(() =>
|
|
JSON.parse(buf.toString("utf-8").split("\n")[0])
|
|
).toThrow();
|
|
// When this test starts passing (e.g., after implementing streaming parse),
|
|
// update it to verify correct parsing instead of documenting the limitation.
|
|
});
|
|
});
|
|
|
|
describe("discovery output structure", () => {
|
|
test("repos have required fields", () => {
|
|
const result = spawnSync(
|
|
"bun",
|
|
["run", scriptPath, "--since", "30d", "--format", "json"],
|
|
{ encoding: "utf-8", timeout: 30000 }
|
|
);
|
|
expect(result.status).toBe(0);
|
|
const json = JSON.parse(result.stdout);
|
|
|
|
for (const repo of json.repos) {
|
|
expect(repo).toHaveProperty("name");
|
|
expect(repo).toHaveProperty("remote");
|
|
expect(repo).toHaveProperty("paths");
|
|
expect(repo).toHaveProperty("sessions");
|
|
expect(Array.isArray(repo.paths)).toBe(true);
|
|
expect(repo.paths.length).toBeGreaterThan(0);
|
|
expect(repo.sessions).toHaveProperty("claude_code");
|
|
expect(repo.sessions).toHaveProperty("codex");
|
|
expect(repo.sessions).toHaveProperty("gemini");
|
|
}
|
|
});
|
|
|
|
test("tools summary matches repo data", () => {
|
|
const result = spawnSync(
|
|
"bun",
|
|
["run", scriptPath, "--since", "30d", "--format", "json"],
|
|
{ encoding: "utf-8", timeout: 30000 }
|
|
);
|
|
const json = JSON.parse(result.stdout);
|
|
|
|
// Total sessions should equal sum across tools
|
|
const toolTotal =
|
|
json.tools.claude_code.total_sessions +
|
|
json.tools.codex.total_sessions +
|
|
json.tools.gemini.total_sessions;
|
|
expect(json.total_sessions).toBe(toolTotal);
|
|
});
|
|
|
|
test("deduplicates Conductor workspaces by remote", () => {
|
|
const result = spawnSync(
|
|
"bun",
|
|
["run", scriptPath, "--since", "30d", "--format", "json"],
|
|
{ encoding: "utf-8", timeout: 30000 }
|
|
);
|
|
const json = JSON.parse(result.stdout);
|
|
|
|
// Check that no two repos share the same normalized remote
|
|
const remotes = json.repos.map((r: any) => r.remote);
|
|
const uniqueRemotes = new Set(remotes);
|
|
expect(remotes.length).toBe(uniqueRemotes.size);
|
|
});
|
|
});
|
|
|
|
describe("extractCwdFromJsonl 64KB cap (PR #1169 bug #8)", () => {
|
|
// Regression: the old 8KB cap landed mid-line on Claude Code sessions with
|
|
// long headers, JSON.parse threw on the truncated tail, the catch
|
|
// `continue`d silently, and the project disappeared from discovery.
|
|
// The fix raised the cap to 64KB AND drops the trailing partial segment
|
|
// before parsing.
|
|
let extractCwdFromJsonl: (filePath: string) => string | null;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
const mod = await import("../bin/gstack-global-discover.ts");
|
|
extractCwdFromJsonl = mod.extractCwdFromJsonl;
|
|
tmpDir = mkdtempSync(join(tmpdir(), "pr1169-cwd-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("happy path: small JSONL with obj.cwd returns it (sanity)", () => {
|
|
const filePath = join(tmpDir, "small.jsonl");
|
|
const line = JSON.stringify({ cwd: "/tmp/repo-small", type: "header" });
|
|
writeFileSync(filePath, line + "\n");
|
|
expect(extractCwdFromJsonl(filePath)).toBe("/tmp/repo-small");
|
|
});
|
|
|
|
test("12KB first line with obj.cwd: returns cwd (old 8KB cap returned null)", () => {
|
|
// Pad a JSONL header so the whole line is ~12KB ending in `}\n`.
|
|
// Old 8KB read would slice mid-line; JSON.parse on the truncated tail
|
|
// would throw, the catch would `continue`, and we'd return null.
|
|
const padding = "x".repeat(12 * 1024);
|
|
const line = JSON.stringify({
|
|
cwd: "/tmp/repo-12k",
|
|
type: "header",
|
|
notes: padding,
|
|
});
|
|
expect(line.length).toBeGreaterThan(8 * 1024);
|
|
expect(line.length).toBeLessThan(64 * 1024);
|
|
|
|
const filePath = join(tmpDir, "header-12k.jsonl");
|
|
writeFileSync(filePath, line + "\n");
|
|
expect(extractCwdFromJsonl(filePath)).toBe("/tmp/repo-12k");
|
|
});
|
|
|
|
test("80KB single line (overflows 64KB cap): returns null without crashing", () => {
|
|
// One line >64KB with no newline inside the read window. The 64KB read
|
|
// captures a truncated prefix, parts.length === 1, no trailing drop
|
|
// applies, JSON.parse throws, catch returns null. The fix's
|
|
// trailing-partial-drop must not crash on this shape.
|
|
const padding = "y".repeat(80 * 1024);
|
|
const line = JSON.stringify({ cwd: "/tmp/repo-80k", type: "header", notes: padding });
|
|
expect(line.length).toBeGreaterThan(64 * 1024);
|
|
|
|
const filePath = join(tmpDir, "header-80k.jsonl");
|
|
writeFileSync(filePath, line + "\n");
|
|
// Don't throw, just return null.
|
|
expect(extractCwdFromJsonl(filePath)).toBeNull();
|
|
});
|
|
|
|
test("complete line followed by partial second line: returns first line's cwd", () => {
|
|
// Line 1 ends cleanly with `\n` well within the cap.
|
|
// Line 2 is long enough that the 64KB read captures only its incomplete
|
|
// beginning. The trailing-partial drop must skip the truncated line 2
|
|
// and not poison the result.
|
|
const line1 = JSON.stringify({ cwd: "/tmp/repo-line-1", type: "header" });
|
|
const line2Padding = "z".repeat(80 * 1024);
|
|
const line2 = JSON.stringify({ cwd: "/tmp/repo-line-2", notes: line2Padding });
|
|
|
|
const filePath = join(tmpDir, "header-partial-2.jsonl");
|
|
writeFileSync(filePath, line1 + "\n" + line2 + "\n");
|
|
expect(extractCwdFromJsonl(filePath)).toBe("/tmp/repo-line-1");
|
|
});
|
|
|
|
test("missing file: returns null (file read error is swallowed)", () => {
|
|
const filePath = join(tmpDir, "nonexistent.jsonl");
|
|
expect(extractCwdFromJsonl(filePath)).toBeNull();
|
|
});
|
|
|
|
test("malformed first line then valid second line within cap: returns second", () => {
|
|
// Both lines fully within 64KB. First line is not valid JSON; second
|
|
// is. The function must skip first and return second's cwd.
|
|
const filePath = join(tmpDir, "bad-then-good.jsonl");
|
|
const good = JSON.stringify({ cwd: "/tmp/repo-skip-bad" });
|
|
writeFileSync(filePath, "{ not valid json\n" + good + "\n");
|
|
expect(extractCwdFromJsonl(filePath)).toBe("/tmp/repo-skip-bad");
|
|
});
|
|
});
|
|
});
|