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
81 changes: 81 additions & 0 deletions src/app/routes/AddVault.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { AddVault } from "@/app/routes/AddVault";
import { useVaultStore } from "@/lib/vault/store";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const validMetadata = {
issuer: "http://localhost:1940",
authorization_endpoint: "http://localhost:1940/oauth/authorize",
token_endpoint: "http://localhost:1940/oauth/token",
registration_endpoint: "http://localhost:1940/oauth/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
grant_types_supported: ["authorization_code"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["full", "read"],
};

function mockFetchOnce(response: {
ok?: boolean;
status?: number;
json?: unknown;
throwNetwork?: boolean;
}) {
const impl = vi.fn<typeof fetch>(async () => {
if (response.throwNetwork) throw new Error("network down");
return {
ok: response.ok ?? true,
status: response.status ?? 200,
json: async () => response.json,
text: async () => "",
} as Response;
});
vi.stubGlobal("fetch", impl);
return impl;
}

function renderAddVault(initialPath = "/add") {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/add" element={<AddVault />} />
</Routes>
</MemoryRouter>,
);
}

describe("AddVault URL prefill", () => {
beforeEach(() => {
useVaultStore.setState({ vaults: {}, activeVaultId: null });
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
useVaultStore.setState({ vaults: {}, activeVaultId: null });
});

it("prefills the URL input from ?url= regardless of probe outcome", async () => {
mockFetchOnce({ throwNetwork: true });
renderAddVault("/add?url=http%3A%2F%2Fvault.example%3A1940");
const input = screen.getByLabelText(/vault url/i) as HTMLInputElement;
expect(input.value).toBe("http://vault.example:1940");
});

it("prefills the URL input with the detected origin when the probe succeeds", async () => {
mockFetchOnce({ json: validMetadata });
renderAddVault();
const input = screen.getByLabelText(/vault url/i) as HTMLInputElement;
await waitFor(() => expect(input.value).toBe(window.location.origin));
});

it("leaves the URL input empty when the probe fails", async () => {
const fetchImpl = mockFetchOnce({ throwNetwork: true });
renderAddVault();
const input = screen.getByLabelText(/vault url/i) as HTMLInputElement;
// Wait for the probe to settle — fetchImpl should have been called.
await waitFor(() => expect(fetchImpl).toHaveBeenCalled());
expect(input.value).toBe("");
});
});
29 changes: 26 additions & 3 deletions src/app/routes/AddVault.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { beginOAuth, normalizeVaultUrl } from "@/lib/vault";
import { type FormEvent, useState } from "react";
import { beginOAuth, normalizeVaultUrl, useOriginVaultProbe } from "@/lib/vault";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";

