qwen-code/scripts/tests/interactive-session-start.test.ts
qwen-code-dev-bot f8605526f6
test(integration): let workflow-completion reply waits inherit the helper default (#13552) (#13555)
* test(integration): let workflow-completion reply waits inherit the helper default (#13552)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(integration): retarget the #13552 fix at the ready-prompt startup wait

The recorded #13552 failure (run 37547210501) was InteractiveSession.start's
ready-prompt wait dying at its 30s cap on all three retries while the
sandbox:docker leg's container was still booting — 'Timeout (30000ms)
waiting for text: "Type your message"' at interactive-session.ts, before any
test body ran. The reply waits the previous round widened sit after start()
and never fired in that log, so the widening could not fix the issue while
'Fixes #13552' would have closed its dedup tracker.

- Revert workflow-completion.test.ts to main: the reply-wait widening was
  premised on a misread signature, and the inherited 120s default would have
  pushed this file's systematic-failure cost past the no-AK gate's 20-minute
  step budget (18 attempts x ~132s).
- Give start's ready-prompt wait a 90s budget on self-hosted runners (the
  /about loop's documented stall magnitude for the shared ECS pool), keeping
  the tight 30s signal everywhere else.
- Pin both branches with a stub CLI whose first prompt lands at 35s:
  self-hosted resolves, other runners reject with the exact CI string.

* test(integration): harden InteractiveSession.start failure paths (#13552)

Address the round-2 review findings on the #13552 ready-prompt budget:

- Extract the RUNNER_ENVIRONMENT budget decision into a pure
  readyPromptBudgetMs(env) helper so the suite-wide decision has one
  home; pin every runner state instantly from scripts/tests/ (collected
  by PR CI via test:scripts — no pull_request job executes
  integration-tests/**). The helper comment records that only
  InteractiveSession.start was observed to stall; the TestRig readiness
  waits keep their own budgets and are tracked separately.
- Race the ready-prompt wait against the child's onExit: a CLI that dies
  before printing its prompt now fails in milliseconds with its exit
  code instead of holding start() for the full budget (probe: a
  process.exit(3) child held start() for 30045ms before this change).
- Close the session when the startup wait rejects so a refused start()
  cannot orphan the child, pty, and terminal (probe: the 30s rejection
  left the stub child alive before this change); cleanup sits in a
  nested try so a throwing dispose() cannot mask the startup error, and
  the original error is rethrown so the #13552 timeout signature stays
  byte-identical.
- Keep one real-spawn wiring case (30s rejection + pid-death witness)
  instead of two real-time cases; capture an unexpected resolve straight
  into the afterEach-visible binding so the leaked-session cleanup can
  actually run.

* test(integration): keep dead-child boot logs and stop abandoned start polls (#13552)

* test(integration): word the dead-child exit line from captured output (#13552)

onExit settles ahead of waitFor's 200ms scan, so a child that printed
the ready prompt and died inside that gap was reported as having exited
before the prompt its own tail contained. Word the failure from the
captured output instead, pin the closed-flag statements the loop-text
pin cannot see, and make the call-site pins tolerate the trailing-comma
multi-line form prettier emits past 80 columns.

* test(integration): neutral dead-child exit wording, teardown latch, tighter pins (#13552)

The post-prompt-death case's outcome rode waitFor's 200ms scan phase
(~27% bad band, measured), and the sawPrompt wording could claim the
CLI exited before the prompt its own tail contains once a >4KB burst
outruns the parent's final read. Word the line neutrally ('during
startup'), swap the phase-dependent case for a deterministic >4KB-burst
case, latch the onExit handler off after startup is decided so healthy
teardowns skip the strip passes, and narrow the waitFor guard pin so
displacing the guard into the loop body reddens it.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-10-08 09:09:39 +00:00

84 lines
3.8 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
// No PR-triggered job collects the behavioural cases in
// integration-tests/interactive/interactive-session.test.ts: ci.yml's
// PR-gated integration_no_ak leg runs an explicit allow-list under
// --root ./integration-tests that does not name it, and the whole-root legs
// run post-merge and at release. These byte-shape pins give the #13552
// start() contract a PR-gated witness at ~0ms through test:scripts — each
// assertion names the revert it exists to redden, and each was probe-checked
// against that revert. The behaviour behind them is covered by the
// real-spawn cases beside the module. Patterns that pin a call site must
// tolerate the trailing-comma multi-line form prettier emits past 80
// columns, or a pure reflow reddens a PR-gated lane with no behavioural
// cause.
const source = readFileSync(
join(
resolve(import.meta.dirname, '../..'),
'integration-tests/interactive/interactive-session.ts',
),
'utf8',
);
describe('InteractiveSession.start source pins (#13552)', () => {
it('threads the resolved ready-prompt budget into the waitFor', () => {
// A full revert to the literal 30_000 drops the readyPromptBudgetMs call
// and its import together, and otherwise passes every lane. The call
// sits one token short of printWidth, so the pattern must also match the
// trailing-comma multi-line form a reflow emits.
expect(source).toMatch(
/session\.waitFor\(\s*'Type your message',\s*readyPromptBudgetMs\(process\.env\),?\s*\)/,
);
});
it('races the ready-prompt wait against child exit, keeping the tail', () => {
// Reverting the race returns a dead-child boot to a full-budget wait;
// dropping the tail discards the boot log that made #13552 diagnosable
// from the job log alone. The exit line stays neutral: whether the
// prompt reached rawOutput is undecidable once a child out-writes the
// parent's final read, so a before/after claim can contradict the very
// tail attached to it.
expect(source).toContain('Promise.race([');
expect(source).toContain('ptyProcess.onExit(');
// Without the latch, close()'s kill() re-runs the handler's strip passes
// and builds a rejection no settled race observes on every healthy
// session's teardown.
expect(source).toContain('if (!startupUndecided) return;');
expect(source).toContain(
'CLI exited during startup (code ${exitCode}, signal ${signal})',
);
expect(source).toContain(
'Last 500 chars: ${stripAnsi(session.rawOutput).slice(-500)}',
);
});
it('closes the session a failed start refuses to hand out', () => {
// Dropping the catch-close orphans the child, pty, and terminal.
expect(source).toMatch(/catch \(err\) \{[\s\S]*?await session\.close\(\)/);
});
it('stops the waitFor poll when the session closes', () => {
// Without the flag, the race's abandoned loser keeps re-scanning a dead
// pty's output every 200ms until its own budget expires. The guard is
// pinned leading waitFor's loop condition — displaced into the body it
// no longer stops the poll — while prettier's operator-leading reflow of
// the condition still matches.
expect(source).toMatch(/while\s*\(\s*!this\.closed\s*&&/);
});
it('fails an abandoned poll with the closed-session error', () => {
// Deleting either statement leaves the loop text intact, so the guard
// pin alone cannot see it: close() must set the flag, and waitFor must
// throw on it.
expect(source).toContain('this.closed = true;');
expect(source).toContain('Session closed while waiting for text:');
});
});