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
37 changes: 37 additions & 0 deletions apps/server/src/cli/invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,43 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
}),
);

it.effect("names the launcher symlink that started a downloaded runtime", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
// The install script's launcher names ~/.t3, which here links to a home elsewhere.
const executable = path.join(root, "data/t3/runtime/versions/1.0.0/t3");
const launcher = path.join(root, "bin/t3");
const copy = path.join(root, "bin/t3-copy");
yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
yield* fs.writeFileString(executable, "", { mode: 0o755 });
yield* fs.writeFileString(copy, "", { mode: 0o755 });
yield* fs.symlink(path.join(root, "data/t3"), path.join(root, ".t3"));
yield* fs.symlink(path.join(root, ".t3/runtime/versions/1.0.0/t3"), launcher);
// Earlier PATH entries hold a directory and a non-executable file named t3.
yield* fs.makeDirectory(path.join(root, "shadow-dir/t3"), { recursive: true });
yield* fs.makeDirectory(path.join(root, "shadow-file"));
yield* fs.writeFileString(path.join(root, "shadow-file/t3"), "");
const resolve = (invokedAs: string) =>
resolveServerInstallation.pipe(
Effect.provideService(HostProcess.ExecutablePath, executable),
Effect.provideService(HostProcess.IsExecutable, true),
Effect.provideService(HostProcess.InvokedAs, invokedAs),
Effect.provideService(HostProcess.WorkingDirectory, root),
Effect.provideService(HostProcess.Environment, { PATH: "shadow-dir:shadow-file:bin" }),
Effect.provideService(HostProcess.Platform, "linux"),
);
const baseDir = path.join(root, ".t3");

expect(yield* resolve("t3")).toEqual({ kind: "t3-launcher", launcher, baseDir });
expect(yield* resolve(launcher)).toEqual({ kind: "t3-launcher", launcher, baseDir });
expect(yield* resolve(executable)).toBeNull();
expect(yield* resolve(copy)).toBeNull();
}),
);

