Merge branch 'main' into review-issue-48

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 19:48:21 +02:00
committed by GitHub
50 changed files with 5012 additions and 459 deletions
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { buildApp } from "./app.js";
describe("PI WEB status routes", () => {
it("forces a fresh status load when refresh is requested", async () => {
const get = vi.fn(() => Promise.resolve(status("cached")));
const refresh = vi.fn(() => Promise.resolve(status("forced")));
const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false });
try {
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
const forcedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
expect(cachedResponse.json<PiWebStatusResponse>().generatedAt).toBe("cached");
expect(forcedResponse.json<PiWebStatusResponse>().generatedAt).toBe("forced");
expect(get).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledWith({ force: true });
} finally {
await app.close();
}
});
});
function status(generatedAt: string): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
};
}
+5
View File
@@ -24,6 +24,11 @@ describe("buildApp PI WEB plugin routes", () => {
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.body).toBe("export default {};");
const svgResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/assets/icon.svg" });
expect(svgResponse.statusCode).toBe(200);
expect(svgResponse.headers["content-type"]).toContain("image/svg+xml");
expect(svgResponse.body).toContain("<svg");
const missingResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" });
expect(missingResponse.statusCode).toBe(404);
});
+17
View File
@@ -25,6 +25,23 @@ describe("buildApp remote machine proxy routes", () => {
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("preserves the force-refresh query when proxying update checks", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ ok: true })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web/status?refresh=1` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("GET", "/api/pi-web/status?refresh=1", undefined);
});
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
+8 -1
View File
@@ -100,7 +100,7 @@ export function registerAppTestHooks(): void {
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
readAsset: fakePiWebPluginAsset,
},
clientDist: false,
logger: false,
@@ -123,6 +123,13 @@ export function registerAppTestHooks(): void {
});
}
function fakePiWebPluginAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
if (pluginId !== "fake") return Promise.resolve(undefined);
if (assetPath === "plugin.js") return Promise.resolve({ content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" });
if (assetPath === "assets/icon.svg") return Promise.resolve({ content: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'), contentType: "image/svg+xml" });
return Promise.resolve(undefined);
}
export interface CapturedSessionDaemonRequest {
method: string;
path: string;
+9 -5
View File
@@ -23,7 +23,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachin
import { PiWebPluginService } from "./piWebPluginService.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
@@ -38,6 +38,7 @@ export interface AppDependencies {
sessionDaemon?: SessionProxyDaemon;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
piPackages?: PiPackageService;
piWebStatusCache?: PiWebStatusCache;
config?: PiWebConfigService;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -136,9 +137,10 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
const configService = deps.config ?? createFilePiWebConfigService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
});
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }),
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
);
const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon),
});
@@ -153,7 +155,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content);
});
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
? piWebStatusCache.refresh({ force: true })
: piWebStatusCache.get());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
+111 -2
View File
@@ -39,20 +39,24 @@ describe("Docker command assets", () => {
execUtf8("sh", ["-n", dockerEntrypoint], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "dev", "compose")], process.env),
execUtf8("bash", ["-n", join(repoRoot, "docker", "internal", "dev", "sync-node-modules")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "host-profile.sh")], process.env),
]);
});
it("packages the canonical Docker command and internal support assets", async () => {
const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dockerignore] = await Promise.all([
const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dependencySync, dockerignore] = await Promise.all([
readRepoFile("docker/Dockerfile"),
readRepoFile("docker/Dockerfile.dev"),
readRepoFile("docker/compose.yml"),
readRepoFile("docker/compose.dev.yml"),
readRepoFile("docker/install.sh"),
readRepoFile("docker/internal/dev/compose"),
readRepoFile("docker/internal/dev/sync-node-modules"),
readRepoFile("docker/.dockerignore"),
]);
const customImageHooksIndex = devDockerfile.indexOf("for script in /tmp/pi-web-custom-image.d/*.sh");
const dependencyGenerationIndex = devDockerfile.indexOf("/opt/pi-web-dev-dependencies/generation");
expect(dockerfile).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker");
expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec");
@@ -62,6 +66,12 @@ describe("Docker command assets", () => {
expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@");
expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker");
expect(devDockerfile).toContain("COPY docker/internal/bin/hostexec /usr/local/bin/hostexec");
expect(devDockerfile).toContain("COPY --chmod=0755 docker/internal/dev/sync-node-modules /usr/local/sbin/pi-web-dev-sync-node-modules");
expect(devDockerfile).toContain("/opt/pi-web-dev-dependencies/node_modules");
// Hooks can mutate the dependency seed, so its cache generation must be finalized afterward.
expect(customImageHooksIndex).toBeGreaterThanOrEqual(0);
expect(dependencyGenerationIndex).toBeGreaterThan(customImageHooksIndex);
expect(dependencySync).toContain(".pi-web-dev-dependency-generation");
expect(dockerignore).toContain("!pi-web-docker");
expect(dockerignore).toContain("!internal/bin/hostexec");
expect(installer).toContain("write_asset pi-web-docker 0755");
@@ -83,6 +93,8 @@ describe("Docker command assets", () => {
expect(devCompose).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}");
expect(devCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}");
expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}");
expect(devCompose).toContain("/usr/local/sbin/pi-web-dev-sync-node-modules");
expect(devCompose.match(/volumes: \*pi-web-dev-volumes/g)).toHaveLength(3);
});
dockerCommandIt("fetches remote installer assets without clobbering the write target", async () => {
@@ -247,6 +259,84 @@ describe("Docker command assets", () => {
expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n");
});
dockerCommandIt("refuses development updates when the checkout has uncommitted files", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await writeFile(join(devRoot, "staged.txt"), "changed\n", "utf8");
await execUtf8("git", ["-C", devRoot, "add", "staged.txt"], cleanProcessEnv());
await writeFile(join(devRoot, "modified.txt"), "changed\n", "utf8");
await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8");
const result = await runDockerCommandAllowFailure(
["--dev", "update"],
devHostEnv(fakeDocker, devRoot, join(tempDir, "home")),
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("refusing to update the Docker development stack because the checkout has uncommitted changes");
expect(result.stderr).toContain("staged.txt");
expect(result.stderr).toContain("modified.txt");
expect(result.stderr).toContain("?? untracked.txt");
expect(result.stderr).toContain("commit, stash, or remove these changes");
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("refuses dirty development updates before scheduling a detached helper", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8");
const result = await runDockerCommandAllowFailure(["--dev", "update"], devRuntimeEnv(fakeDocker, devRoot));
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("checkout has uncommitted changes");
expect(result.stdout).not.toContain("Started detached PI WEB Docker helper");
await expect(readFile(fakeDocker.logPath, "utf8")).rejects.toThrow();
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("refuses development updates while a Git operation is in progress", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
const head = (await execUtf8("git", ["-C", devRoot, "rev-parse", "HEAD"], cleanProcessEnv())).stdout.trim();
await writeFile(join(devRoot, ".git", "MERGE_HEAD"), `${head}\n`, "utf8");
const result = await runDockerCommandAllowFailure(
["--dev", "update"],
devHostEnv(fakeDocker, devRoot, join(tempDir, "home")),
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("while a Git merge is in progress");
expect(result.stderr).toContain("resolve or abort the Git merge");
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("allows clean development updates and dirty development starts", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
const env = devHostEnv(fakeDocker, devRoot, join(tempDir, "home"));
await runDockerCommand(["--dev", "update"], env);
await writeFile(join(devRoot, "in-progress-work.txt"), "dirty by design\n", "utf8");
await runDockerCommand(["--dev", "start"], env);
expect(await readFile(helperLog, "utf8")).toBe([
"allow=0 args=build --pull",
"allow=0 args=up -d --force-recreate --remove-orphans",
"allow=0 args=up -d --build",
"",
].join("\n"));
});
dockerCommandIt("starts development detached helpers as the generated dev user", async () => {
const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 });
const fakeDocker = await installFakeDocker();
@@ -431,12 +521,31 @@ async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise<stri
await mkdir(dirname(helperPath), { recursive: true });
await writeFile(helperPath, `#!/usr/bin/env sh
set -eu
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >${shellSingleQuote(logPath)}
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >>${shellSingleQuote(logPath)}
`, "utf8");
await chmod(helperPath, 0o755);
return devRoot;
}
async function createCleanDevGitRepoWithFakeHelper(logPath: string): Promise<string> {
const devRoot = await createDevRepoFixtureWithFakeHelper(logPath);
await Promise.all([
writeFile(join(devRoot, "staged.txt"), "clean\n", "utf8"),
writeFile(join(devRoot, "modified.txt"), "clean\n", "utf8"),
]);
const env = cleanProcessEnv();
await execUtf8("git", ["init", "--quiet", devRoot], env);
await execUtf8("git", ["-C", devRoot, "add", "."], env);
await execUtf8("git", [
"-C", devRoot,
"-c", "user.name=PI WEB Test",
"-c", "[email protected]",
"-c", "core.hooksPath=/dev/null",
"commit", "--quiet", "--no-gpg-sign", "-m", "test fixture",
], env);
return devRoot;
}
async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise<string> {
const devRoot = join(tempDir, "dev-runtime");
await mkdir(join(devRoot, ".pi-web"), { recursive: true });
+102
View File
@@ -0,0 +1,102 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const syncScript = join(repoRoot, "docker", "internal", "dev", "sync-node-modules");
const dockerSyncIt = it.skipIf(process.platform === "win32");
let tempDir = "";
interface SyncFixture {
workspaceDir: string;
seedDir: string;
targetDir: string;
}
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-dependencies-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("Docker development dependency synchronization", () => {
dockerSyncIt("replaces a stale dependency tree once per image generation", async () => {
const fixture = await createSyncFixture();
const first = await runSync(fixture);
expect(first.exitCode).toBe(0);
expect(first.stderr).toContain("Synchronizing PI WEB Docker dev dependencies");
expect(await readFile(join(fixture.targetDir, "fresh", "version.txt"), "utf8")).toBe("0.80.6\n");
expect(await readlink(join(fixture.targetDir, ".bin", "fresh"))).toBe("../fresh/version.txt");
expect(await readFile(join(fixture.targetDir, ".pi-web-dev-dependency-generation"), "utf8")).toBe("image-generation-2\n");
await expect(readFile(join(fixture.targetDir, "stale.txt"), "utf8")).rejects.toThrow();
await writeFile(join(fixture.targetDir, "keep-on-current-generation.txt"), "kept\n", "utf8");
const second = await runSync(fixture);
expect(second.exitCode).toBe(0);
expect(second.stderr).toContain("dependencies are current");
expect(await readFile(join(fixture.targetDir, "keep-on-current-generation.txt"), "utf8")).toBe("kept\n");
});
dockerSyncIt("fails without changing the volume when the image manifests are stale", async () => {
const fixture = await createSyncFixture();
await writeFile(join(fixture.workspaceDir, "package-lock.json"), '{"lockfileVersion":3,"changed":true}\n', "utf8");
const result = await runSync(fixture);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("development image dependencies do not match the checkout");
expect(await readFile(join(fixture.targetDir, "stale.txt"), "utf8")).toBe("stale\n");
});
});
async function createSyncFixture(): Promise<SyncFixture> {
const workspaceDir = join(tempDir, "workspace");
const seedDir = join(tempDir, "seed");
const targetDir = join(workspaceDir, "node_modules");
const packageJson = '{"name":"dependency-sync-fixture","private":true}\n';
const packageLock = '{"name":"dependency-sync-fixture","lockfileVersion":3}\n';
await Promise.all([
mkdir(join(seedDir, "node_modules", "fresh"), { recursive: true }),
mkdir(join(seedDir, "node_modules", ".bin"), { recursive: true }),
mkdir(targetDir, { recursive: true }),
]);
await Promise.all([
writeFile(join(workspaceDir, "package.json"), packageJson, "utf8"),
writeFile(join(workspaceDir, "package-lock.json"), packageLock, "utf8"),
writeFile(join(seedDir, "package.json"), packageJson, "utf8"),
writeFile(join(seedDir, "package-lock.json"), packageLock, "utf8"),
writeFile(join(seedDir, "generation"), "image-generation-2\n", "utf8"),
writeFile(join(seedDir, "node_modules", "fresh", "version.txt"), "0.80.6\n", "utf8"),
writeFile(join(targetDir, "stale.txt"), "stale\n", "utf8"),
writeFile(join(targetDir, ".pi-web-dev-dependency-generation"), "image-generation-1\n", "utf8"),
]);
await symlink("../fresh/version.txt", join(seedDir, "node_modules", ".bin", "fresh"));
return { workspaceDir, seedDir, targetDir };
}
function runSync(fixture: SyncFixture): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolvePromise) => {
execFile("bash", [syncScript], {
encoding: "utf8",
env: {
...process.env,
PI_WEB_DEV_WORKSPACE_DIR: fixture.workspaceDir,
PI_WEB_DEV_DEPENDENCY_SEED_DIR: fixture.seedDir,
},
}, (error, stdout, stderr) => {
const exitCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" ? error.code : 0;
resolvePromise({ stdout, stderr, exitCode });
});
});
}
+38
View File
@@ -44,6 +44,44 @@ describe("PiWebPluginService", () => {
expect(asset?.content.toString("utf8")).toContain("export default");
});
it("preserves content types for extension-only asset names", async () => {
const pluginDir = join(tempDir, "plugins", "extension-only");
await writePlugin(pluginDir, {
packageJson: { piWeb: { plugins: [{ id: "extension-only", module: ".js" }] } },
files: {
".js": "export default {};",
".svg": '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
},
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
await expect(service.readAsset("extension-only", ".js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" });
await expect(service.readAsset("extension-only", ".svg")).resolves.toMatchObject({ contentType: "image/svg+xml" });
});
it("serves nested SVG assets with a browser-compatible content type", async () => {
const pluginDir = join(tempDir, "plugins", "icons");
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>';
await writePlugin(pluginDir, {
packageJson: { piWeb: { plugins: [{ id: "icons", module: "pi-web-plugin.js" }] } },
files: {
"pi-web-plugin.js": "export default {};",
"assets/icon.svg": svg,
"assets/uppercase.SVG": svg,
"assets/data.bin": "unknown",
},
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const svgAsset = await service.readAsset("icons", "assets/icon.svg");
expect(svgAsset?.contentType).toBe("image/svg+xml");
expect(svgAsset?.content.toString("utf8")).toBe(svg);
await expect(service.readAsset("icons", "assets/uppercase.SVG")).resolves.toMatchObject({ contentType: "image/svg+xml" });
await expect(service.readAsset("icons", "assets/data.bin")).resolves.toMatchObject({ contentType: "application/octet-stream" });
});
it("includes machine-specific preferences in plugin manifests", async () => {
await writePlugin(join(tempDir, "plugins", "updates"), {
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
+6 -4
View File
@@ -335,10 +335,12 @@ function isWithin(root: string, candidate: string): boolean {
}
function contentTypeFor(path: string): string {
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
if (path.endsWith(".json")) return "application/json; charset=utf-8";
if (path.endsWith(".css")) return "text/css; charset=utf-8";
if (path.endsWith(".html")) return "text/html; charset=utf-8";
const lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".js")) return "application/javascript; charset=utf-8";
if (lowerPath.endsWith(".json")) return "application/json; charset=utf-8";
if (lowerPath.endsWith(".css")) return "text/css; charset=utf-8";
if (lowerPath.endsWith(".html")) return "text/html; charset=utf-8";
if (lowerPath.endsWith(".svg")) return "image/svg+xml";
return "application/octet-stream";
}
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { createPiWebReleaseLookupCache } from "./piWebReleaseLookupCache.js";
describe("createPiWebReleaseLookupCache", () => {
it("serves a fresh cached release lookup", async () => {
let now = 1_000;
const load = vi.fn(() => Promise.resolve("1.0.0"));
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
now = 1_050;
await expect(cache.get("0.9.1")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
expect(load).toHaveBeenCalledOnce();
expect(load).toHaveBeenCalledWith("0.9.0");
});
it("bypasses a fresh lookup when forced", async () => {
let now = 1_000;
const load = vi.fn()
.mockResolvedValueOnce("1.0.0")
.mockResolvedValueOnce("1.1.0");
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
await cache.get("0.9.0");
now = 1_050;
await expect(cache.get("0.9.0", { force: true })).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
expect(load).toHaveBeenCalledTimes(2);
});
it.each(["forced-first", "regular-first"] as const)("does not let an older regular lookup replace a forced result when %s completes", async (completionOrder) => {
const regular = createDeferred<string>();
const forced = createDeferred<string>();
const load = vi.fn()
.mockImplementationOnce(() => regular.promise)
.mockImplementationOnce(() => forced.promise);
const cache = createPiWebReleaseLookupCache(load);
const regularLookup = cache.get("0.9.0");
const forcedLookup = cache.get("0.9.0", { force: true });
if (completionOrder === "forced-first") {
forced.resolve("2.0.0");
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
regular.resolve("1.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
} else {
regular.resolve("1.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
forced.resolve("2.0.0");
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
}
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "2.0.0" });
expect(load).toHaveBeenCalledTimes(2);
});
it("makes regular callers join a pending forced lookup", async () => {
const forced = createDeferred<string>();
const load = vi.fn(() => forced.promise);
const cache = createPiWebReleaseLookupCache(load);
const forcedLookup = cache.get("0.9.0", { force: true });
const regularLookup = cache.get("0.9.0");
expect(regularLookup).toBe(forcedLookup);
forced.resolve("2.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
expect(load).toHaveBeenCalledOnce();
});
});
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
+57
View File
@@ -0,0 +1,57 @@
const DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
export interface PiWebReleaseLookup {
checkedAtMs: number;
latestVersion?: string;
error?: string;
}
export interface PiWebReleaseLookupCacheOptions {
ttlMs?: number;
now?: () => number;
}
export interface PiWebReleaseLookupOptions {
force?: boolean;
}
export interface PiWebReleaseLookupCache {
get(currentVersion: string, options?: PiWebReleaseLookupOptions): Promise<PiWebReleaseLookup>;
}
export function createPiWebReleaseLookupCache(
load: (currentVersion: string) => Promise<string>,
options: PiWebReleaseLookupCacheOptions = {},
): PiWebReleaseLookupCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: PiWebReleaseLookup | undefined;
let pending: { promise: Promise<PiWebReleaseLookup>; force: boolean; sequence: number } | undefined;
let loadSequence = 0;
return {
get(currentVersion: string, lookupOptions: PiWebReleaseLookupOptions = {}): Promise<PiWebReleaseLookup> {
const force = lookupOptions.force === true;
if (pending?.force === true) return pending.promise;
const checkedAtMs = now();
if (!force && cached !== undefined && checkedAtMs - cached.checkedAtMs < ttlMs) return Promise.resolve(cached);
if (!force && pending !== undefined) return pending.promise;
const sequence = ++loadSequence;
const promise = Promise.resolve()
.then(() => load(currentVersion))
.then((latestVersion): PiWebReleaseLookup => ({ checkedAtMs, latestVersion }))
.catch((error: unknown): PiWebReleaseLookup => ({ checkedAtMs, error: error instanceof Error ? error.message : String(error) }))
.then((lookup) => {
if (sequence === loadSequence) cached = lookup;
return lookup;
})
.finally(() => {
if (pending?.sequence === sequence) pending = undefined;
});
pending = { promise, force, sequence };
return promise;
},
};
}
+32 -1
View File
@@ -69,6 +69,33 @@ describe("PI WEB status", () => {
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
});
it("bypasses cached npm release data for a forced check", async () => {
Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK");
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(npmVersionResponse("1.202607.1"))
.mockResolvedValueOnce(npmVersionResponse("1.202607.2"));
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202607.0",
installedVersion: "1.202607.0",
stale: false,
available: true,
installation: { kind: "docker", dockerMode: "runtime" },
});
const first = await getPiWebStatus(daemon, { forceReleaseCheck: true });
const cached = await getPiWebStatus(daemon);
const forced = await getPiWebStatus(daemon, { forceReleaseCheck: true });
expect(first.release.latestVersion).toBe("1.202607.1");
expect(cached.release.latestVersion).toBe("1.202607.1");
expect(forced.release.latestVersion).toBe("1.202607.2");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
@@ -82,7 +109,7 @@ describe("PI WEB status", () => {
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
});
const status = await getPiWebStatus(daemon);
const status = await getPiWebStatus(daemon, { forceReleaseCheck: true });
expect(status.release.skipped).toBe(true);
expect(status.components.sessiond.stale).toBe(true);
@@ -192,6 +219,10 @@ describe("PI WEB status", () => {
});
});
function npmVersionResponse(version: string): Response {
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
}
function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
+10 -16
View File
@@ -11,11 +11,11 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
const DEFAULT_VERSION = "0.0.0-dev";
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
const VERSION_CHECK_TIMEOUT_MS = 5000;
type ServiceId = "sessiond" | "web" | "uiDev";
@@ -74,8 +74,11 @@ interface PiWebStatusDaemon {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
}
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
export interface PiWebStatusOptions {
forceReleaseCheck?: boolean;
}
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
const runtimePackageInfo = readPackageInfoSync();
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
@@ -129,10 +132,10 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
};
}
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
const versionStatus = await getPiWebVersionStatus(daemon);
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond };
const commands = await commandsFor(components);
const messages = buildMessages(components, release, commands);
@@ -375,25 +378,16 @@ function unavailableSessiond(error: string): PiWebComponentStatus {
};
}
async function getLatestReleaseStatus(currentVersion: string): Promise<PiWebReleaseStatus> {
async function getLatestReleaseStatus(currentVersion: string, force: boolean): Promise<PiWebReleaseStatus> {
const checkedAtMs = Date.now();
if (skipVersionCheck()) {
return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true };
}
if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) {
return releaseStatusFromCache(latestReleaseCache, currentVersion);
}
try {
latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) };
} catch (error) {
latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) };
}
return releaseStatusFromCache(latestReleaseCache, currentVersion);
return releaseStatusFromCache(await latestReleaseLookupCache.get(currentVersion, { force }), currentVersion);
}
function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus {
function releaseStatusFromCache(cache: PiWebReleaseLookup, currentVersion: string): PiWebReleaseStatus {
return {
packageName: PI_WEB_PACKAGE_NAME,
...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }),
+40 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { createPiWebStatusCache, type PiWebStatusCacheLoadOptions } from "./piWebStatusCache.js";
describe("createPiWebStatusCache", () => {
it("serves cached status while it is fresh", async () => {
@@ -46,6 +46,45 @@ describe("createPiWebStatusCache", () => {
expect(load).toHaveBeenCalledTimes(2);
});
it.each(["forced-first", "regular-first"] as const)("does not let an older refresh replace a forced result when %s completes", async (completionOrder) => {
const regular = createDeferred<PiWebStatusResponse>();
const forced = createDeferred<PiWebStatusResponse>();
const load = vi.fn(({ force }: PiWebStatusCacheLoadOptions) => force ? forced.promise : regular.promise);
const cache = createPiWebStatusCache(load);
const regularRefresh = cache.refresh();
const forcedRefresh = cache.refresh({ force: true });
if (completionOrder === "forced-first") {
forced.resolve(status("forced"));
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
regular.resolve(status("regular"));
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
} else {
regular.resolve(status("regular"));
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
forced.resolve(status("forced"));
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
}
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "forced" });
expect(load).toHaveBeenNthCalledWith(1, { force: false });
expect(load).toHaveBeenNthCalledWith(2, { force: true });
});
it("makes regular refreshes join a pending forced refresh", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
const cache = createPiWebStatusCache(load);
const forced = cache.refresh({ force: true });
const regular = cache.refresh();
expect(regular).toBe(forced);
deferred.resolve(status("forced"));
await forced;
expect(load).toHaveBeenCalledOnce();
});
it("retains stale status and reports background refresh errors", async () => {
let now = 1_000;
const refreshError = new Error("refresh failed");
+25 -11
View File
@@ -8,28 +8,42 @@ export interface PiWebStatusCacheOptions {
onError?: (error: unknown) => void;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(): Promise<PiWebStatusResponse>;
export interface PiWebStatusCacheLoadOptions {
force: boolean;
}
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
export interface PiWebStatusCacheRefreshOptions {
force?: boolean;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
}
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
let pending: Promise<PiWebStatusResponse> | undefined;
let pending: { promise: Promise<PiWebStatusResponse>; force: boolean; sequence: number } | undefined;
let loadSequence = 0;
const refresh = (): Promise<PiWebStatusResponse> => {
pending ??= Promise.resolve()
.then(load)
const refresh = (refreshOptions: PiWebStatusCacheRefreshOptions = {}): Promise<PiWebStatusResponse> => {
const force = refreshOptions.force === true;
if (pending !== undefined && (!force || pending.force)) return pending.promise;
const sequence = ++loadSequence;
const promise = Promise.resolve()
.then(() => load({ force }))
.then((status) => {
cached = { status, expiresAt: now() + ttlMs };
if (sequence === loadSequence) cached = { status, expiresAt: now() + ttlMs };
return status;
})
.finally(() => {
pending = undefined;
if (pending?.sequence === sequence) pending = undefined;
});
return pending;
pending = { promise, force, sequence };
return promise;
};
return {