carthagent 1.0.7 → 1.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/beta-rewards-e2e-plan.md +48 -0
- package/docs/beta-rewards-e2e-results.md +84 -0
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/launch-readiness-live-results.md +65 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +175 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-beta.1.md +60 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +19 -6
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +22 -5
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +59 -31
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -0
- package/plugins/carthagent/src/ui/client.ts +127 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
- package/plugins/carthagent/src/ui/voyages.tsx +131 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +64 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +55 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +525 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-guidance.test.mjs +16 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +397 -0
- package/tests/native-voyages-ui.test.mjs +307 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +597 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +231 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +87 -63
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
package/tests/server.test.mjs
CHANGED
|
@@ -79,7 +79,9 @@ test('dashboard status reports an available update from the release cache', asyn
|
|
|
79
79
|
// Seed the throttled cache so the check resolves without touching the network.
|
|
80
80
|
const cachePath = join(dir, 'update-check.json');
|
|
81
81
|
writeFileSync(cachePath, JSON.stringify({ checkedAt: Date.now(), release: { version: '99.0.0', tag: 'v99.0.0' } }));
|
|
82
|
-
const
|
|
82
|
+
const channelPath = join(dir, 'update-channel.json');
|
|
83
|
+
writeFileSync(channelPath, JSON.stringify({ version: 1, channel: 'stable' }));
|
|
84
|
+
const server = await serverFixture(t, { CARTHAGENT_UPDATE_CHECK_FILE: cachePath, CARTHAGENT_UPDATE_CHANNEL_FILE: channelPath });
|
|
83
85
|
let body = {};
|
|
84
86
|
for (let i = 0; i < 50 && !body.update; i++) {
|
|
85
87
|
body = await (await fetch(`${server.url}/api/status`, { headers: { 'x-carthagent-token': server.token } })).json();
|
|
@@ -234,7 +234,7 @@ test('update check sends the install headers to the control plane only', async t
|
|
|
234
234
|
const agentDir = tmp(t);
|
|
235
235
|
const seen = [];
|
|
236
236
|
const { release, source } = await fetchLatestRelease({
|
|
237
|
-
cloudUrl: 'https://cloud.test', env: {}, agentDir,
|
|
237
|
+
cloudUrl: 'https://cloud.test', env: {}, agentDir, channel: 'stable',
|
|
238
238
|
fetchImpl: async (url, options) => {
|
|
239
239
|
seen.push({ url, headers: options.headers });
|
|
240
240
|
if (url.includes('/v1/cli/releases/latest')) return { status: 503, json: async () => ({}) };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
10
|
+
const launcher = join(root, 'script/ctg/source-preview.sh');
|
|
11
|
+
const plugin = join(root, 'plugins/carthagent');
|
|
12
|
+
|
|
13
|
+
function fixture(t, { bunVersion = '1.4.2' } = {}) {
|
|
14
|
+
// Reach the fixture through a symlinked parent, like macOS /var -> /private/var, so explicit
|
|
15
|
+
// alias spellings and physical cwd resolution stay distinguishable on every POSIX host.
|
|
16
|
+
const physicalParent = mkdtempSync(join(tmpdir(), 'ctg preview physical '));
|
|
17
|
+
const aliasParent = mkdtempSync(join(tmpdir(), 'ctg preview alias '));
|
|
18
|
+
t.after(() => {
|
|
19
|
+
rmSync(aliasParent, { recursive: true, force: true });
|
|
20
|
+
rmSync(physicalParent, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
symlinkSync(physicalParent, join(aliasParent, 'var'));
|
|
23
|
+
const base = join(aliasParent, 'var');
|
|
24
|
+
assert.notEqual(realpathSync(base), base, 'fixture base must be reached through an alias');
|
|
25
|
+
const source = join(base, 'native source');
|
|
26
|
+
mkdirSync(join(source, 'packages/cli/src'), { recursive: true });
|
|
27
|
+
mkdirSync(join(source, 'node_modules'));
|
|
28
|
+
writeFileSync(join(source, 'package.json'), JSON.stringify({ name: 'opencode', packageManager: 'bun@1.4.2' }));
|
|
29
|
+
writeFileSync(join(source, 'packages/cli/src/index.ts'), '');
|
|
30
|
+
const tools = join(base, 'tool dir');
|
|
31
|
+
mkdirSync(tools);
|
|
32
|
+
const record = join(base, 'record.json');
|
|
33
|
+
const bun = join(tools, 'bun');
|
|
34
|
+
// Test double: reports version, or records invocation cwd/args/env instead of running OpenCode.
|
|
35
|
+
writeFileSync(bun, `#!/bin/sh
|
|
36
|
+
if [ "$1" = "--version" ]; then echo ${bunVersion}; exit 0; fi
|
|
37
|
+
exec "${process.execPath}" -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2), env: process.env }))' "${record}" "$@"
|
|
38
|
+
`);
|
|
39
|
+
chmodSync(bun, 0o755);
|
|
40
|
+
const profile = join(base, 'private profile');
|
|
41
|
+
const workspace = join(base, 'empty workspace');
|
|
42
|
+
mkdirSync(workspace);
|
|
43
|
+
const outside = join(base, 'outside cwd');
|
|
44
|
+
mkdirSync(outside);
|
|
45
|
+
return { base, source, bun, profile, workspace, outside, record };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The launcher is a POSIX /bin/sh script; native Windows has no /bin/sh and is not a supported platform.
|
|
49
|
+
const posixOnly = process.platform === 'win32' ? 'POSIX /bin/sh launcher is not supported on native Windows' : false;
|
|
50
|
+
const posixTest = (name, fn) => test(name, { skip: posixOnly }, fn);
|
|
51
|
+
|
|
52
|
+
function sh(script, args, options) {
|
|
53
|
+
const result = spawnSync('/bin/sh', [script, ...args], { encoding: 'utf8', ...options });
|
|
54
|
+
if (result.error) throw new Error(`could not run /bin/sh ${script}: ${result.error.message}`, { cause: result.error });
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function run(f, args, { env = {}, cwd = f.outside } = {}) {
|
|
59
|
+
const result = sh(launcher, args, { cwd, env: { PATH: '/usr/bin:/bin', TERM: 'xterm-256color', ...env } });
|
|
60
|
+
const recorded = existsSync(f.record) ? JSON.parse(readFileSync(f.record, 'utf8')) : undefined;
|
|
61
|
+
return { ...result, recorded };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const required = f => ['--source', f.source, '--profile', f.profile, '--bun', f.bun];
|
|
65
|
+
|
|
66
|
+
posixTest('launches source CLI from packages/cli with explicit space-containing paths outside the repository', t => {
|
|
67
|
+
const f = fixture(t);
|
|
68
|
+
const result = run(f, [...required(f), '--workspace', f.workspace]);
|
|
69
|
+
assert.equal(result.status, 0, result.stderr);
|
|
70
|
+
// process.cwd() always reports the physical directory, even when entered through an alias.
|
|
71
|
+
assert.equal(result.recorded.cwd, join(realpathSync(f.source), 'packages/cli'));
|
|
72
|
+
assert.deepEqual(result.recorded.args, ['--no-env-file', 'run', 'src/index.ts', '--standalone', f.workspace]);
|
|
73
|
+
assert.equal(result.recorded.env.HOME, join(f.profile, 'home'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
posixTest('defaults the workspace to the caller directory and resolves relative paths against it', t => {
|
|
77
|
+
const f = fixture(t);
|
|
78
|
+
// The spawned caller has no inherited PWD, so its shell resolves the default and relative paths from the physical cwd.
|
|
79
|
+
let result = run(f, required(f), { cwd: f.workspace });
|
|
80
|
+
assert.equal(result.status, 0, result.stderr);
|
|
81
|
+
assert.deepEqual(result.recorded.args.slice(-2), ['--standalone', realpathSync(f.workspace)]);
|
|
82
|
+
rmSync(f.record);
|
|
83
|
+
const callerBase = realpathSync(f.base);
|
|
84
|
+
result = run(f, ['--source', 'native source', '--profile', 'private profile', '--bun', 'tool dir/bun', '--workspace', 'empty workspace'], { cwd: f.base });
|
|
85
|
+
assert.equal(result.status, 0, result.stderr);
|
|
86
|
+
assert.equal(result.recorded.cwd, join(callerBase, 'native source/packages/cli'));
|
|
87
|
+
assert.deepEqual(result.recorded.args.slice(-2), ['--standalone', join(callerBase, 'empty workspace')]);
|
|
88
|
+
assert.equal(result.recorded.env.HOME, join(callerBase, 'private profile/home'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
posixTest('passes only the isolated allowlist and never inherits credentials', t => {
|
|
92
|
+
const f = fixture(t);
|
|
93
|
+
const result = run(f, [...required(f), '--workspace', f.workspace], {
|
|
94
|
+
env: { OPENAI_API_KEY: 'synthetic-not-a-secret', ANTHROPIC_API_KEY: 'synthetic', OPENCODE_CONFIG: '/elsewhere', HOME: '/home/ordinary', XDG_CONFIG_HOME: '/home/ordinary/.config', COLORTERM: 'truecolor' },
|
|
95
|
+
});
|
|
96
|
+
assert.equal(result.status, 0, result.stderr);
|
|
97
|
+
const env = result.recorded.env;
|
|
98
|
+
for (const name of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_CONFIG']) assert.equal(env[name], undefined, name);
|
|
99
|
+
assert.equal(env.HOME, join(f.profile, 'home'));
|
|
100
|
+
assert.equal(env.TMPDIR, join(f.profile, 'tmp'));
|
|
101
|
+
assert.equal(env.XDG_CONFIG_HOME, join(f.profile, 'config'));
|
|
102
|
+
assert.equal(env.XDG_DATA_HOME, join(f.profile, 'data'));
|
|
103
|
+
assert.equal(env.XDG_CACHE_HOME, join(f.profile, 'cache'));
|
|
104
|
+
assert.equal(env.XDG_STATE_HOME, join(f.profile, 'state'));
|
|
105
|
+
assert.equal(env.OPENCODE_CONFIG_DIR, join(f.profile, 'config/opencode'));
|
|
106
|
+
assert.equal(env.OPENCODE_TEST_HOME, join(f.profile, 'home'));
|
|
107
|
+
assert.equal(env.OPENCODE_DISABLE_PROJECT_CONFIG, '1');
|
|
108
|
+
assert.equal(env.OPENCODE_DISABLE_AUTOUPDATE, '1');
|
|
109
|
+
assert.equal(env.BUN_RUNTIME_TRANSPILER_CACHE_PATH, '0');
|
|
110
|
+
assert.equal(env.TERM, 'xterm-256color');
|
|
111
|
+
assert.equal(env.COLORTERM, 'truecolor');
|
|
112
|
+
assert.equal(env.PATH.split(':')[0], join(f.base, 'tool dir'));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
posixTest('creates a private profile with plugin, theme and CLI configuration on first launch', t => {
|
|
116
|
+
const f = fixture(t);
|
|
117
|
+
const result = run(f, [...required(f), '--workspace', f.workspace]);
|
|
118
|
+
assert.equal(result.status, 0, result.stderr);
|
|
119
|
+
const config = join(f.profile, 'config/opencode');
|
|
120
|
+
assert.deepEqual(JSON.parse(readFileSync(join(config, 'opencode.json'), 'utf8')).plugins, [plugin]);
|
|
121
|
+
assert.equal(JSON.parse(readFileSync(join(config, 'cli.json'), 'utf8')).theme.name, 'carthage');
|
|
122
|
+
assert.equal(readFileSync(join(config, 'themes/carthage.json'), 'utf8'), readFileSync(join(plugin, 'themes/carthage.json'), 'utf8'));
|
|
123
|
+
assert.equal(statSync(f.profile).mode & 0o777, 0o700);
|
|
124
|
+
assert.equal(statSync(join(config, 'opencode.json')).mode & 0o777, 0o600);
|
|
125
|
+
for (const dir of ['home', 'tmp', 'data', 'cache', 'state']) assert.ok(statSync(join(f.profile, dir)).isDirectory(), dir);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
posixTest('creates the private profile with BSD chmod argument parsing and a dash-leading relative path', t => {
|
|
129
|
+
const f = fixture(t);
|
|
130
|
+
const shims = join(f.base, 'bsd shims');
|
|
131
|
+
mkdirSync(shims);
|
|
132
|
+
// BSD chmod stops option parsing at the mode, so a later `--` is a missing file operand.
|
|
133
|
+
writeFileSync(join(shims, 'chmod'), `#!/bin/sh
|
|
134
|
+
mode=$1; shift
|
|
135
|
+
for operand in "$@"; do
|
|
136
|
+
case "$operand" in --|-*) echo "chmod: $operand: No such file or directory" >&2; exit 1 ;; esac
|
|
137
|
+
done
|
|
138
|
+
exec /bin/chmod "$mode" "$@"
|
|
139
|
+
`);
|
|
140
|
+
chmodSync(join(shims, 'chmod'), 0o755);
|
|
141
|
+
const result = run(f, ['--source', f.source, '--profile', '-dash profile', '--bun', f.bun, '--workspace', f.workspace], {
|
|
142
|
+
cwd: f.base, env: { PATH: `${shims}:/usr/bin:/bin` },
|
|
143
|
+
});
|
|
144
|
+
assert.equal(result.status, 0, result.stderr);
|
|
145
|
+
const profile = join(f.base, '-dash profile');
|
|
146
|
+
assert.equal(statSync(profile).mode & 0o777, 0o700);
|
|
147
|
+
assert.equal(result.recorded.env.HOME, join(realpathSync(f.base), '-dash profile/home'));
|
|
148
|
+
assert.ok(existsSync(join(profile, 'config/opencode/opencode.json')));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
posixTest('preserves existing profile configuration and authentication on repeat launches', t => {
|
|
152
|
+
const f = fixture(t);
|
|
153
|
+
const config = join(f.profile, 'config/opencode');
|
|
154
|
+
mkdirSync(join(config, 'themes'), { recursive: true });
|
|
155
|
+
mkdirSync(join(f.profile, 'data/opencode'), { recursive: true });
|
|
156
|
+
const files = {
|
|
157
|
+
[join(config, 'opencode.json')]: JSON.stringify({ plugins: [plugin], provider: { synthetic: { options: { baseURL: 'http://127.0.0.1:1' } } } }),
|
|
158
|
+
[join(config, 'cli.json')]: JSON.stringify({ theme: { name: 'nord', mode: 'light' } }),
|
|
159
|
+
[join(config, 'themes/carthage.json')]: '{"synthetic":"user theme"}',
|
|
160
|
+
[join(f.profile, 'data/opencode/auth.json')]: '{"synthetic":"fixture, not a credential"}',
|
|
161
|
+
};
|
|
162
|
+
for (const [path, content] of Object.entries(files)) writeFileSync(path, content);
|
|
163
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
164
|
+
const result = run(f, [...required(f), '--workspace', f.workspace]);
|
|
165
|
+
assert.equal(result.status, 0, result.stderr);
|
|
166
|
+
for (const [path, content] of Object.entries(files)) assert.equal(readFileSync(path, 'utf8'), content, path);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
posixTest('warns without rewriting a supplied config that omits the Carthagent plugin', t => {
|
|
171
|
+
const f = fixture(t);
|
|
172
|
+
const config = join(f.profile, 'config/opencode');
|
|
173
|
+
mkdirSync(config, { recursive: true });
|
|
174
|
+
writeFileSync(join(config, 'opencode.json'), '{"plugins":[]}');
|
|
175
|
+
const result = run(f, [...required(f), '--workspace', f.workspace]);
|
|
176
|
+
assert.equal(result.status, 0, result.stderr);
|
|
177
|
+
assert.match(result.stderr, /does not reference the Carthagent plugin/);
|
|
178
|
+
assert.equal(readFileSync(join(config, 'opencode.json'), 'utf8'), '{"plugins":[]}');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
posixTest('accepts a config that references the plugin through its physical path when launched via an alias', t => {
|
|
182
|
+
const f = fixture(t);
|
|
183
|
+
const alias = join(f.base, 'checkout alias');
|
|
184
|
+
symlinkSync(root, alias);
|
|
185
|
+
const config = join(f.profile, 'config/opencode');
|
|
186
|
+
mkdirSync(config, { recursive: true });
|
|
187
|
+
writeFileSync(join(config, 'opencode.json'), JSON.stringify({ plugins: [plugin] }));
|
|
188
|
+
const result = sh(join(alias, 'script/ctg/source-preview.sh'), [...required(f), '--workspace', f.workspace], {
|
|
189
|
+
cwd: f.outside, env: { PATH: '/usr/bin:/bin' },
|
|
190
|
+
});
|
|
191
|
+
assert.equal(result.status, 0, result.stderr);
|
|
192
|
+
assert.doesNotMatch(result.stderr, /warning/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
posixTest('forwards explicit commands after -- and enforces a private standalone server', t => {
|
|
196
|
+
const f = fixture(t);
|
|
197
|
+
let result = run(f, [...required(f), '--', 'auth', 'login', 'my provider']);
|
|
198
|
+
assert.equal(result.status, 0, result.stderr);
|
|
199
|
+
assert.deepEqual(result.recorded.args, ['--no-env-file', 'run', 'src/index.ts', 'auth', 'login', 'my provider', '--standalone']);
|
|
200
|
+
rmSync(f.record);
|
|
201
|
+
result = run(f, [...required(f), '--', '--standalone', f.workspace]);
|
|
202
|
+
assert.equal(result.status, 0, result.stderr);
|
|
203
|
+
assert.deepEqual(result.recorded.args.slice(3), ['--standalone', f.workspace]);
|
|
204
|
+
for (const forbidden of [['--server', 'http://127.0.0.1:4096'], ['--server=http://127.0.0.1:4096']]) {
|
|
205
|
+
rmSync(f.record, { force: true });
|
|
206
|
+
result = run(f, [...required(f), '--', ...forbidden]);
|
|
207
|
+
assert.equal(result.status, 2);
|
|
208
|
+
assert.match(result.stderr, /--server/);
|
|
209
|
+
assert.equal(result.recorded, undefined);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
posixTest('reports missing explicit inputs and prerequisites without launching', t => {
|
|
214
|
+
const f = fixture(t);
|
|
215
|
+
const cases = [
|
|
216
|
+
[['--profile', f.profile, '--bun', f.bun], /--source is required/],
|
|
217
|
+
[['--source', f.source, '--bun', f.bun], /--profile is required/],
|
|
218
|
+
[['--source', f.source, '--profile', f.profile], /--bun is required/],
|
|
219
|
+
[['--source', join(f.base, 'missing'), '--profile', f.profile, '--bun', f.bun], /source .* is not a directory/],
|
|
220
|
+
[['--source', f.workspace, '--profile', f.profile, '--bun', f.bun], /packages\/cli\/src\/index\.ts/],
|
|
221
|
+
[['--source', f.source, '--profile', f.profile, '--bun', join(f.base, 'missing bun')], /not executable/],
|
|
222
|
+
[[...required(f), '--workspace', join(f.base, 'missing workspace')], /workspace .* is not a directory/],
|
|
223
|
+
[[...required(f), '--unknown'], /Unknown option/],
|
|
224
|
+
];
|
|
225
|
+
for (const [args, message] of cases) {
|
|
226
|
+
const result = run(f, args);
|
|
227
|
+
assert.equal(result.status, 2, `${args.join(' ')}\n${result.stderr}`);
|
|
228
|
+
assert.match(result.stderr, message);
|
|
229
|
+
assert.equal(result.recorded, undefined);
|
|
230
|
+
}
|
|
231
|
+
rmSync(join(f.source, 'node_modules'), { recursive: true });
|
|
232
|
+
const missingDeps = run(f, required(f));
|
|
233
|
+
assert.equal(missingDeps.status, 1);
|
|
234
|
+
assert.match(missingDeps.stderr, /bun install/);
|
|
235
|
+
assert.equal(missingDeps.recorded, undefined);
|
|
236
|
+
assert.equal(existsSync(f.profile), false, 'failed prerequisite checks do not create a profile');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
posixTest('rejects a Bun version that differs from the source packageManager pin', t => {
|
|
240
|
+
const f = fixture(t, { bunVersion: '1.3.0' });
|
|
241
|
+
const result = run(f, required(f));
|
|
242
|
+
assert.equal(result.status, 1);
|
|
243
|
+
assert.match(result.stderr, /requires bun 1\.4\.2; .* reports 1\.3\.0/);
|
|
244
|
+
assert.equal(result.recorded, undefined);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
posixTest('prints usage with --help', t => {
|
|
248
|
+
const result = sh(launcher, ['--help'], { env: { PATH: '/usr/bin:/bin' } });
|
|
249
|
+
assert.equal(result.status, 0);
|
|
250
|
+
assert.match(result.stdout, /--source DIR/);
|
|
251
|
+
assert.match(result.stdout, /--profile DIR/);
|
|
252
|
+
assert.match(result.stdout, /--bun PATH/);
|
|
253
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import pathlib
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
MODULE_PATH = pathlib.Path(__file__).resolve().parents[1] / "tools" / "test-native-terminal-performance.py"
|
|
7
|
+
SPEC = importlib.util.spec_from_file_location("native_terminal_probe", MODULE_PATH)
|
|
8
|
+
PROBE = importlib.util.module_from_spec(SPEC)
|
|
9
|
+
SPEC.loader.exec_module(PROBE)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EditorEchoTests(unittest.TestCase):
|
|
13
|
+
def test_native_placeholder_anchor_and_exact_changed_cell_are_required(self):
|
|
14
|
+
screen = PROBE.Screen(rows=4, cols=40)
|
|
15
|
+
row, col = 2, 5
|
|
16
|
+
line = PROBE.NATIVE_EDITOR_PLACEHOLDER
|
|
17
|
+
for offset, char in enumerate(line):
|
|
18
|
+
screen.cells[row][col + offset] = char
|
|
19
|
+
before = [cells[:] for cells in screen.cells]
|
|
20
|
+
self.assertEqual(PROBE.locate_text(screen, line), (row, col))
|
|
21
|
+
screen.cells[row][col] = "z"
|
|
22
|
+
self.assertTrue(PROBE.editor_contains_typed_cell(screen, "z", before, row, col))
|
|
23
|
+
|
|
24
|
+
def test_arbitrary_transcript_or_another_editor_cell_is_not_an_echo(self):
|
|
25
|
+
screen = PROBE.Screen(rows=4, cols=40)
|
|
26
|
+
row, col = 2, 5
|
|
27
|
+
for offset, char in enumerate(PROBE.NATIVE_EDITOR_PLACEHOLDER):
|
|
28
|
+
screen.cells[row][col + offset] = char
|
|
29
|
+
before = [cells[:] for cells in screen.cells]
|
|
30
|
+
screen.cells[row][col + 4] = "z"
|
|
31
|
+
self.assertFalse(PROBE.editor_contains_typed_cell(screen, "z", before, row, col))
|
|
32
|
+
screen.cells[row][col + 4] = before[row][col + 4]
|
|
33
|
+
screen.cells[0][0] = "z"
|
|
34
|
+
self.assertFalse(PROBE.editor_contains_typed_cell(screen, "z", before, row, col))
|
|
35
|
+
|
|
36
|
+
def test_pty_ready_detection_uses_the_actual_current_product_hint(self):
|
|
37
|
+
screen = PROBE.Screen(rows=4, cols=80)
|
|
38
|
+
hint = "Message carthagent…"
|
|
39
|
+
for offset, char in enumerate(hint):
|
|
40
|
+
screen.cells[2][5 + offset] = char
|
|
41
|
+
self.assertEqual(PROBE.locate_text(screen, PROBE.NATIVE_EDITOR_PLACEHOLDER), (2, 5))
|
|
42
|
+
self.assertNotEqual(PROBE.NATIVE_EDITOR_PLACEHOLDER, "Ask anything")
|
|
43
|
+
|
|
44
|
+
def test_workspace_render_requires_fresh_post_submit_route_and_loaded_section(self):
|
|
45
|
+
screen = PROBE.Screen(rows=5, cols=80)
|
|
46
|
+
screen.cells[1][2:25] = list("CARTHAGENT · WORKSPACE")
|
|
47
|
+
before = ["old session", "slash autocomplete /ctg-workspace", "", "", ""]
|
|
48
|
+
screen.cells[3][2:26] = list("Workspace native sessions")
|
|
49
|
+
self.assertTrue(PROBE.workspace_render_completed(screen, before))
|
|
50
|
+
|
|
51
|
+
def test_workspace_render_rejects_blank_partial_and_stale_frames(self):
|
|
52
|
+
screen = PROBE.Screen(rows=5, cols=80)
|
|
53
|
+
before = ["old session", "", "", "", ""]
|
|
54
|
+
screen.cells[1][2:25] = list("CARTHAGENT · WORKSPACE")
|
|
55
|
+
self.assertFalse(PROBE.workspace_render_completed(screen, before))
|
|
56
|
+
screen.cells[3][2:26] = list("Workspace native sessions")
|
|
57
|
+
stale = ["", "CARTHAGENT · WORKSPACE", "", "Workspace native sessions", ""]
|
|
58
|
+
self.assertFalse(PROBE.workspace_render_completed(screen, stale))
|
|
59
|
+
|
|
60
|
+
def test_workspace_command_is_not_timed_while_completion_or_old_editor_is_visible(self):
|
|
61
|
+
screen = PROBE.Screen(rows=5, cols=80)
|
|
62
|
+
origin = (1, 2)
|
|
63
|
+
screen.cells[0][20:34] = list("/ctg-workspace")
|
|
64
|
+
screen.cells[2][2:20] = list("Carthagent commands")
|
|
65
|
+
self.assertFalse(PROBE.workspace_command_staged(screen, origin))
|
|
66
|
+
screen.cells[2][2:20] = list(" ")
|
|
67
|
+
screen.cells[3][2:20] = list(PROBE.NATIVE_EDITOR_PLACEHOLDER)
|
|
68
|
+
self.assertFalse(PROBE.workspace_command_staged(screen, origin))
|
|
69
|
+
screen.cells[3][2:20] = list(" ")
|
|
70
|
+
self.assertFalse(PROBE.workspace_command_staged(screen, origin))
|
|
71
|
+
screen.cells[1][2:16] = list("/ctg-workspace")
|
|
72
|
+
self.assertTrue(PROBE.workspace_command_staged(screen, origin))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
unittest.main()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import hashlib
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import Mock, patch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PTY_AVAILABLE = False
|
|
14
|
+
PROBE = None
|
|
15
|
+
if os.name == 'posix':
|
|
16
|
+
try:
|
|
17
|
+
import fcntl
|
|
18
|
+
import pty
|
|
19
|
+
import tty
|
|
20
|
+
except ImportError:
|
|
21
|
+
pass
|
|
22
|
+
else:
|
|
23
|
+
PTY_AVAILABLE = hasattr(os, 'openpty')
|
|
24
|
+
|
|
25
|
+
if PTY_AVAILABLE:
|
|
26
|
+
MODULE_PATH = pathlib.Path(__file__).resolve().parents[1] / 'tools' / 'native-terminal-semantics.py'
|
|
27
|
+
SPEC = importlib.util.spec_from_file_location('native_terminal_semantics', MODULE_PATH)
|
|
28
|
+
PROBE = importlib.util.module_from_spec(SPEC)
|
|
29
|
+
SPEC.loader.exec_module(PROBE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@unittest.skipUnless(PTY_AVAILABLE, 'requires POSIX fcntl, pty, tty and os.openpty')
|
|
33
|
+
class DuplexTransportTests(unittest.TestCase):
|
|
34
|
+
def assert_flags_restored(self, master, flags):
|
|
35
|
+
restored = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
36
|
+
# XNU fcntl.h: FWASWRITTEN (0x10000) records successful writes;
|
|
37
|
+
# sys_generic.c sets it, and kern_descrip.c F_SETFL preserves it
|
|
38
|
+
# outside FCNTLFLAGS. It is not O_LARGEFILE or a controllable flag.
|
|
39
|
+
# Compare every other bit, including access mode and O_NONBLOCK.
|
|
40
|
+
kernel_write_history = 0x10000 if sys.platform == 'darwin' else 0
|
|
41
|
+
self.assertEqual(restored & ~kernel_write_history, flags & ~kernel_write_history)
|
|
42
|
+
self.assertEqual(os.get_blocking(master), not bool(flags & os.O_NONBLOCK))
|
|
43
|
+
|
|
44
|
+
def launch(self, program):
|
|
45
|
+
master, slave = pty.openpty()
|
|
46
|
+
tty.setraw(slave)
|
|
47
|
+
child = subprocess.Popen([sys.executable, '-u', '-c', program],
|
|
48
|
+
stdin=slave, stdout=slave, stderr=slave)
|
|
49
|
+
os.close(slave)
|
|
50
|
+
self.addCleanup(os.close, master)
|
|
51
|
+
|
|
52
|
+
def stop():
|
|
53
|
+
if child.poll() is None:
|
|
54
|
+
child.kill()
|
|
55
|
+
child.wait(timeout=3)
|
|
56
|
+
|
|
57
|
+
self.addCleanup(stop)
|
|
58
|
+
return master, child
|
|
59
|
+
|
|
60
|
+
def test_large_input_drains_output_and_preserves_every_byte(self):
|
|
61
|
+
payload = b'\x1b[200~' + ('café 日本語 😀\n' * 20000).encode() + b'\x1b[201~'
|
|
62
|
+
# The child deliberately fills output BEFORE consuming any input. A
|
|
63
|
+
# write-only parent deadlocks once the independent PTY queues fill.
|
|
64
|
+
master, child = self.launch(f'''
|
|
65
|
+
import hashlib, os, time
|
|
66
|
+
block = b'O' * 4096
|
|
67
|
+
for _ in range(128):
|
|
68
|
+
os.write(1, block)
|
|
69
|
+
data = bytearray()
|
|
70
|
+
while len(data) < {len(payload)}:
|
|
71
|
+
data.extend(os.read(0, min(4096, {len(payload)} - len(data))))
|
|
72
|
+
os.write(1, b'\\nDIGEST:' + hashlib.sha256(data).hexdigest().encode() + b'\\n')
|
|
73
|
+
time.sleep(2)
|
|
74
|
+
''')
|
|
75
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
76
|
+
output = bytearray()
|
|
77
|
+
original_write = os.write
|
|
78
|
+
counts = []
|
|
79
|
+
retries = [InterruptedError(), BlockingIOError()]
|
|
80
|
+
|
|
81
|
+
def partial_write(fd, data):
|
|
82
|
+
if retries:
|
|
83
|
+
raise retries.pop()
|
|
84
|
+
# Force short writes independently of the kernel's queue sizing.
|
|
85
|
+
count = original_write(fd, data[:997])
|
|
86
|
+
counts.append(count)
|
|
87
|
+
return count
|
|
88
|
+
|
|
89
|
+
deadline = time.monotonic() + 5
|
|
90
|
+
with patch.object(PROBE.os, 'write', side_effect=partial_write):
|
|
91
|
+
PROBE.write_pty_input(master, payload, deadline, output.extend, child)
|
|
92
|
+
self.assert_flags_restored(master, flags)
|
|
93
|
+
self.assertEqual(sum(counts), len(payload))
|
|
94
|
+
self.assertGreater(len(counts), 1)
|
|
95
|
+
self.assertIn(b'O' * 4096, output)
|
|
96
|
+
expected = b'DIGEST:' + hashlib.sha256(payload).hexdigest().encode()
|
|
97
|
+
while expected not in output and time.monotonic() < deadline:
|
|
98
|
+
if PROBE.select.select([master], [], [], .05)[0]:
|
|
99
|
+
output.extend(os.read(master, 65536))
|
|
100
|
+
self.assertIn(expected, output)
|
|
101
|
+
|
|
102
|
+
def test_unconsumed_input_times_out_and_restores_flags(self):
|
|
103
|
+
master, child = self.launch('import time; time.sleep(10)')
|
|
104
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
105
|
+
started = time.monotonic()
|
|
106
|
+
with self.assertRaisesRegex(TimeoutError, r'bytes written; child status=None'):
|
|
107
|
+
PROBE.write_pty_input(master, b'x' * 1000000, started + .2, lambda _: None, child)
|
|
108
|
+
self.assertLess(time.monotonic() - started, 1.5)
|
|
109
|
+
self.assert_flags_restored(master, flags)
|
|
110
|
+
|
|
111
|
+
def test_exited_child_fails_without_waiting_for_deadline(self):
|
|
112
|
+
master, child = self.launch('pass')
|
|
113
|
+
child.wait(timeout=3)
|
|
114
|
+
with self.assertRaisesRegex(RuntimeError, 'exited during PTY input'):
|
|
115
|
+
PROBE.write_pty_input(master, b'x', time.monotonic() + 5, lambda _: None, child)
|
|
116
|
+
|
|
117
|
+
def test_output_callback_failure_restores_flags(self):
|
|
118
|
+
master, child = self.launch("import os, time; os.write(1, b'ready'); time.sleep(10)")
|
|
119
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
120
|
+
|
|
121
|
+
def reject_output(_chunk):
|
|
122
|
+
raise ValueError('screen rejected output')
|
|
123
|
+
|
|
124
|
+
with self.assertRaisesRegex(ValueError, 'screen rejected output'):
|
|
125
|
+
PROBE.write_pty_input(master, b'x' * 1000000, time.monotonic() + 2,
|
|
126
|
+
reject_output, child)
|
|
127
|
+
self.assert_flags_restored(master, flags)
|
|
128
|
+
|
|
129
|
+
def test_initially_nonblocking_descriptor_stays_nonblocking(self):
|
|
130
|
+
master, child = self.launch('import time; time.sleep(10)')
|
|
131
|
+
os.set_blocking(master, False)
|
|
132
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
133
|
+
PROBE.write_pty_input(master, b'x', time.monotonic() + 2, lambda _: None, child)
|
|
134
|
+
self.assert_flags_restored(master, flags)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@unittest.skipUnless(PTY_AVAILABLE, 'requires POSIX probe imports')
|
|
138
|
+
class SnapshotTests(unittest.TestCase):
|
|
139
|
+
def test_utf8_sync_and_queries_survive_every_chunk_boundary(self):
|
|
140
|
+
payload = b'\x1b[?2026h' + 'café 日本語 😀'.encode() + b'\x1b[6n\x1b[c\x1b[?2026l'
|
|
141
|
+
for boundary in range(len(payload) + 1):
|
|
142
|
+
with self.subTest(boundary=boundary):
|
|
143
|
+
screen = PROBE.Screen()
|
|
144
|
+
screen.feed(payload[:boundary])
|
|
145
|
+
screen.feed(payload[boundary:])
|
|
146
|
+
self.assertFalse(screen.sync_open)
|
|
147
|
+
self.assertEqual(screen.mode, 'text')
|
|
148
|
+
self.assertIn('café 日本語 😀', screen.visible())
|
|
149
|
+
self.assertEqual(screen.queries, [b'\x1b[1;1R', b'\x1b[?1;2c'])
|
|
150
|
+
|
|
151
|
+
def test_backlogged_paint_is_consumed_and_all_queries_answered_before_snapshot(self):
|
|
152
|
+
screen = PROBE.Screen()
|
|
153
|
+
screen.feed(b'\x1b[?2026hSearch sessions and projects\x1b[6')
|
|
154
|
+
chunks = [b'n\x1b[', b'c\x1b[2Jshift+tab agents ctrl+p commands\x1b[?2026', b'l']
|
|
155
|
+
child = Mock(returncode=None)
|
|
156
|
+
child.poll.return_value = None
|
|
157
|
+
replies = []
|
|
158
|
+
# select times out once mid-frame: no partial snapshot may escape.
|
|
159
|
+
with patch.object(PROBE.select, 'select', side_effect=[([], [], []),
|
|
160
|
+
([123], [], []), ([123], [], []), ([123], [], []), ([], [], [])]), \
|
|
161
|
+
patch.object(PROBE.os, 'read', side_effect=chunks):
|
|
162
|
+
PROBE.drain_terminal_snapshot(123, screen, time.monotonic() + 2,
|
|
163
|
+
screen.feed, replies.append, child)
|
|
164
|
+
self.assertFalse(screen.sync_open)
|
|
165
|
+
self.assertNotIn('sessions and projects', screen.visible())
|
|
166
|
+
self.assertIn('ctrl+p commands', screen.visible())
|
|
167
|
+
self.assertEqual(replies, [b'\x1b[1;1R', b'\x1b[?1;2c'])
|
|
168
|
+
self.assertEqual(screen.queries, [])
|
|
169
|
+
|
|
170
|
+
def test_unfinished_frame_and_continuous_backlog_obey_existing_deadline(self):
|
|
171
|
+
for incomplete in (False, True):
|
|
172
|
+
with self.subTest(incomplete=incomplete):
|
|
173
|
+
screen = PROBE.Screen()
|
|
174
|
+
if incomplete:
|
|
175
|
+
screen.feed(b'\x1b[?2026h')
|
|
176
|
+
child = Mock(returncode=None)
|
|
177
|
+
child.poll.return_value = None
|
|
178
|
+
with patch.object(PROBE.time, 'monotonic', side_effect=[1, 2, 3]), \
|
|
179
|
+
patch.object(PROBE.select, 'select', return_value=([123], [], [])), \
|
|
180
|
+
patch.object(PROBE.os, 'read', return_value=b'x'):
|
|
181
|
+
with self.assertRaisesRegex(TimeoutError, 'existing input deadline'):
|
|
182
|
+
PROBE.drain_terminal_snapshot(123, screen, 3, screen.feed, Mock(), child)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@unittest.skipUnless(PTY_AVAILABLE, 'requires POSIX probe imports')
|
|
186
|
+
class PickerSequencingTests(unittest.TestCase):
|
|
187
|
+
def test_pending_acceptance_prevents_resize_and_paste_with_stale_home_footer(self):
|
|
188
|
+
# Execute the actual prompt phase from main, not a duplicate predicate.
|
|
189
|
+
# A slow startup (>5s) leaves the home footer visible behind the picker
|
|
190
|
+
# in the same iteration that Escape marks its acceptance PENDING.
|
|
191
|
+
tree = ast.parse(MODULE_PATH.read_text(encoding='utf8'))
|
|
192
|
+
phases = [node for node in ast.walk(tree) if isinstance(node, ast.If) and
|
|
193
|
+
isinstance(node.test, ast.BoolOp) and
|
|
194
|
+
[ast.unparse(value) for value in node.test.values[:2]] ==
|
|
195
|
+
['not sent', 'not picker_only']]
|
|
196
|
+
self.assertEqual(len(phases), 1)
|
|
197
|
+
phase = compile(ast.Module(body=phases, type_ignores=[]), str(MODULE_PATH), 'exec')
|
|
198
|
+
for status in ('PENDING', 'PASS', 'FAIL', 'UNSUPPORTED'):
|
|
199
|
+
with self.subTest(status=status):
|
|
200
|
+
screen = PROBE.Screen()
|
|
201
|
+
screen.feed(b'shift+tab agents ctrl+p commands')
|
|
202
|
+
payload = ('native-pty: café 日本語 😀\n' + 'large-paste-λ-' * 1200 +
|
|
203
|
+
'\nEND-PASTE-終').encode('utf8')
|
|
204
|
+
write_input = Mock()
|
|
205
|
+
namespace = dict(sent=False, picker_only=False, focused_case=False,
|
|
206
|
+
picker_cancelled=True, elapsed=6, home_ready=True,
|
|
207
|
+
result={'checks': {'pickerEscapeCancel': {'status': status}}},
|
|
208
|
+
args=Mock(focused_host_roundtrip=False), layout_only=False,
|
|
209
|
+
resize_case=True, screen=screen, output=bytearray(),
|
|
210
|
+
master=123, child=Mock(pid=456), payload=payload,
|
|
211
|
+
write_input=write_input, fcntl=Mock(), termios=PROBE.termios,
|
|
212
|
+
struct=PROBE.struct, os=Mock(), signal=PROBE.signal,
|
|
213
|
+
time=Mock())
|
|
214
|
+
exec(phase, namespace)
|
|
215
|
+
if status == 'PENDING':
|
|
216
|
+
namespace['fcntl'].ioctl.assert_not_called()
|
|
217
|
+
namespace['os'].kill.assert_not_called()
|
|
218
|
+
write_input.assert_not_called()
|
|
219
|
+
self.assertEqual((screen.rows, screen.columns), (32, 100))
|
|
220
|
+
self.assertIn('ctrl+p commands', screen.visible())
|
|
221
|
+
self.assertFalse(namespace['sent'])
|
|
222
|
+
else:
|
|
223
|
+
namespace['fcntl'].ioctl.assert_called_once()
|
|
224
|
+
namespace['os'].kill.assert_called_once_with(456, PROBE.signal.SIGWINCH)
|
|
225
|
+
write_input.assert_called_once_with(b'\x1b[200~' + payload + b'\x1b[201~')
|
|
226
|
+
self.assertEqual(len(payload), 18047)
|
|
227
|
+
self.assertEqual((screen.rows, screen.columns), (12, 40))
|
|
228
|
+
self.assertTrue(namespace['sent'])
|
|
229
|
+
self.assertEqual(namespace['result']['checks']['pickerEscapeCancel']['status'], status)
|
|
230
|
+
|
|
231
|
+
if __name__ == '__main__':
|
|
232
|
+
unittest.main()
|