it.effect("leaves local, standalone, missing and unreadable installs unknown", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/cli/invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
import { resolveCommandPath } from "@t3tools/shared/shell";

import packageJson from "../../package.json" with { type: "json" };
import { resolveLauncherPath } from "../cloud/pinnedRuntime.ts";

const PINNED_RUNTIME_ENTRY = /^(.*)\/runtime\/versions\/[^/]+\/t3$/;

export type CliRunner = "npx" | "pnpm dlx" | "bunx";

Expand Down Expand Up @@ -67,6 +70,21 @@ export const resolveServerInstallation = Effect.gen(function* () {
const path = yield* Path.Path;
const platform = yield* HostProcess.Platform;
const entry = yield* fs.realPath(yield* resolveEntryPath);
const runtime = PINNED_RUNTIME_ENTRY.exec(entry);
if (runtime) {
const launcher = yield* resolveLauncherPath;
if (launcher === undefined) return null;
// Fails for a runtime started directly, as SSH launches do, or through a copy.
const link = yield* fs.readLink(launcher);
// Keep the home as the link spells it; `t3 update` only repoints links into that spelling.
const linked = PINNED_RUNTIME_ENTRY.exec(path.resolve(path.dirname(launcher), link));
if (!linked || (yield* fs.realPath(linked[1] || "/")) !== (runtime[1] || "/")) return null;
return {
kind: "t3-launcher",
launcher,
baseDir: linked[1] || "/",
} satisfies ServerInstallation;
}
const match = /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.mjs)$/.exec(entry);
if (!match) {
const runner = detectCliRunner(entry);
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/cli/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import {
generatedWindowsShimTarget,
isVersionExecutable,
pinnedRuntimeVersionsDir,
resolveLauncherPath,
} from "../cloud/pinnedRuntime.ts";
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
import * as CliService from "./service.ts";
import { findWindowsShim, resolveLauncherPath } from "./update.ts";
import { findWindowsShim } from "./update.ts";

export class CliUninstallError extends Schema.TaggedError<CliUninstallError>()(
"CliUninstallError",
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/cli/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ import { afterEach, vi } from "vite-plus/test";

import packageJson from "../../package.json" with { type: "json" };
import * as BootService from "../cloud/bootService.ts";
import { pinnedRuntimePaths, prunePinnedRuntimes } from "../cloud/pinnedRuntime.ts";
import {
pinnedRuntimePaths,
prunePinnedRuntimes,
resolveLauncherPath,
} from "../cloud/pinnedRuntime.ts";
import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
import * as ProcessRunner from "../processRunner.ts";
import {
repointLauncher,
resolveLauncherPath,
resolveMirrorVersion,
resolveNewestVersion,
runUpdate,
Expand Down Expand Up @@ -411,7 +414,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => {
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
const launcher = path.join(root, "bin/t3");
yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
yield* fs.writeFileString(launcher, "");
yield* fs.writeFileString(launcher, "", { mode: 0o755 });

const bare = yield* resolveLauncherPath.pipe(
Effect.provideService(HostProcess.InvokedAs, "t3"),
Expand Down
23 changes: 1 addition & 22 deletions apps/server/src/cli/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
pinnedRuntimeVersionsDir,
prunePinnedRuntimes,
recordPinnedRuntimeLauncher,
resolveLauncherPath,
windowsShimTarget,
} from "../cloud/pinnedRuntime.ts";
import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts";
Expand Down Expand Up @@ -194,28 +195,6 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function
return Option.some(input.launchedAs);
});

/** Node preserves argv0's spelling; resolve bare names on PATH to find the launcher symlink. */
export const resolveLauncherPath = Effect.gen(function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const invokedAs = yield* HostProcess.InvokedAs;
const cwd = yield* HostProcess.WorkingDirectory;
const environment = yield* HostProcess.Environment;
const platform = yield* HostProcess.Platform;
if (invokedAs.includes("/") || invokedAs.includes("\\")) {
return path.resolve(cwd, invokedAs);
}
const delimiter = platform === "win32" ? ";" : ":";
for (const directory of (environment["PATH"] ?? "").split(delimiter)) {
if (directory.length === 0) continue;
const candidate = path.join(directory, invokedAs);
if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
return candidate;
}
}
return undefined;
});

/** Windows executables see their own path, so find the installer shim by matching t3.cmd targets on PATH. */
export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* (
executablePath: string,
Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/cloud/pinnedRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,34 @@ export function launcherOwnsVersionsDir(
return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
}

/** Node preserves argv0's spelling; resolve bare names on PATH to find the launcher symlink. */
export const resolveLauncherPath = Effect.gen(function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const invokedAs = yield* HostProcess.InvokedAs;
const cwd = yield* HostProcess.WorkingDirectory;
const environment = yield* HostProcess.Environment;
const platform = yield* HostProcess.Platform;
if (invokedAs.includes("/") || invokedAs.includes("\\")) {
return path.resolve(cwd, invokedAs);
}
const delimiter = platform === "win32" ? ";" : ":";
for (const directory of (environment["PATH"] ?? "").split(delimiter)) {
if (directory.length === 0) continue;
const candidate = path.resolve(cwd, directory, invokedAs);
const info = yield* fs.stat(candidate).pipe(Effect.option);
// Like the shell, pass over directories and non-executable files named the same.
if (
Option.isSome(info) &&
info.value.type === "File" &&
(platform === "win32" || (info.value.mode & 0o111) !== 0)
) {
return candidate;
}
}
return undefined;
});

/**
* `t3 update` repoints the launcher it was started through, which may be on neither PATH nor the
* default bin dir. Unrecorded, the next prune would remove the runtime it now points at.
Expand Down
62 changes: 61 additions & 1 deletion apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ vi.mock("./ServerBrowserContexts.ts", () => ({
return context as unknown as BrowserContext;
}
async scratchPage() {
return makeEncoderPage() as unknown as Page;
return encoderPageOverride ?? (makeEncoderPage() as unknown as Page);
}
async connectDesktopPage(endpoint: string) {
const context = makeContext(undefined, desktopPageUrl);
Expand Down Expand Up @@ -377,6 +377,7 @@ function makeEncoderPage() {
}

const contexts: ReturnType<typeof makeContext>[] = [];
let encoderPageOverride: Page | undefined;
const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = [];
const contextRequests: Array<{ profileId: string; isolated: boolean }> = [];
let contextGate: PromiseWithResolvers<void> | null = null;
Expand Down Expand Up @@ -560,6 +561,7 @@ const openDesktopViewer = Effect.gen(function* () {

beforeEach(() => {
contexts.length = 0;
encoderPageOverride = undefined;
encoderStreams.length = 0;
contextRequests.length = 0;
contextGate = null;
Expand Down Expand Up @@ -936,6 +938,64 @@ it.live.each([
).pipe(Effect.provide(layer)),
);

it.live.each([{ truncated: false }, { truncated: true }])(
"saves a large recording as a binary download and removes its staged file (truncated: $truncated)",
({ truncated }) =>
Effect.scoped(
Effect.gen(function* () {
const { broker, tabId } = yield* ready;
const fs = yield* FileSystem.FileSystem;
const bytes = 8 * 1024 * 1024;
const payload = Buffer.alloc(bytes, 0x5a);
let stagedPath: string | undefined;
const download = Promise.withResolvers<{ saveAs: (path: string) => Promise<void> }>();
const sandbox = NodeVM.createContext({
__t3Recorder: {
frame: async () => true,
stop: async () => ({ mimeType: "video/webm", count: 1, bytes }),
chunk: async () => {
throw new Error("Large recording used Base64 export");
},
download: () =>
download.resolve({
saveAs: async (path) => {
stagedPath = path;
await NodeFSP.writeFile(path, truncated ? payload.subarray(1) : payload);
},
}),
},
});
encoderPageOverride = {
...makeContext().page,
evaluate: async (script: string | ((arg: unknown) => unknown), arg?: unknown) =>
typeof script === "function"
? NodeVM.runInContext(`(${script.toString()})`, sandbox)(arg)
: undefined,
waitForEvent: () => download.promise,
} as unknown as Page;

yield* broker.invoke({ scope, tabId, operation: "recordingStart", input: {} });
const stop = broker.invoke<{ path: string; sizeBytes: number }>({
scope,
tabId,
operation: "recordingStop",
input: {},
});
if (truncated) {
expect(yield* stop.pipe(Effect.flip)).toMatchObject({
_tag: "PreviewAutomationExecutionError",
});
} else {
const artifact = yield* stop;
expect(artifact.sizeBytes).toBe(bytes);
expect(Buffer.from(yield* fs.readFile(artifact.path)).equals(payload)).toBe(true);
}
expect(stagedPath).toBeDefined();
expect(yield* fs.exists(stagedPath!)).toBe(false);
}),
).pipe(Effect.provide(layer)),
);

it.live.each([
{ refreshFails: false, expected: "final frame" },
{ refreshFails: true, expected: "frame" },
Expand Down
Loading
Loading