Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 115 additions & 4 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { describe, expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, expect, it } from "@effect/vitest";
import {
ProjectId,
TerminalNotRunningError,
type TerminalEvent,
type TerminalSessionSnapshot,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import { TestClock } from "effect/testing";

import * as ServerSettings from "../serverSettings.ts";
Expand Down Expand Up @@ -164,15 +168,15 @@ describe("ProjectSetupScriptRunner", () => {

yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" });
yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" });
expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
expect(yield* Queue.take(writes)).toContain("vp i");
}),
);

it.effect("types into a quiet shell that has no line editor", () =>
Effect.gen(function* () {
const { writes } = yield* setup();
yield* TestClock.adjust("3 seconds");
expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
expect(yield* Queue.take(writes)).toContain("vp i");
}),
);

Expand All @@ -194,7 +198,7 @@ describe("ProjectSetupScriptRunner", () => {
yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") });
// The restart lands inside a quiet window, so the next full window decides.
yield* TestClock.adjust("6 seconds");
expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
expect(yield* Queue.take(writes)).toContain("vp i");
}),
);

Expand Down Expand Up @@ -227,3 +231,110 @@ describe("ProjectSetupScriptRunner", () => {
}),
);
});

const runObservedSetup = Effect.fnUntraced(function* (command: string) {
const { writes, outputLines, emit, completion } = yield* setup({ command });
yield* emit({ ...event, type: "output", data: "\x1b[?2004h" });
const submitted = yield* Queue.take(writes);
const cwd = yield* HostProcess.WorkingDirectory;
const env = yield* HostProcess.Environment;

// Enter becomes a newline at the terminal boundary; transport assertions below
// cover line-editor submissions and canonical TTY limits that bash -c bypasses.
const child = yield* Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const handle = yield* spawner.spawn(
ChildProcess.make(
"/bin/bash",
["--noprofile", "--norc", "-c", submitted.replaceAll("\r", "\n")],
{
cwd,
env: { PATH: env.PATH },
},
),
);
return yield* Effect.all(
{
stdout: handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
stderr: handle.stderr.pipe(Stream.decodeText(), Stream.mkString),
exitCode: handle.exitCode,
},
{ concurrency: "unbounded" },
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
yield* emit({ ...event, type: "output", data: submitted + "\n" });
yield* emit({ ...event, type: "output", data: child.stdout + child.stderr });
yield* emit({ ...event, type: "closed" });
return {
submitted,
lines: yield* Queue.clear(outputLines),
exitCode: (yield* completion).exitCode,
};
});

describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("POSIX setup completion", () => {
it.live("submits one line while preserving multiline shell syntax and literal text", () =>
Effect.gen(function* () {
const result = yield* runObservedSetup(
[
"printf '%s\\n' \"it's ready\" 'literal \\n $(printf wrong) `printf wrong` café'",
"cat <<'END'",
"$literal \\path 雪",
"END",
"printf '%s\\n' done # trailing comment",
].join("\r\n"),
);
assert.equal(result.submitted.at(-1), "\r");
assert.notMatch(result.submitted.slice(0, -1), /[\r\n]/);
assert.deepEqual(result.lines, [
"it's ready",
"literal \\n $(printf wrong) `printf wrong` café",
"$literal \\path 雪",
"done",
]);
assert.equal(result.exitCode, 0);
}),
);

it.live("completes long scripts without exceeding canonical terminal line limits", () =>
Effect.gen(function* () {
const expected = Array.from(
{ length: 120 },
(_, index) => `line-${index}: café 雪 'quoted' \\path`,
);
const command = expected
.map((line) => `printf '%s\\n' "${line.replaceAll("\\", "\\\\")}"`)
.join("\n");
const utf8 = new TextEncoder();
assert.isAbove(utf8.encode(command).byteLength, 4_096);

const result = yield* runObservedSetup(command);
assert.deepEqual(result.lines, expected);
assert.equal(result.exitCode, 0);
assert.equal(result.submitted.at(-1), "\r");
assert.notInclude(result.submitted, "\n");
// The runner can write before the shell switches the terminal out of canonical mode.
for (const line of result.submitted.slice(0, -1).split("\r")) {
assert.isBelow(utf8.encode(line).byteLength, 1_024);
}
}),
);

it.live("preserves a trailing backslash-newline even when losing it would still exit zero", () =>
Effect.gen(function* () {
const result = yield* runObservedSetup("printf '%s\\n' direct\\\n");
assert.deepEqual(result.lines, ["direct"]);
assert.equal(result.exitCode, 0);
}),
);

it.live.each([
{ name: "an explicit exit", command: "exit 7", exitCode: 7 },
{ name: "a syntax error", command: "if then", exitCode: 2 },
])("reports a nonzero completion for $name", ({ command, exitCode }) =>
Effect.gen(function* () {
const result = yield* runObservedSetup(command);
assert.equal(result.exitCode, exitCode);
}),
);
});
54 changes: 52 additions & 2 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,57 @@ function resolveCompletionShell(shell: string): CompletionShell | null {
return "posix";
}

// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin.
const utf8 = new TextEncoder();
// Canonical TTY input (before a line editor starts reading) caps a line below 1 KiB on Linux.
const POSIX_MAX_LINE_BYTES = 1_023;
const POSIX_APPEND = '__t3_command="$__t3_command"';

function encodePosixScript(text: string): string {
let encoded = "";
for (const character of text) {
const code = character.charCodeAt(0);
if (character === "'") {
encoded += "'\\''";
} else if (character === "\\" || code <= 0x1f || code === 0x7f) {
encoded += `\\0${code.toString(8).padStart(3, "0")}`;
} else {
encoded += character;
}
}
return encoded;
}

// Line editors run each submitted line on its own, so every line must be a complete command.
function wrapPosixCommand(command: string, sentinel: string): string {
const normalized = command.replace(/\r\n/g, "\n");
// The `_` suffix keeps trailing newlines; the subshell contains exits and syntax errors.
const evaluate = (payload: string) =>
`( __t3_command=$(printf '%b_' ${payload}); eval "\${__t3_command%_}" ); printf '\\n${sentinel}%s\\n' "$?"`;
const inline = evaluate(`'${encodePosixScript(normalized)}'`);
if (utf8.encode(` ${inline}\r`).byteLength <= POSIX_MAX_LINE_BYTES) return inline;

const maxChunkBytes = POSIX_MAX_LINE_BYTES - utf8.encode(` ${POSIX_APPEND}''\r`).byteLength;
const lines: string[] = [];
let prefix = "__t3_command=";
let chunk = "";
let chunkBytes = 0;
for (const character of normalized) {
const encoded = encodePosixScript(character);
const bytes = utf8.encode(encoded).byteLength;
if (chunkBytes + bytes > maxChunkBytes) {
lines.push(`${prefix}'${chunk}'`);
prefix = POSIX_APPEND;
chunk = "";
chunkBytes = 0;
}
chunk += encoded;
chunkBytes += bytes;
}
lines.push(`${prefix}'${chunk}'`, `${evaluate('"$__t3_command"')}; unset __t3_command`);
// The leading space gives every line the history suppression a short command gets.
return lines.join("\r ");
}

function wrapCommandForCompletion(
command: string,
shell: CompletionShell,
Expand All @@ -204,7 +254,7 @@ function wrapCommandForCompletion(
].join("; ");
}
case "posix":
return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`;
return wrapPosixCommand(command, sentinel);
}
}

Expand Down
Loading