镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
191 changes: 190 additions & 1 deletion apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { assert, it, vi } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it, vi } from "@effect/vitest";
import { ProjectId } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

import * as TerminalManager from "../terminal/Manager.ts";
import * as ServerSettings from "../serverSettings.ts";
Expand Down Expand Up @@ -179,3 +183,188 @@ it.effect("resolves setup scripts through the standalone project service", () =>
});
}).pipe(Effect.provide(layer));
});

const runObservedSetup = Effect.fnUntraced(function* (command: string, stdin = "") {
let submitted = "";
let listener: Parameters<TerminalManager.TerminalManager["Service"]["subscribe"]>[0];
const lines: string[] = [];
const cwd = yield* HostProcess.WorkingDirectory;
const env = yield* HostProcess.Environment;
const layer = ProjectSetupScriptRunner.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(TerminalManager.TerminalManager)({
open: (input) =>
Effect.succeed({
threadId: input.threadId,
terminalId: input.terminalId,
cwd: input.cwd,
worktreePath: input.worktreePath ?? null,
status: "running" as const,
pid: 123,
history: "",
exitCode: null,
exitSignal: null,
label: "Shell",
updatedAt: "2026-06-20T00:00:00.000Z",
}),
subscribe: (callback) =>
Effect.sync(() => {
listener = callback;
return () => undefined;
}),
write: ({ data }) =>
Effect.sync(() => {
submitted = data;
}),
closeIdle: () => Effect.void,
}),
ServerSettings.layerTest(),
NodeCrypto.layer,
Layer.succeed(HostProcess.Platform, "linux"),
Layer.succeed(HostProcess.Environment, { SHELL: "/bin/bash" }),
),
),
);
const result = yield* Effect.gen(function* () {
const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
return yield* runner.runForThread({
threadId: "thread-setup",
worktreePath: cwd,
project: {
id: ProjectId.make("project-setup"),
workspaceRoot: cwd,
scripts: [
{
id: "setup",
name: "Setup",
command,
icon: "configure",
runOnWorktreeCreate: true,
},
],
},
observeCompletion: {
onOutputLine: (line) =>
Effect.sync(() => {
lines.push(line);
}),
},
});
}).pipe(Effect.provide(layer));
assert.equal(result.status, "started");
if (result.status !== "started" || !result.completion) return yield* Effect.die("no completion");

// 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 },
stdin: Stream.make(new TextEncoder().encode(stdin)),
},
),
);
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));
const terminal = { threadId: "thread-setup", terminalId: result.terminalId };
yield* listener!({ type: "output", ...terminal, data: `${submitted}\n` });
yield* listener!({ type: "output", ...terminal, data: child.stdout + child.stderr });
// Process completion releases the observer even when a broken wrapper emits no sentinel.
yield* listener!({ type: "closed", ...terminal });
const completion = yield* result.completion;
return { submitted, lines, exitCode: completion.exitCode };
});

describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("POSIX setup completion", () => {
it.effect("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.effect("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.effect("leaves stdin available to the script without consuming completion commands", () =>
Effect.gen(function* () {
const result = yield* runObservedSetup(
"read -r value\nprintf '%s\\n' \"$value\"",
"from stdin \\path\n",
);
assert.deepEqual(result.lines, ["from stdin \\path"]);
assert.equal(result.exitCode, 0);
}),
);

it.effect(
"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.effect.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);
}),
);
});
56 changes: 48 additions & 8 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,6 @@ function resolveCompletionShell(
return "posix";
}

/**
* The command runs inside a block and the block closes on its own line, so a trailing `# comment`
* or a heredoc terminator in the command cannot swallow the sentinel. The shell reads the whole
* block before running any of it, so a script that reads stdin cannot consume the sentinel line
* either.
*/
function wrapCommandForCompletion(
command: string,
shell: CompletionShell,
Expand All @@ -175,8 +169,54 @@ function wrapCommandForCompletion(
return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`;
case "fish":
return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`;
case "posix":
return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`;
case "posix": {
const normalized = command.replace(/\r\n/g, "\n");
const encode = (text: 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;
};
// The suffix preserves 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(`'${encode(normalized)}'`);
const utf8 = new TextEncoder();
const maxLineBytes = 1_023;
if (utf8.encode(` ${inline}\r`).byteLength <= maxLineBytes) return inline;

// Before readline starts, canonical TTY limits apply. Line editors also need each
// submission to be complete, so accumulate encoded text with standalone assignments.
const append = '__t3_command="$__t3_command"';
const maxChunkBytes = maxLineBytes - utf8.encode(` ${append}''\r`).byteLength;
const lines: string[] = [];
let prefix = "__t3_command=";
let chunk = "";
let chunkBytes = 0;
for (const character of normalized) {
const encoded = encode(character);
const bytes = utf8.encode(encoded).byteLength;
if (chunkBytes + bytes > maxChunkBytes) {
lines.push(`${prefix}'${chunk}'`);
prefix = append;
chunk = "";
chunkBytes = 0;
}
chunk += encoded;
chunkBytes += bytes;
}
lines.push(`${prefix}'${chunk}'`, `${evaluate('"$__t3_command"')}; unset __t3_command`);
// Each physical line gets the same history suppression as a short command.
return lines.join("\r ");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}

Expand Down
Loading