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
4 changes: 2 additions & 2 deletions .claude/skills/iterating-on-voltius-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ docker compose -f compose.headless.yml logs tauri-headless # wait for the driv

Ready signals: log shows `Joined session keyring`, and `claude mcp list` shows
`tauri-docker` connected. First build from a cold cache takes minutes; a warm cache
(`target/` is host-mounted) finishes in seconds. If the MCP is registered but failing,
(the `voltius-target` volume at `/target`) finishes in seconds. If the MCP is registered but failing,
the cause is almost always that the container isn't up yet — bring it up and retry.

Register the MCP if absent:
`claude mcp add tauri-docker -- docker exec -i tauri-headless npx -y github:VoltiusApp/mcp-tauri-automation`

## Loop

1. `launch_app` `appPath=/app/target/debug/voltius` (check `get_app_state` first).
1. `launch_app` `appPath=/target/debug/voltius` (check `get_app_state` first).
2. Interact: `click_element`, `type_text` (`clear:true` to overwrite; `\n` sends Enter),
`press_key` (Enter, arrows, chords like `["Control","l"]`), `wait_for_element`.
3. `capture_screenshot` with `returnBase64:false` → saves to `/app/screenshots/<name>.png`
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/testing-voltius-team-features-e2e/wd.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ try {
// (kept for reference); set VOLTIUS_KEYCHAIN_NS at `docker run -e ...` instead.
const env = {};
if (a[0]) env.VOLTIUS_KEYCHAIN_NS = a[0];
const r = await j('POST', '/session', { capabilities:{ alwaysMatch:{ 'tauri:options':{ application:'/app/target/debug/voltius', env } } } });
const r = await j('POST', '/session', { capabilities:{ alwaysMatch:{ 'tauri:options':{ application: process.env.VOLTIUS_APP_BIN || '/target/debug/voltius', env } } } });
const s = r.d && r.d.value && r.d.value.sessionId;
if (!s) { console.log('FAIL '+JSON.stringify(r.d).slice(0,300)); process.exit(1); }
writeFileSync(SIDF, s); await j('POST', `/session/${s}/timeouts`, { implicit: 6000 });
Expand Down
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Dockerfile.tauri-headless has no COPY or ADD — the repo arrives as a bind
# mount at run time, not baked into the image. Without this, every build tars
# the whole worktree, which carries a multi-gigabyte cargo target/ and
# node_modules and takes minutes before the first instruction runs.
*
56 changes: 47 additions & 9 deletions Dockerfile.tauri-headless
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive

# The container writes into the bind-mounted worktree (pnpm install, and any
# cargo output that still lands there). Running as root left ~30k root-owned
# files the host user could not clean up, so the build user is created with the
# host's uid/gid:
# docker build -f Dockerfile.tauri-headless \
# --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t tauri-mcp .
ARG UID=1000
ARG GID=1000

# System dependencies: Xvfb + WebKit + compilers + mold (fast linker) + keyutils
# (the app's OS keychain talks to the kernel keyutils keyring at startup) + ffmpeg
# (records the Xvfb framebuffer for headless UI screen-capture / video) + xdotool
Expand All @@ -25,14 +34,28 @@ RUN apt-get update && apt-get install -y \
python3-pil \
&& rm -rf /var/lib/apt/lists/*

# ubuntu:24.04 ships a stock `ubuntu` user at 1000:1000, which collides with the
# common host uid. Drop it first so the requested ids are always free.
RUN userdel -r ubuntu 2>/dev/null || true; \
groupadd -g "${GID}" builder 2>/dev/null || true; \
useradd -m -u "${UID}" -g "${GID}" -s /bin/bash builder

# Install Node.js and enable pnpm
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
npm install -g pnpm

# Install Rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
# Rust lives outside root's home so the build user can reach it, and cargo's
# output lives OUTSIDE the bind mount at /app. A target dir anywhere under
# vite's root makes vite's watcher crawl it — the dev server climbs to ~15GB RSS
# and the webview never loads, which presents as every WebDriver call hanging
# with no error. Mount a host dir at /target to keep the cache across rebuilds.
ENV CARGO_HOME=/opt/cargo \
RUSTUP_HOME=/opt/rustup \
CARGO_TARGET_DIR=/target \
PATH=/opt/cargo/bin:${PATH}

RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path

# Install tauri-driver
RUN cargo install tauri-driver
Expand All @@ -41,12 +64,15 @@ RUN cargo install tauri-driver
# RUSTFLAGS env) keeps the build fingerprint constant so the target/ cache is
# reused across rebuilds. The host triple is detected at build time so this
# works on any arch (arm64 / x86_64) without hardcoding.
RUN mkdir -p /root/.cargo && \
TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" && \
RUN TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" && \
printf '[target.%s]\nrustflags = ["-C", "link-arg=-fuse-ld=mold"]\n' "$TRIPLE" \
> /root/.cargo/config.toml
> "${CARGO_HOME}/config.toml"

RUN mkdir -p /target /app && \
chown -R "${UID}:${GID}" /opt/cargo /opt/rustup /target /app

WORKDIR /app
USER builder

# Dev / hot-reload workflow:
# 1. install JS deps
Expand All @@ -63,16 +89,28 @@ CMD bash -c "CI=true pnpm install && \
(pnpm dev > /tmp/vite.log 2>&1 &) && \
keyctl session - xvfb-run --auto-servernum tauri-driver --port 4444"

# docker build -f Dockerfile.tauri-headless -t tauri-mcp .
# docker build -f Dockerfile.tauri-headless \
# --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t tauri-mcp .
#
# IMPORTANT: --security-opt seccomp=unconfined is REQUIRED. The app's OS keychain
# uses the kernel keyutils keyring; the default Docker seccomp profile blocks
# those syscalls, which makes keychain_get fail and the app hang on its splash.
# docker run -d --name tauri-headless --security-opt seccomp=unconfined -v "$(pwd):/app" tauri-mcp
# docker run -d --name tauri-headless --security-opt seccomp=unconfined \
# -v "$(pwd):/app" -v voltius-node-modules:/app/node_modules \
# -v voltius-target:/target tauri-mcp
#
# node_modules MUST be a volume, not shared with the host: pnpm records an
# absolute storeDir in node_modules/.modules.yaml, and a host command that then
# sees the container's path decides the tree is foreign and asks to purge it —
# which fails outside a TTY. Recovery on the host: `CI=true pnpm install`.
#
# docker logs -f tauri-headless
# claude mcp add tauri-docker -- docker exec -i tauri-headless npx -y github:VoltiusApp/mcp-tauri-automation
#
# Then, in Claude Code, drive the live dev build via the MCP:
# launch_app appPath=/app/target/debug/voltius
# launch_app appPath=/target/debug/voltius
# capture_screenshot / click_element / type_text / ...
#
# NEVER run cargo against this repo mounted anywhere but /app: the absolute path
# is baked into the target dir's fingerprints, and a later build at /app then
# fails to read plugin permissions from the stale path.
21 changes: 21 additions & 0 deletions compose.headless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ services:
build:
context: .
dockerfile: Dockerfile.tauri-headless
args:
# Matches the container's build user to the host's, so pnpm install and
# cargo stop leaving root-owned files in the mounted worktree. Export
# UID/GID before `up` if your host ids are not 1000.
UID: ${UID:-1000}
GID: ${GID:-1000}
image: tauri-mcp
container_name: tauri-headless
security_opt:
Expand All @@ -38,6 +44,17 @@ services:
- WEBKIT_DISABLE_COMPOSITING_MODE=1
volumes:
- .:/app
# The container gets its own node_modules. Sharing the host's poisons it:
# pnpm records an absolute `storeDir` in node_modules/.modules.yaml, the
# container's differs from the host's, and the next host command decides
# the tree is foreign and asks to purge it — which fails outside a TTY,
# so the plugin-bundle tests start failing with a bare "Command failed".
# (Recovery if it happens anyway: `CI=true pnpm install` on the host.)
- voltius-node-modules:/app/node_modules
# CARGO_TARGET_DIR. Deliberately not inside /app: vite's watcher crawls a
# target dir under its root, climbs to ~15GB RSS, and the webview then
# never loads — which presents as every WebDriver call hanging.
- voltius-target:/target
networks:
- voltius-test

Expand Down Expand Up @@ -68,3 +85,7 @@ services:
networks:
voltius-test:
driver: bridge

volumes:
voltius-target:
voltius-node-modules:
61 changes: 39 additions & 22 deletions src/components/settings/sections/AccountSection.handle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,33 @@ afterEach(() => {
vi.clearAllMocks();
});

test("a free account sees its generated handle, a copy button and the upsell — no claim form", async () => {
h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", allow_stranger_invites: true });
test("a free but verified account is offered the claim control — no tier gate", async () => {
h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true });
render(<AccountSection />);
expect(await screen.findByText("@swift-otter-4821")).toBeTruthy();
expect(screen.getByText("settings.account.handle.upsell")).toBeTruthy();
expect(screen.queryByRole("button", { name: "settings.account.handle.save" })).toBeNull();
const choose = screen.getByRole("button", { name: "settings.account.handle.choose" });
expect(choose.hasAttribute("disabled")).toBe(false);
expect(screen.getByText("settings.account.handle.chooseSub")).toBeTruthy();
expect(screen.queryByText("settings.account.handle.unverified")).toBeNull();
});

test("a pro account can claim and the taken case is explained, not swallowed", async () => {
h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", allow_stranger_invites: true });
test("an unverified account sees the control disabled with the reason, not hidden", async () => {
h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", email_verified: false, allow_stranger_invites: true });
render(<AccountSection />);
const choose = await screen.findByRole("button", { name: "settings.account.handle.choose" });
expect(choose.hasAttribute("disabled")).toBe(true);
expect(screen.getByText("settings.account.handle.unverified")).toBeTruthy();
});

test("the copy explains what a custom handle does rather than naming a tier", async () => {
h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true });
render(<AccountSection />);
await screen.findByText("settings.account.handle.chooseSub");
expect(screen.getByText("settings.account.handle.generatedNote")).toBeTruthy();
});

test("a verified account can claim and the taken case is explained, not swallowed", async () => {
h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true });
h.claimHandle.mockRejectedValue(new HandleClaimError(409));
render(<AccountSection />);
await userEvent.click(await screen.findByRole("button", { name: "settings.account.handle.choose" }));
Expand All @@ -80,14 +97,14 @@ test("a pro account can claim and the taken case is explained, not swallowed", a
});

test("the stranger-invite toggle persists", async () => {
h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true });
h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true });
render(<AccountSection />);
await userEvent.click(await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" }));
expect(h.updateInvitePreferences).toHaveBeenCalledWith(false);
});

test("the stranger-invite toggle reverts on failure", async () => {
h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true });
h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true });
h.updateInvitePreferences.mockRejectedValueOnce(new Error("network error"));
render(<AccountSection />);
const toggle = await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" });
Expand All @@ -96,10 +113,10 @@ test("the stranger-invite toggle reverts on failure", async () => {
expect(toggle.getAttribute("aria-checked")).toBe("true");
});

test("a pro account sees distinct copy for each claim-failure status", async () => {
h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true });
test("distinct copy for each claim-failure status", async () => {
h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true });
const cases: [number, string][] = [
[402, "settings.account.handle.errorTierRequired"],
[403, "settings.account.handle.errorEmailNotVerified"],
[422, "settings.account.handle.errorInvalid"],
[429, "settings.account.handle.errorCooldown"],
];
Expand All @@ -114,30 +131,30 @@ test("a pro account sees distinct copy for each claim-failure status", async ()
}
});

test("a lapsed pro user keeps the custom-handle message, not the upsell", async () => {
h.getMe.mockResolvedValue({ handle: "kevin-p", handle_is_custom: true, tier: "free", allow_stranger_invites: true });
test("a lapsed account can still rename its custom handle", async () => {
h.getMe.mockResolvedValue({ handle: "kevin-p", handle_is_custom: true, tier: "free", email_verified: true, allow_stranger_invites: true });
render(<AccountSection />);
await screen.findByText("@kevin-p");
expect(screen.getByText("settings.account.handle.lapsedKeepsHandle")).toBeTruthy();
expect(screen.queryByText("settings.account.handle.upsell")).toBeNull();
const change = screen.getByRole("button", { name: "settings.account.handle.change" });
expect(change.hasAttribute("disabled")).toBe(false);
});

test("no upsell flash before the tier is known", async () => {
test("no verify-your-email flash before /auth/me answers", async () => {
let resolveMe!: (v: MeResponse) => void;
h.getMe.mockReturnValue(new Promise<MeResponse>((resolve) => { resolveMe = resolve; }));
render(<AccountSection />);
// Wait for mode ("server") to resolve and the handle block to mount, while
// getMe (and so the tier) is still pending — this is the exact window a
// paying user would otherwise see the free-tier upsell flash in.
// Wait for mode ("server") to resolve and the handle block to mount while
// getMe (and so email_verified) is still pending — the exact window a
// verified user would otherwise see the "verify your email" line flash in.
await screen.findByText("settings.account.handle.title");
expect(screen.queryByText("settings.account.handle.upsell")).toBeNull();
expect(screen.queryByText("settings.account.handle.unverified")).toBeNull();
expect(screen.queryByRole("button", { name: "settings.account.handle.choose" })).toBeNull();
resolveMe({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", allow_stranger_invites: true });
resolveMe({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true });
expect(await screen.findByRole("button", { name: "settings.account.handle.choose" })).toBeTruthy();
});

test("the stranger-invite toggle disables itself mid-flight so a second click can't race it", async () => {
h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true });
h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true });
let resolveUpdate!: () => void;
h.updateInvitePreferences.mockReturnValue(new Promise<void>((resolve) => { resolveUpdate = resolve; }));
render(<AccountSection />);
Expand Down
Loading