mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-22 21:00:51 +02:00
test: hermetic update-check, onboarding gate sequencing, seeding parity
The contract test's child did a live git ls-remote + curl to github.com on every bun run test (update_check config now gates it off); the headless test gets a fresh GSTACK_HOME so the suppression is actually exercised; a new OV6 test drives the script three times to pin ack-at-emit and gate sequencing; hermetic seeding covers the config-keyed privacy gate; the EVALS_HERMETIC=0 debug seeding reaches marker parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6161b8ae1e
commit
4f432f521c
@@ -45,6 +45,11 @@ function runStart(args: string[] = [], env: Record<string, string> = {}): string
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-home-'));
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-home-'));
|
||||||
tmpGstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-gh-'));
|
tmpGstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-gh-'));
|
||||||
|
// Keep the free suite hermetic: with update_check unset, the child's
|
||||||
|
// gstack-update-check takes the slow path (a live git ls-remote + curl to
|
||||||
|
// github.com) on every `bun run test`. The config gate exits it before any
|
||||||
|
// network; the UPDATE_CHECK: contract key is still emitted (value "false").
|
||||||
|
fs.writeFileSync(path.join(tmpGstackHome, 'config.yaml'), 'update_check: false\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -170,16 +175,58 @@ describe('gstack-skill-start behavior', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('headless session suppresses first-task detection and Conductor line', () => {
|
test('headless session suppresses first-task detection and Conductor line', () => {
|
||||||
const out = runStart([], { GSTACK_HEADLESS: '1', CONDUCTOR_WORKSPACE_PATH: '/x' });
|
// Fresh GSTACK_HOME per test: the shared home is already ACTIVATED by the
|
||||||
// session-kind binary decides headless from env; if it does, FIRST_TASK
|
// contract test, which makes FIRST_TASK vacuously empty regardless of the
|
||||||
// stays empty and CONDUCTOR_SESSION is suppressed. If the binary reports
|
// headless gate — the suppression is only exercised from a cold home.
|
||||||
// interactive in this env, the guard still holds vacuously — assert the
|
const freshGh = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-fresh-'));
|
||||||
// implication, not the env behavior.
|
fs.writeFileSync(path.join(freshGh, 'config.yaml'), 'update_check: false\n');
|
||||||
if (/^SESSION_KIND: headless$/m.test(out)) {
|
try {
|
||||||
expect(out).toMatch(/^FIRST_TASK: $/m);
|
const out = runStart([], { GSTACK_HEADLESS: '1', CONDUCTOR_WORKSPACE_PATH: '/x', GSTACK_HOME: freshGh });
|
||||||
expect(out).not.toContain('CONDUCTOR_SESSION: true');
|
// session-kind binary decides headless from env; if it does, FIRST_TASK
|
||||||
} else {
|
// stays empty and CONDUCTOR_SESSION is suppressed. If the binary reports
|
||||||
expect(out).toContain('CONDUCTOR_SESSION: true');
|
// interactive in this env, the guard still holds vacuously — assert the
|
||||||
|
// implication, not the env behavior.
|
||||||
|
if (/^SESSION_KIND: headless$/m.test(out)) {
|
||||||
|
expect(out).toMatch(/^FIRST_TASK: $/m);
|
||||||
|
expect(out).not.toContain('CONDUCTOR_SESSION: true');
|
||||||
|
} else {
|
||||||
|
expect(out).toContain('CONDUCTOR_SESSION: true');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(freshGh, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display-only tips ack at emit and never re-fire (OV6)', () => {
|
||||||
|
const freshGh = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-refire-'));
|
||||||
|
fs.writeFileSync(path.join(freshGh, 'config.yaml'), 'update_check: false\n');
|
||||||
|
try {
|
||||||
|
const first = runStart([], { GSTACK_HOME: freshGh });
|
||||||
|
// Cold home: the script acks display gates at emit (.activated,
|
||||||
|
// .first-loop-tip-shown markers written by the script itself).
|
||||||
|
expect(fs.existsSync(path.join(freshGh, '.activated'))).toBe(true);
|
||||||
|
const second = runStart([], { GSTACK_HOME: freshGh });
|
||||||
|
// first-run-tip fires only on the activation run; first-loop-tip is
|
||||||
|
// DESIGNED to fire on a later run (activated, not yet shown) and acks
|
||||||
|
// at emit — so it may appear here but must never appear again below.
|
||||||
|
expect(second).not.toContain('GSTACK_INSTRUCTION_BEGIN: first-run-tip');
|
||||||
|
expect(fs.existsSync(path.join(freshGh, '.first-loop-tip-shown'))).toBe(true);
|
||||||
|
// Interactive gates are model-acked, so lake-intro (unacked) may still
|
||||||
|
// fire — but it must carry the run's own SESSION_ID, and only once.
|
||||||
|
const sid2 = second.match(/^SESSION_ID: (\S+)$/m)?.[1];
|
||||||
|
const headers2 = second.match(/^GSTACK_INSTRUCTION_BEGIN: .*$/gm) ?? [];
|
||||||
|
for (const h of headers2) expect(h.endsWith(` ${sid2}`)).toBe(true);
|
||||||
|
// Sequencing: telemetry-prompt is gated on the lake ack, so it must not
|
||||||
|
// appear while .completeness-intro-seen is absent.
|
||||||
|
expect(first).not.toContain('GSTACK_INSTRUCTION_BEGIN: telemetry-prompt');
|
||||||
|
fs.writeFileSync(path.join(freshGh, '.completeness-intro-seen'), '');
|
||||||
|
const third = runStart([], { GSTACK_HOME: freshGh });
|
||||||
|
expect(third).toContain('GSTACK_INSTRUCTION_BEGIN: telemetry-prompt');
|
||||||
|
expect(third).not.toContain('GSTACK_INSTRUCTION_BEGIN: lake-intro');
|
||||||
|
// Ack-at-emit means the loop tip from run 2 never re-fires.
|
||||||
|
expect(third).not.toContain('GSTACK_INSTRUCTION_BEGIN: first-loop-tip');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(freshGh, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,6 +255,14 @@ describe('gstack-skill-end', () => {
|
|||||||
expect(Number(m![1])).toBeLessThan(60);
|
expect(Number(m![1])).toBeLessThan(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('drains the artifacts queue (discover-new + once) — render prose promises it', () => {
|
||||||
|
// Every render says "do not run gstack-brain-sync separately — skill-end
|
||||||
|
// drains it"; dropping these lines would silently orphan the queue.
|
||||||
|
const s = fs.readFileSync(END, 'utf-8');
|
||||||
|
expect(s).toContain('gstack-brain-sync" --discover-new');
|
||||||
|
expect(s).toContain('gstack-brain-sync" --once');
|
||||||
|
});
|
||||||
|
|
||||||
test('cleans the pending analytics marker for the session', () => {
|
test('cleans the pending analytics marker for the session', () => {
|
||||||
fs.mkdirSync(path.join(tmpGstackHome, 'analytics'), { recursive: true });
|
fs.mkdirSync(path.join(tmpGstackHome, 'analytics'), { recursive: true });
|
||||||
const pending = path.join(tmpGstackHome, 'analytics', '.pending-sid-2');
|
const pending = path.join(tmpGstackHome, 'analytics', '.pending-sid-2');
|
||||||
|
|||||||
@@ -267,10 +267,15 @@ export async function finalizeEvalCollector(evalCollector: EvalCollector | null)
|
|||||||
|
|
||||||
// Pre-seed preamble state files so E2E tests don't waste turns on lake intro + telemetry prompts.
|
// Pre-seed preamble state files so E2E tests don't waste turns on lake intro + telemetry prompts.
|
||||||
// These are one-time interactive prompts that burn 3-7 turns per test if not pre-seeded.
|
// These are one-time interactive prompts that burn 3-7 turns per test if not pre-seeded.
|
||||||
|
// NOTE: since gstack-skill-start honors GSTACK_HOME (EOV7), hermetic children read the
|
||||||
|
// temp GSTACK_HOME that hermetic-env.ts seeds (the canonical marker list lives there);
|
||||||
|
// this operator-HOME seeding only serves EVALS_HERMETIC=0 debug runs.
|
||||||
if (evalsEnabled) {
|
if (evalsEnabled) {
|
||||||
const gstackDir = path.join(os.homedir(), '.gstack');
|
const gstackDir = path.join(os.homedir(), '.gstack');
|
||||||
fs.mkdirSync(gstackDir, { recursive: true });
|
fs.mkdirSync(gstackDir, { recursive: true });
|
||||||
for (const f of ['.completeness-intro-seen', '.telemetry-prompted', '.proactive-prompted']) {
|
// Marker list kept at parity with hermetic-env.ts's child-GSTACK_HOME seed
|
||||||
|
// (the canonical set for the emission layer's gates).
|
||||||
|
for (const f of ['.activated', '.completeness-intro-seen', '.telemetry-prompted', '.proactive-prompted', '.first-loop-tip-shown']) {
|
||||||
const p = path.join(gstackDir, f);
|
const p = path.join(gstackDir, f);
|
||||||
if (!fs.existsSync(p)) fs.writeFileSync(p, '');
|
if (!fs.existsSync(p)) fs.writeFileSync(p, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,10 @@ export function getHermeticDirs(): HermeticDirs {
|
|||||||
for (const f of ['.activated', '.completeness-intro-seen', '.telemetry-prompted', '.proactive-prompted', '.first-loop-tip-shown']) {
|
for (const f of ['.activated', '.completeness-intro-seen', '.telemetry-prompted', '.proactive-prompted', '.first-loop-tip-shown']) {
|
||||||
fs.writeFileSync(path.join(gstackHome, f), '');
|
fs.writeFileSync(path.join(gstackHome, f), '');
|
||||||
}
|
}
|
||||||
|
// The privacy stop-gate is config-keyed, not marker-keyed: on machines
|
||||||
|
// with gbrain installed it fires whenever artifacts_sync_mode is off and
|
||||||
|
// the consent prompt is unrecorded — same PTY-stall class as the markers.
|
||||||
|
fs.writeFileSync(path.join(gstackHome, 'config.yaml'), 'artifacts_sync_mode_prompted: true\n');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { fs.rmSync(runRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
|
try { fs.rmSync(runRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
Reference in New Issue
Block a user