export function AddVault() {
const [url, setUrl] = useState("");
const [searchParams] = useSearchParams();
const queryUrl = searchParams.get("url") ?? "";
const [url, setUrl] = useState(queryUrl);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const prefilled = useRef(queryUrl.length > 0);
const probe = useOriginVaultProbe();

// Auto-focus so the user can submit with Enter when the URL is pre-filled
// via ?url=... or the origin probe. Runs once on mount.
useEffect(() => {
inputRef.current?.focus();
}, []);

// If the probe resolves with a detected origin and the user hasn't typed
// anything, seed the input. Don't clobber a ?url= value or user input.
useEffect(() => {
if (prefilled.current) return;
if (probe.status === "found" && probe.origin && url === "") {
setUrl(probe.origin);
prefilled.current = true;
}
}, [probe.status, probe.origin, url]);

async function onSubmit(e: FormEvent) {
e.preventDefault();
Expand Down Expand Up @@ -42,6 +64,7 @@ export function AddVault() {
</label>
<input
id="vault-url"
ref={inputRef}
type="url"
required
placeholder="http://localhost:1940"
Expand Down
131 changes: 131 additions & 0 deletions src/app/routes/Home.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Home } from "@/app/routes/Home";
import { useVaultStore } from "@/lib/vault/store";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const validMetadata = {
issuer: "http://localhost:1940",
authorization_endpoint: "http://localhost:1940/oauth/authorize",
token_endpoint: "http://localhost:1940/oauth/token",
registration_endpoint: "http://localhost:1940/oauth/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
grant_types_supported: ["authorization_code"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["full", "read"],
};

function mockFetchOnce(response: {
ok?: boolean;
status?: number;
json?: unknown;
throwNetwork?: boolean;
}) {
const impl = vi.fn<typeof fetch>(async () => {
if (response.throwNetwork) throw new Error("network down");
return {
ok: response.ok ?? true,
status: response.status ?? 200,
json: async () => response.json,
text: async () => "",
} as Response;
});
vi.stubGlobal("fetch", impl);
return impl;
}

function renderHome() {
return render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/notes" element={<div>Notes</div>} />
<Route path="/add" element={<div>Add form</div>} />
</Routes>
</MemoryRouter>,
);
}

describe("Home landing probe", () => {
beforeEach(() => {
useVaultStore.setState({ vaults: {}, activeVaultId: null });
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
useVaultStore.setState({ vaults: {}, activeVaultId: null });
});

it("offers to connect to the detected origin when the probe succeeds", async () => {
mockFetchOnce({ json: validMetadata });
renderHome();

const connect = await screen.findByRole("link", { name: /^connect$/i });
expect(connect).toHaveAttribute(
"href",
`/add?url=${encodeURIComponent(window.location.origin)}`,
);
expect(screen.getByText(/looks like there's a vault at/i)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /or connect to a different vault/i })).toHaveAttribute(
"href",
"/add",
);
});

it("falls back silently to the default CTA on network error", async () => {
mockFetchOnce({ throwNetwork: true });
renderHome();

await waitFor(() =>
expect(screen.getByRole("link", { name: /^connect a vault$/i })).toBeInTheDocument(),
);
expect(screen.queryByText(/looks like there's a vault at/i)).not.toBeInTheDocument();
});

it("falls back silently on 404", async () => {
mockFetchOnce({ ok: false, status: 404 });
renderHome();

await waitFor(() =>
expect(screen.getByRole("link", { name: /^connect a vault$/i })).toBeInTheDocument(),
);
expect(screen.queryByText(/looks like there's a vault at/i)).not.toBeInTheDocument();
});

it("falls back silently when metadata is invalid", async () => {
mockFetchOnce({
json: { ...validMetadata, code_challenge_methods_supported: ["plain"] },
});
renderHome();

await waitFor(() =>
expect(screen.getByRole("link", { name: /^connect a vault$/i })).toBeInTheDocument(),
);
expect(screen.queryByText(/looks like there's a vault at/i)).not.toBeInTheDocument();
});

it("does not probe when vaults are already in storage", async () => {
const fetchImpl = mockFetchOnce({ json: validMetadata });
useVaultStore.setState({
vaults: {
existing: {
id: "existing",
url: "http://localhost:1940",
name: "default",
issuer: "http://localhost:1940",
clientId: "c",
scope: "full",
addedAt: "2026-04-18T00:00:00.000Z",
lastUsedAt: "2026-04-18T00:00:00.000Z",
},
},
activeVaultId: "existing",
});
renderHome();
// Active vault redirects to /notes.
await waitFor(() => expect(screen.getByText("Notes")).toBeInTheDocument());
expect(fetchImpl).not.toHaveBeenCalled();
});
});
45 changes: 35 additions & 10 deletions src/app/routes/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,54 @@
import { useVaultStore } from "@/lib/vault";
import { useOriginVaultProbe, useVaultStore } from "@/lib/vault";
import { Link, Navigate } from "react-router";

export function Home() {
const activeVault = useVaultStore((s) => s.getActiveVault());
const probe = useOriginVaultProbe();

if (activeVault) {
return <Navigate to="/notes" replace />;
}

const foundOrigin = probe.status === "found" ? probe.origin : null;

return (
<div className="mx-auto max-w-2xl px-6 py-20 text-center">
<p className="mb-8 font-serif text-xl italic text-fg-muted">
A lens onto any Parachute Vault.
</p>
<h1 className="mb-4 font-serif text-5xl tracking-tight">Lens</h1>
<p className="mb-10 text-fg-dim tracking-wide">
Point it at a vault. Sign in. Browse, edit, visualize.
</p>

<Link
to="/add"
className="inline-block rounded-md bg-accent px-6 py-3 text-sm font-medium text-white hover:bg-accent-hover"
>
Connect a vault
</Link>
{foundOrigin ? (
<>
<p className="mb-8 text-fg tracking-wide">
Looks like there's a vault at{" "}
<code className="rounded bg-bg/60 px-1.5 py-0.5 font-mono text-sm">{foundOrigin}</code>.
</p>
<Link
to={`/add?url=${encodeURIComponent(foundOrigin)}`}
className="inline-block rounded-md bg-accent px-6 py-3 text-sm font-medium text-white hover:bg-accent-hover"
>
Connect
</Link>
<div className="mt-4">
<Link to="/add" className="text-sm text-fg-dim hover:text-accent">
Or connect to a different vault
</Link>
</div>
</>
) : (
<>
<p className="mb-10 text-fg-dim tracking-wide">
Point it at a vault. Sign in. Browse, edit, visualize.
</p>
<Link
to="/add"
className="inline-block rounded-md bg-accent px-6 py-3 text-sm font-medium text-white hover:bg-accent-hover"
>
Connect a vault
</Link>
</>
)}
</div>
);
}
1 change: 1 addition & 0 deletions src/lib/vault/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./discovery";
export * from "./note-query";
export * from "./oauth";
export * from "./pkce";
export * from "./probe";
export * from "./queries";
export * from "./storage";
export * from "./store";
Expand Down
Loading