Files
Triple-C/app/src/hooks/useProjectStatusEvents.test.ts
T
shadowdaoandClaude Opus 5.5 eba0d95780
Secret Scan / scan (push) Successful in 7s
Build App (Preview) / compute-version (pull_request) Successful in 12s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / test (pull_request) Successful in 4m45s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 3m16s
Build App (Preview) / build-linux (pull_request) Successful in 5m36s
Build App (Preview) / build-windows (pull_request) Failing after 6m38s
Build App (Preview) / prune-previews (pull_request) Skipped
Build Container / build-container (pull_request) Successful in 23m46s
Keep containers alive as root; notice containers that exit on their own
The entrypoint ended in `exec su -s /bin/bash claude -c "exec sleep
infinity"`, so a project container's whole lifetime was a sleep owned by
the unprivileged claude user. Claude Code runs as claude and routinely runs
`pkill sleep`, `pkill -f`, `kill -9 -1` while testing, and any of those
stopped the container: every terminal disconnected and the app kept
showing Running.

Entrypoint: stay resident as root and wait on a root-owned sleep run as
`triple-c-keepalive infinity`, respawned if killed, with a TERM/INT/HUP
trap so `docker stop` returns at once (550 ms, exit 0, vs 2.6 s / 143
before). Unprivileged kills now get EPERM; `sudo pkill sleep` is survived
by the respawn. The drop to claude dated from the initial commit with no
stated purpose; its only reader was the marketplace readiness probe, which
now accepts either generation of entrypoint.

App: container_watch.rs holds one Docker event subscription filtered
daemon-side to container `die`. A die naming a Running project's container
is confirmed with an inspect, then ProjectsStore::mark_exited flips it to
Stopped as a compare-and-set that skips any project a command holds the
project lock on. Each (re)subscription re-checks Running projects and asks
for events since just before it. The frontend applies the new
`project-status-changed` event and toasts why the sessions died.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:57:12 -08:00

49 lines
1.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useAppState } from "../store/appState";
import type { Project } from "../lib/types";
let handler: ((e: { payload: unknown }) => void) | null = null;
const unlisten = vi.fn();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => {
if (name === "project-status-changed") handler = cb;
return unlisten;
}),
}));
import { useProjectStatusEvents } from "./useProjectStatusEvents";
const project = (over: Partial<Project>): Project =>
({ id: "p1", name: "demo", status: "running", container_id: "c1", ...over }) as Project;
describe("useProjectStatusEvents", () => {
beforeEach(() => {
handler = null;
unlisten.mockClear();
useAppState.setState({ projects: [project({}), project({ id: "p2", name: "other" })], toasts: [] });
});
it("replaces the project the backend reports, so a dead container stops reading Running", async () => {
renderHook(() => useProjectStatusEvents());
await vi.waitFor(() => expect(handler).not.toBeNull());
handler!({ payload: project({ status: "stopped" }) });
const { projects, toasts } = useAppState.getState();
expect(projects.find((p) => p.id === "p1")?.status).toBe("stopped");
expect(projects.find((p) => p.id === "p2")?.status).toBe("running");
// The terminals just went dead with no explanation; say why.
expect(toasts).toHaveLength(1);
expect(toasts[0].message).toContain("demo");
expect(toasts[0].dedupeKey).toBe("container-exited-p1");
});
it("stops listening on unmount", async () => {
const { unmount } = renderHook(() => useProjectStatusEvents());
await vi.waitFor(() => expect(handler).not.toBeNull());
unmount();
expect(unlisten).toHaveBeenCalled();
});
});