mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* fix(flaky): 2026-10-31 flaky pass: paid detector gaps, Windows cold first launches, cleanup and settlement races, ubicloud pack index copy (GSTA-199) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship-budget): name the child-list probe timeout so the literals check reads the spawn timeout unchanged Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (Step 11): linear passive-pitch clause scan, tokenized grep operands, harness diagnostic reset and early stderr Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for the 2026-10-31 flaky pass Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(harness): non-behavioral HARNESS_VERSION decision for the llm-judge byte counter Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment path and count Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(testing): llm-judge-aborted lists bytesReceived and lastByteMs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * revert(flaky): leave the cso-snapshot SIGCONT fix for after the SBOM generator re-pin (GST-495) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
108 lines
5.8 KiB
TypeScript
108 lines
5.8 KiB
TypeScript
/**
|
|
* Required team mode registers its PreToolUse hook as a command string that
|
|
* the hook runner hands to whatever shell it uses: sh on macOS and Linux,
|
|
* cmd.exe or PowerShell on Windows and under Copilot CLI. The old
|
|
* `"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh"` only worked in a POSIX
|
|
* shell, so on Windows an intended allow or deny became a hook error (#2217).
|
|
*
|
|
* This reads the hook source and the registered command straight out of
|
|
* bin/gstack-team-init (no bash needed) and runs the command through the
|
|
* platform's default shell (`shell: true`: /bin/sh here, cmd.exe on Windows),
|
|
* plus PowerShell when one is installed, from a project path with a space.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, test } 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';
|
|
import { warmFirstExec } from './helpers/warm-first-exec';
|
|
|
|
const TEAM_INIT = fs.readFileSync(new URL('../bin/gstack-team-init', import.meta.url), 'utf-8');
|
|
|
|
const HOOK_START = 'cat > "$HOOKS_DIR/check-gstack.cjs" << \'HOOK_EOF\'\n';
|
|
const hookSource = TEAM_INIT.slice(TEAM_INIT.indexOf(HOOK_START) + HOOK_START.length, TEAM_INIT.indexOf('\nHOOK_EOF\n'));
|
|
const commandLiteral = TEAM_INIT.match(/^\s*HOOK_COMMAND="((?:[^"\\]|\\.)*)"$/m)?.[1] ?? '';
|
|
const command = commandLiteral.replace(/\\(["\\$`])/g, '$1');
|
|
|
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'team-hook-neutral-'));
|
|
afterAll(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
const project = path.join(base, 'my project');
|
|
fs.mkdirSync(path.join(project, '.claude', 'hooks'), { recursive: true });
|
|
fs.writeFileSync(path.join(project, '.claude', 'hooks', 'check-gstack.cjs'), hookSource);
|
|
const elsewhere = path.join(base, 'elsewhere');
|
|
fs.mkdirSync(elsewhere);
|
|
|
|
function hookEnv(home: Record<string, string>): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDE_PROJECT_DIR: project, ...home };
|
|
delete env.GSTACK_ROOT;
|
|
if (!('HOME' in home)) delete env.HOME;
|
|
return env;
|
|
}
|
|
|
|
type Runner = (env: NodeJS.ProcessEnv) => { status: number | null; stdout: string; stderr: string };
|
|
const viaDefaultShell: Runner = (env) => spawnSync(command, { shell: true, cwd: elsewhere, env, encoding: 'utf-8', timeout: 20_000 });
|
|
const powershell = process.platform === 'win32' ? 'powershell' : Bun.which('pwsh');
|
|
const viaPowerShell: Runner = (env) =>
|
|
spawnSync(powershell!, ['-NoProfile', '-NonInteractive', '-Command', command], { cwd: elsewhere, env, encoding: 'utf-8', timeout: 30_000 });
|
|
|
|
describe('required team hook: shell-neutral registration', () => {
|
|
// The first powershell.exe launch on a runner costs 1.7-3.1 s more than a warm one (probe 41205346554), inside the
|
|
// three-launch test's shared 30 s budget (41187420609 lost its first launch). Pay it once here, unasserted.
|
|
if (powershell) beforeAll(() => warmFirstExec(powershell, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0']), 30_000);
|
|
|
|
test('the extracted command is a node launcher with no shell variable syntax', () => {
|
|
expect(hookSource).toContain("'use strict';");
|
|
expect(command).toStartWith('node -e "');
|
|
expect(command).toContain('process.env.CLAUDE_PROJECT_DIR');
|
|
expect(commandLiteral).not.toContain('$');
|
|
expect(command).not.toContain('%');
|
|
});
|
|
|
|
// #3319: without node the shell's own error is all the runner gets, which is
|
|
// neither exit 0 nor 2, so the registration must carry onFailure: block.
|
|
test('without node on PATH the default shell exits neither 0 nor 2, so the handler is registered with onFailure: block', () => {
|
|
expect(TEAM_INIT).toContain("const handler = { type: 'command', command: process.env.GSTACK_HOOK_COMMAND, onFailure: 'block' };");
|
|
const noNode = path.join(base, 'no-node-bin');
|
|
fs.mkdirSync(noNode, { recursive: true });
|
|
const failed = viaDefaultShell({ ...hookEnv({ HOME: elsewhere, USERPROFILE: elsewhere }), PATH: noNode, Path: noNode });
|
|
expect(failed.stdout.trim()).toBe('');
|
|
expect([0, 2]).not.toContain(failed.status);
|
|
});
|
|
|
|
// `powershell -Command` reports a failing native command as exit 1, so only
|
|
// the default shell sees the hook's own exit 2; the deny JSON is the same.
|
|
for (const [name, runner, enabled, denyStatus] of [
|
|
['the default shell', viaDefaultShell, true, 2],
|
|
['PowerShell', viaPowerShell, Boolean(powershell), 1],
|
|
] as const) {
|
|
test.skipIf(!enabled)(`through ${name}: deny with exit 2 when no install resolves, {} when one does`, () => {
|
|
const home = path.join(base, `home-${name.replace(/\W+/g, '-')}`);
|
|
fs.mkdirSync(home, { recursive: true });
|
|
|
|
// A launch killed by the 30 s test budget reads as status null; the launch times name the one that stalled.
|
|
const launches: string[] = [];
|
|
const timed: Runner = (env) => {
|
|
const started = performance.now();
|
|
const result = runner(env);
|
|
launches.push(`${Math.round(performance.now() - started)} ms`);
|
|
return result;
|
|
};
|
|
|
|
const denied = timed(hookEnv({ HOME: home, USERPROFILE: home }));
|
|
expect(denied.status, `launches: ${launches.join(', ')}`).toBe(denyStatus);
|
|
expect(JSON.parse(denied.stdout.trim()).hookSpecificOutput).toMatchObject({ hookEventName: 'PreToolUse', permissionDecision: 'deny' });
|
|
expect(denied.stderr).toContain('BLOCKED: gstack is not installed globally.');
|
|
|
|
fs.mkdirSync(path.join(home, '.claude/skills/gstack/bin'), { recursive: true });
|
|
const allowed = timed(hookEnv({ HOME: home, USERPROFILE: home }));
|
|
expect(allowed.stderr).toBe('');
|
|
expect(allowed.stdout.trim(), `launches: ${launches.join(', ')}`).toBe('{}');
|
|
expect(allowed.status).toBe(0);
|
|
|
|
const viaProfile = timed(hookEnv({ USERPROFILE: home }));
|
|
expect(viaProfile.stdout.trim(), `launches: ${launches.join(', ')}`).toBe('{}');
|
|
expect(viaProfile.status).toBe(0);
|
|
});
|
|
}
|
|
});
|