Files
Triple-C/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx
T
shadow-testandClaude Opus 5 016de8f641
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 10m5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 4m31s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / build-windows (pull_request) Successful in 19m1s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Close the blockers from the fifth audit
Docs and disclosure. HOW-TO-USE.md's settings table still described the
pre-fix behaviour — and help_commands.rs fetches that file from GitHub
main at runtime, ahead of the embedded copy, so it would have reached
every user's Help dialog the moment this merged. The Config tab named
three settings that need a base-image update; there are four, and the
omitted one (Session recap) is the one that fails *without* the "won't
switch off" symptom the warning teaches. Both now also state the cost
nobody had written down: changing any of these recreates the container,
which commits a layer.

Two stale comments that told a reviewer the code was safe when it was
not. compute_claude_code_settings_fingerprint still claimed the
historical fingerprint is preserved so an upgrade cannot churn every
container — carried over from before the widening, false since the
format string changed. And capabilities/default.json, which is the
reviewed threat model of record, described a "Save to host…" action this
branch deletes.

Security and correctness. update_settings validated env vars and nothing
else, so the *global* default_ssh_key_path — the fallback for every
project without an override — took `/` and read-only bind-mounted the
host, which entrypoint.sh then copies into the home volume. classify_
mount_source ran canonicalize on the raw string, which resolves a
relative path against Triple-C's own cwd, so `.` and `..` were accepted
or refused depending on where the app was launched; the daemon then
refuses the mount and the project can never start. Its test passed only
because its examples did not exist under app/src-tauri.

bind_mount_exclusions still derived a path from every row while
project_path_mounts had learned to skip unmountable ones, so a legacy
row made /workspace/<name> ordinary container content that a migration
would then exclude from staging and destroy. The skip is also logged now
rather than silently dropping a folder.

The terminal's file-in path checked is_dir() but not file type, so a
dropped FIFO blocked forever with no timeout — and it is the only route
in now. The web terminal labelled sessions from a global set at request
time, so two quick opens swapped them; harmless until Shift+Enter became
type-dependent, at which point a mislabelled Claude session submitted a
half-written prompt. Opened now carries the type.

Every ~/.claude.json write goes through one atomic helper. The
awsAuthRefresh branches still truncated in place — the same corruption
the Shift+Enter block was fixed for twenty lines later, and its own
comment said so. Demonstrated: a failed write now leaves the original
byte-identical.

And the registration test I added yesterday could pass while the
property was false: an audit got five real unregistered commands past its
exact-string attribute match, and "exactly once" was in its name but not
its body. Mutation-checked against all six shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 18:45:11 -07:00

226 lines
10 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
import type { ClaudeCodeSettings } from "../../lib/types";
function renderEditor(
settings: ClaudeCodeSettings | null,
scope: "global" | "project" = "global",
) {
const onSave = vi.fn().mockResolvedValue(undefined);
render(
<ClaudeCodeSettingsEditor
scope={scope}
settings={settings}
disabled={false}
onSave={onSave}
/>,
);
return onSave;
}
describe("ClaudeCodeSettingsEditor", () => {
it("shows the two default-on settings as on for a project that never touched them", () => {
// Claude Code's session recap and fullscreen auto-scroll are both on by
// default, and the fields behind them store the *disabled* sense. A toggle
// rendered straight from the field would tell every existing user their
// recap is off.
renderEditor(null);
expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked();
});
it("stores the disabled sense when an inverted toggle is switched off", () => {
const onSave = renderEditor(null);
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ session_recap_disabled: true }),
);
});
it("collapses back to null once every setting is at its default again", () => {
// `null` is what tells the backend this project adds nothing over the
// global settings, so the round trip has to land exactly back on it.
const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true });
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenCalledWith(null);
});
it("offers the classic renderer as a choice distinct from automatic", () => {
// Leaving `tui` unset lets Claude Code pick; pinning "default" is a
// different, and previously unreachable, instruction.
const onSave = renderEditor(null);
const tui = screen.getByLabelText("TUI mode");
expect(
Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")),
).toEqual(["", "default", "fullscreen"]);
fireEvent.change(tui, { target: { value: "default" } });
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" }));
});
it("offers every effort level Claude Code accepts", () => {
// Verified against the shipped `claude` binary's own schema rather than
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
// checked externally — which is the whole weakness of this test. It can
// only prove the editor agrees with this list, never that the list is the
// one Claude Code reads. The same blind spot is why `effort` and
// `focusMode` were confidently wrong for months.
renderEditor(null);
expect(
Array.from(
screen.getByLabelText("Effort level").querySelectorAll("option"),
).map((o) => o.getAttribute("value")),
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
});
describe("project scope", () => {
it("offers Global as a third state so a project can decline to have an opinion", () => {
renderEditor(null, "project");
const focus = screen.getByLabelText("Focus mode");
expect(
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
).toEqual(["global", "off", "on"]);
expect((focus as HTMLSelectElement).value).toBe("global");
});
it("stores a deliberate false so the project can turn a global On back off", () => {
// The reason the field widened from boolean to boolean|null. Under the
// old merge there was no project value that could produce this.
const onSave = renderEditor(null, "project");
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ focus_mode: false }),
);
});
it("does not collapse a deliberate off to null", () => {
// `null` means inherit. Collapsing here would silently hand the setting
// straight back to the global value the user just overrode.
const onSave = renderEditor(null, "project");
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
expect(onSave).not.toHaveBeenCalledWith(null);
});
it("round-trips the inverted fields through the disabled sense", () => {
// Session recap stores `session_recap_disabled`, so choosing "off" has to
// store `true` and choosing "on" has to store `false`.
const onSave = renderEditor(null, "project");
const recap = screen.getByLabelText("Session recap");
fireEvent.change(recap, { target: { value: "off" } });
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ session_recap_disabled: true }),
);
fireEvent.change(recap, { target: { value: "on" } });
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ session_recap_disabled: false }),
);
});
it("shows a stored override rather than the inherited state", () => {
renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }, "project");
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe(
"off",
);
});
/**
* Auto-scroll is the second inverted field and had no project-scope test at
* all — every assertion above rides on `session_recap_disabled`, so a
* `BOOLEAN_FIELDS` entry that lost its `invert` flag would be caught for
* one of the two and pass silently for the other. It is stored as
* `auto_scroll_disabled`, so every value here reads back the other way up.
*/
describe("auto-scroll", () => {
const AUTO = "Auto-scroll";
it("starts on Global, which is not the same as on", () => {
// Claude Code scrolls by default, so an inheriting project *behaves*
// as on — but it has taken no position, and rendering it as "On" would
// make a later global change look like it had no effect.
renderEditor(null, "project");
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("global");
});
it("stores the disabled sense in both directions", () => {
const onSave = renderEditor(null, "project");
const auto = screen.getByLabelText(AUTO);
fireEvent.change(auto, { target: { value: "off" } });
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ auto_scroll_disabled: true }),
);
fireEvent.change(auto, { target: { value: "on" } });
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ auto_scroll_disabled: false }),
);
});
it("hands the setting back to the global level when Global is chosen", () => {
// Back to no opinion, and with nothing else set that collapses the
// whole object to `null` — the value that means "adds nothing over the
// global settings".
const onSave = renderEditor(
{ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true },
"project",
);
fireEvent.change(screen.getByLabelText(AUTO), { target: { value: "global" } });
expect(onSave).toHaveBeenCalledWith(null);
});
it("reads a stored override back the right way up", () => {
renderEditor({ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project");
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("off");
});
});
});
/**
* The inverted fields store a *deviation*, so a stored `false` is the one
* value that means "the user deliberately re-enabled the default". Nothing
* asserted it: every existing test drives the `true` (turned off) direction
* or the `null` (untouched) one, and both scopes would still read correctly
* if the inversion were dropped from the `false` branch alone.
*/
describe.each([
["session_recap_disabled", "Session recap"] as const,
["auto_scroll_disabled", "Auto-scroll"] as const,
])("a stored false on %s", (key, label) => {
it("reads as On at project scope, not as Off", () => {
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }, "project");
expect((screen.getByLabelText(label) as HTMLSelectElement).value).toBe("on");
});
it("reads as on at global scope, where the control is a switch", () => {
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false });
expect(screen.getByRole("switch", { name: label })).toBeChecked();
});
});
/**
* A settings object with nothing set at this level arrives as `{}`: the Rust
* struct skips serialising a field it has no value for, which is what keeps
* an older binary able to parse `projects.json` after a downgrade. It is also
* the exact shape a project stored before the fields were widened is read
* back as — every one of its `false`s meant "unset" — so reading absent as
* "off" would show a switch the user never touched as a deliberate choice.
*/
it("reads an absent field as Global rather than as Off", () => {
renderEditor({} as ClaudeCodeSettings, "project");
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
});
it("still collapses to null when an absent-field object is edited back", () => {
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
// Off and straight back on: the round trip has to land on `null`, or an
// untouched global stops being indistinguishable from one never opened.
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenLastCalledWith(null);
});
});