feat: online verification, node removals, and knowledge browser
CI / quality (push) Canceled after 0s

Adds claim-bound online evidence records and citation verification scripts,
node removal transport/adapter, retrieval benchmark and owner-review tooling,
GraphCanvas/SourceDetail knowledge browser UI, and the ingest handoff helper.
Bumps the agents/gewu pack to the online-verification branch.
This commit is contained in:
vasily committed 2026-10-16 20:02:29 +08:00
1 parent a3156f4e08
commit 78033bb830
83 files changed
+13476 -1051

No files matched your search

+1 -1
View File
@@ -77,7 +77,7 @@ function AccountsSectionComponent({ csrf, projects, onMessage, onError }: {
<AdminDialog open={addOpen} title="Add user" onClose={() => setAddOpen(false)} returnFocusRef={addButtonRef}>
<p className="muted-copy">Users need a password with at least 12 characters. They can change it after signing in.</p>
<form className="admin-form connect-form" onSubmit={create}>
<label>Username<input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\\-]*" placeholder="vasily" /></label>
<label>New username<input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\\-]*" placeholder="vasily" /></label>
<label>Initial password<input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} required minLength={12} maxLength={1024} /></label>
<label>Account role<select value={role} onChange={event => { setRole(event.target.value); setGrants([]); }}><option value="editor">Editor</option><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label>
<fieldset><legend>Project access</legend>{role === 'admin' ? <p>All projects</p> : projects.map(project => <label className="admin-check" key={project.project_id}><input type="checkbox" checked={grants.includes(project.project_id)} onChange={event => setGrants(event.target.checked ? [...grants, project.project_id] : grants.filter(id => id !== project.project_id))} />{project.title}</label>)}</fieldset>
+4 -3
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
export default function AccountLogin({ onLogin, busy, credentialLabel, onError }: {
export default function AccountLogin({ onLogin, busy, credentialLabel, onError, submitLabel = 'Sign in' }: {
onLogin: (session: { csrf_token: string }, passwordAccount: boolean, restored?: boolean) => Promise<void>;
busy: boolean;
credentialLabel: string;
onError: (message: string) => void;
submitLabel?: string;
}) {
const [checking, setChecking] = useState(true);
const callbacks = useRef({ onLogin, onError });
@@ -55,11 +56,11 @@ export default function AccountLogin({ onLogin, busy, credentialLabel, onError }
return <div>
<form className="connect-form" onSubmit={submit}>
{legacy ? <><label htmlFor="login-credential">{credentialLabel}</label><input id="login-credential" type="password" autoComplete="off" value={token} onChange={e => setToken(e.target.value)} required /></> : <>
{legacy ? <><label htmlFor="login-credential">{credentialLabel}</label><input id="login-credential" type="password" autoComplete="off" value={token} onChange={e => setToken(e.target.value)} required /></> : <>\
<label htmlFor="login-username">Username</label><input id="login-username" autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\-]*" />
<label htmlFor="login-password">Password</label><input id="login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required maxLength={1024} />
</>}
<button disabled={busy || working}>{working ? 'Signing in…' : legacy ? 'Connect' : 'Sign in'}</button>
<button disabled={busy || working}>{working ? 'Signing in…' : legacy ? 'Connect' : submitLabel}</button>
</form>
<button className="text-button" disabled={busy || working} onClick={() => { setLegacy(!legacy); setPassword(''); setToken(''); }}>{legacy ? 'Use username and password' : 'Use an existing credential'}</button>
</div>;
+49 -48
View File
@@ -1,69 +1,70 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import Admin from "./Admin";
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('signs in with a password, creates an owned agent key and changes the password', async () => {
it("signs in with a password, creates an owned agent key and changes the password", async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = [];
if (path === '/v1/users/session') result = { csrf_token: 'user-csrf' };
if (path === '/v1/projects') result = ['pilot'];
if (path === '/v1/me/agent-credentials' && init?.method === 'POST') result = { credential_id: 'agent:codex:fixture', token: 'agent-fixture-secret' };
if (path === '/v1/me/password') result = { status: 'password_changed' };
if (path === "/v1/users/session") result = { csrf_token: "user-csrf" };
if (path === "/v1/projects") result = ["pilot"];
if (path === "/v1/me/agent-credentials" && init?.method === "POST") result = { credential_id: "agent:codex:fixture", token: "agent-fixture-secret" };
if (path === "/v1/me/password") result = { status: "password_changed" };
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<Admin personal />);
await user.type(await screen.findByLabelText('Username'), 'alice');
await user.type(screen.getByLabelText('Password'), 'initial-password-fixture');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByRole('heading', { name: 'Add an agent' });
expect(fetchMock).toHaveBeenCalledWith('/v1/users/session', expect.objectContaining({
method: 'POST', credentials: 'same-origin', body: JSON.stringify({ username: 'alice', password: 'initial-password-fixture' }),
await user.type(await screen.findByLabelText("Username"), "alice");
await user.type(screen.getByLabelText("Password"), "initial-password-fixture");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await user.click(await screen.findByRole("button", { name: "Add agent key" }));
await screen.findByRole("heading", { name: "Add agent key" });
expect(fetchMock).toHaveBeenCalledWith("/v1/users/session", expect.objectContaining({
method: "POST", credentials: "same-origin", body: JSON.stringify({ username: "alice", password: "initial-password-fixture" }),
}));
expect(screen.queryByRole('heading', { name: 'User accounts' })).toBeNull();
await user.type(screen.getByLabelText('Agent name'), 'codex-laptop');
await user.click(screen.getByLabelText('pilot'));
await user.click(screen.getByRole('button', { name: 'Create agent key' }));
expect(await screen.findByLabelText('New bearer credential')).toHaveValue('agent-fixture-secret');
expect(fetchMock).toHaveBeenCalledWith('/v1/me/agent-credentials', expect.objectContaining({
headers: expect.objectContaining({ 'X-GEWU-CSRF': 'user-csrf' }),
body: JSON.stringify({ name: 'codex-laptop', project_ids: ['pilot'] }),
expect(screen.queryByRole("heading", { name: "User accounts" })).toBeNull();
await user.type(screen.getByLabelText("Agent name"), "codex-laptop");
await user.click(screen.getByLabelText("pilot"));
await user.click(screen.getByRole("button", { name: "Create agent key" }));
expect(await screen.findByLabelText("New bearer credential")).toHaveValue("agent-fixture-secret");
expect(fetchMock).toHaveBeenCalledWith("/v1/me/agent-credentials", expect.objectContaining({
headers: expect.objectContaining({ "X-GEWU-CSRF": "user-csrf" }),
body: JSON.stringify({ name: "codex-laptop", project_ids: ["pilot"] }),
}));
await user.type(screen.getByLabelText('Current password'), 'initial-password-fixture');
await user.type(screen.getByLabelText('New password'), 'changed-password-fixture');
await user.type(screen.getByLabelText('Confirm new password'), 'changed-password-fixture');
await user.click(screen.getByRole('button', { name: 'Change password' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(screen.queryByLabelText('New bearer credential')).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('Password changed.');
await user.type(screen.getByLabelText("Current password"), "initial-password-fixture");
await user.type(screen.getByLabelText("New password"), "changed-password-fixture");
await user.type(screen.getByLabelText("Confirm new password"), "changed-password-fixture");
await user.click(screen.getByRole("button", { name: "Change password" }));
expect(await screen.findByLabelText("Username")).toBeVisible();
expect(screen.queryByLabelText("New bearer credential")).toBeNull();
expect(screen.getByRole("status")).toHaveTextContent("Password changed.");
});
it('lets the existing administrator create the first password account', async () => {
it("lets the existing administrator create the first password account", async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
const result = path === '/v1/session' ? { csrf_token: 'operator-csrf' } : [];
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
const result = path === "/v1/session" ? { csrf_token: "operator-csrf" } : [];
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'operator-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Users' }));
const accounts = await screen.findByRole('region', { name: 'User accounts' });
await user.type(within(accounts).getByLabelText('New username'), 'vasily');
await user.type(within(accounts).getByLabelText('Initial password'), 'chosen-password-fixture');
await user.selectOptions(within(accounts).getByLabelText('Account role'), 'admin');
await user.click(within(accounts).getByRole('button', { name: 'Create user' }));
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
await user.type(screen.getByLabelText("GEWU administrator credential"), "operator-fixture");
await user.click(screen.getByRole("button", { name: "Connect" }));
await user.click(await screen.findByRole("tab", { name: "Users" }));
await user.click(await screen.findByRole("button", { name: "Add user" }));
const dialog = await screen.findByRole("dialog", { name: "Add user" });
await user.type(within(dialog).getByLabelText("New username"), "vasily");
await user.type(within(dialog).getByLabelText("Initial password"), "chosen-password-fixture");
await user.selectOptions(within(dialog).getByLabelText("Account role"), "admin");
await user.click(within(dialog).getByRole("button", { name: "Add user" }));
await screen.findByText(/Account created for vasily/);
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/users', expect.objectContaining({
body: JSON.stringify({ username: 'vasily', password: 'chosen-password-fixture', role: 'admin', project_ids: [] }),
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/users", expect.objectContaining({
body: JSON.stringify({ username: "vasily", password: "chosen-password-fixture", role: "admin", project_ids: [] }),
}));
expect(within(accounts).getByLabelText('Initial password')).toHaveValue('');
});
+96 -91
View File
@@ -1,133 +1,138 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import Admin from "./Admin";
const key = { credential_id: 'reader-key', principal_id: 'reader', roles: ['viewer'], project_ids: ['pilot'], revoked: false };
const key = { credential_id: "reader-key", principal_id: "reader", roles: ["viewer"], project_ids: ["pilot"], revoked: false };
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('creates a scoped key, dismisses its one-time secret, and confirms revocation with CSRF', async () => {
it("creates a scoped key, dismisses its one-time secret, and confirms revocation with CSRF", async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = {};
if (path === '/v1/session') result = { csrf_token: 'csrf-fixture' };
if (path.startsWith('/v1/admin/credentials?')) result = [key];
if (path.startsWith('/v1/admin/users?')) result = [];
if (path === '/v1/admin/projects') result = [{ project_id: 'pilot', title: 'Pilot research' }];
if (path === '/v1/admin/events?limit=50') result = [];
if (path === '/v1/admin/credentials' && init?.method === 'POST') result = { credential_id: 'new-key', token: 'one-time-fixture-secret' };
if (path === "/v1/session") result = { csrf_token: "csrf-fixture" };
if (path.startsWith("/v1/admin/credentials?")) result = [key];
if (path.startsWith("/v1/admin/users?")) result = [];
if (path === "/v1/admin/projects") result = [{ project_id: "pilot", title: "Pilot research" }];
if (path === "/v1/admin/events?limit=50") result = [];
if (path === "/v1/admin/credentials" && init?.method === "POST") result = { credential_id: "new-key", token: "one-time-fixture-secret" };
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Credentials' }));
await screen.findByRole('heading', { name: 'Credentials' });
await user.type(screen.getByLabelText('Credential ID'), 'new-key');
await user.type(screen.getByLabelText('Principal ID'), 'researcher');
await user.click(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('Pilot research'));
await user.click(screen.getByRole('button', { name: 'Create credential' }));
expect(await screen.findByLabelText('New bearer credential')).toHaveValue('one-time-fixture-secret');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials', expect.objectContaining({
method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
body: JSON.stringify({ credential_id: 'new-key', principal_id: 'researcher', role: 'viewer', project_ids: ['pilot'] }),
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
await user.type(screen.getByLabelText("GEWU administrator credential"), "admin-fixture");
await user.click(screen.getByRole("button", { name: "Connect" }));
await user.click(await screen.findByRole("tab", { name: "Credentials" }));
await screen.findByRole("heading", { name: "Credentials" });
await user.click(screen.getByRole("button", { name: "Add credential" }));
const dialog = await screen.findByRole("dialog", { name: "Add credential" });
await user.type(within(dialog).getByLabelText("Credential ID"), "new-key");
await user.type(within(dialog).getByLabelText("Principal ID"), "researcher");
await user.click(within(dialog).getByLabelText("Pilot research"));
await user.click(within(dialog).getByRole("button", { name: "Create credential" }));
expect(await screen.findByLabelText("New bearer credential")).toHaveValue("one-time-fixture-secret");
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/credentials", expect.objectContaining({
method: "POST", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
body: JSON.stringify({ credential_id: "new-key", principal_id: "researcher", role: "viewer", project_ids: ["pilot"] }),
}));
await user.click(screen.getByRole('button', { name: 'I have saved it — dismiss' }));
expect(screen.queryByLabelText('New bearer credential')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Revoke reader-key' }));
expect(fetchMock.mock.calls.some(([path]) => path.endsWith('/reader-key'))).toBe(false);
const row = screen.getByRole('row', { name: /reader-key/ });
await user.click(within(row).getByRole('button', { name: 'Confirm revoke' }));
await screen.findByText('Revoked reader-key.');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials/reader-key', expect.objectContaining({
method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
await user.click(screen.getByRole("button", { name: "I have saved it — dismiss" }));
expect(screen.queryByLabelText("New bearer credential")).toBeNull();
await user.click(screen.getByRole("button", { name: "Revoke" }));
const confirmBtn = await screen.findByRole("button", { name: "Confirm revoke" });
await user.click(confirmBtn);
await screen.findByText("Revoked reader-key.");
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/credentials/reader-key", expect.objectContaining({
method: "DELETE", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
}));
});
it('reports rejected connections without exposing management controls', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ detail: 'Administrator role is required' }) })));
it("reports rejected connections without exposing management controls", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ detail: "Administrator role is required" }) })));
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'viewer-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Administrator role is required');
expect(screen.queryByRole('heading', { name: 'Create a credential' })).toBeNull();
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
await user.type(screen.getByLabelText("GEWU administrator credential"), "viewer-fixture");
await user.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Administrator role is required");
expect(screen.queryByRole("heading", { name: "Add credential" })).toBeNull();
});
it('clears private panel state and permits reconnection after session expiry', async () => {
it("clears private panel state and permits reconnection after session expiry", async () => {
let expired = false;
vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (expired) return { ok: false, status: 401, json: async () => ({ detail: 'Browser session is invalid or expired' }) };
const result = path === '/v1/session' ? { csrf_token: 'csrf-fixture' }
: path.startsWith('/v1/admin/credentials?') ? [key] : [];
vi.stubGlobal("fetch", vi.fn(async (path: string, init?: RequestInit) => {
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
if (expired) return { ok: false, status: 401, json: async () => ({ detail: "Browser session is invalid or expired" }) };
const result = path === "/v1/session" ? { csrf_token: "csrf-fixture" }
: path.startsWith("/v1/admin/credentials?") ? [key] : [];
return { ok: true, json: async () => result };
}));
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Credentials' }));
await screen.findByRole('heading', { name: 'Credentials' });
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
await user.type(screen.getByLabelText("GEWU administrator credential"), "admin-fixture");
await user.click(screen.getByRole("button", { name: "Connect" }));
await user.click(await screen.findByRole("tab", { name: "Credentials" }));
await screen.findByRole("heading", { name: "Credentials" });
expired = true;
await user.click(screen.getByRole('button', { name: 'Refresh' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(screen.queryByText('reader-key')).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('Your session ended.');
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(await screen.findByLabelText("Username")).toBeVisible();
expect(screen.queryByText("reader-key")).toBeNull();
expect(screen.getByRole("status")).toHaveTextContent("Your session ended.");
});
it('restores the admin session after remount and stops restoring after sign-out', async () => {
it("restores the admin session after remount and stops restoring after sign-out", async () => {
let active = true;
const storage = vi.spyOn(Storage.prototype, 'setItem');
const storage = vi.spyOn(Storage.prototype, "setItem");
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') {
return active ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) }
if (path === "/v1/session" && init?.method === "GET") {
return active ? { ok: true, json: async () => ({ csrf_token: "restored-csrf", password_account: true }) }
: { ok: false, status: 401, json: async () => ({}) };
}
if (path === '/v1/session' && init?.method === 'DELETE') active = false;
const result = path.startsWith('/v1/admin/credentials?') ? [key]
: path === '/v1/me' ? { principal_id: 'user:admin' } : [];
if (path === "/v1/session" && init?.method === "DELETE") active = false;
const result = path.startsWith("/v1/admin/credentials?") ? [key]
: path === "/v1/me" ? { principal_id: "user:admin" } : [];
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
const first = render(<Admin />);
expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible();
expect(screen.queryByLabelText('Username')).toBeNull();
expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Projects', 'Users', 'Credentials', 'Logs', 'Admin Settings']);
expect(screen.getAllByRole('tabpanel')).toHaveLength(1);
expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull();
await user.type(screen.getByLabelText('Project name'), 'Unfinished research');
const projectsTab = screen.getByRole('tab', { name: 'Projects' });
expect(await screen.findByRole("heading", { name: "Projects" })).toBeVisible();
expect(screen.queryByLabelText("Username")).toBeNull();
expect(screen.getAllByRole("tab").map(tab => tab.textContent)).toEqual(["Projects", "Ingest review", "Users", "Credentials", "Logs", "Admin Settings"]);
expect(screen.getAllByRole("tabpanel")).toHaveLength(1);
expect(screen.queryByRole("heading", { name: "Credentials" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Create project" }));
await user.type(screen.getByLabelText("Project name"), "Unfinished research");
const projectsTab = screen.getByRole("tab", { name: "Projects" });
projectsTab.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('tab', { name: 'Users' })).toHaveFocus();
expect(screen.getByRole('heading', { name: 'User accounts' })).toBeVisible();
await user.keyboard('{End}');
expect(screen.getByRole('tab', { name: 'Admin Settings' })).toHaveFocus();
expect(screen.getByRole('heading', { name: 'Change your password' })).toBeVisible();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('heading', { name: 'Recent activity' })).toBeVisible();
await user.keyboard('{Home}');
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Ingest review" })).toHaveFocus();
expect(screen.getByRole("heading", { name: "Ingest review" })).toBeVisible();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Users" })).toHaveFocus();
expect(screen.getByRole("heading", { name: "Users" })).toBeVisible();
await user.keyboard("{End}");
expect(screen.getByRole("tab", { name: "Admin Settings" })).toHaveFocus();
expect(screen.getByRole("heading", { name: "Change your password" })).toBeVisible();
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("heading", { name: "Logs" })).toBeVisible();
await user.keyboard("{Home}");
expect(projectsTab).toHaveFocus();
expect(screen.getByLabelText('Project name')).toHaveValue('Unfinished research');
expect(screen.getByLabelText("Project name")).toHaveValue("Unfinished research");
first.unmount();
render(<Admin />);
expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible();
expect(fetchMock.mock.calls.filter(([path, init]) => path === '/v1/session' && init?.method === 'GET')).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'X-GEWU-Session': 'restore' },
expect(await screen.findByRole("heading", { name: "Projects" })).toBeVisible();
expect(fetchMock.mock.calls.filter(([path, init]) => path === "/v1/session" && init?.method === "GET")).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledWith("/v1/session", expect.objectContaining({
method: "GET", credentials: "same-origin", cache: "no-store", headers: { "X-GEWU-Session": "restore" },
}));
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'restored-csrf' }),
await user.click(screen.getByRole("button", { name: "Disconnect" }));
expect(await screen.findByLabelText("Username")).toBeVisible();
expect(fetchMock).toHaveBeenCalledWith("/v1/session", expect.objectContaining({
method: "DELETE", headers: expect.objectContaining({ "X-GEWU-CSRF": "restored-csrf" }),
}));
expect(storage).not.toHaveBeenCalled();
storage.mockRestore();
+22 -2
View File
@@ -123,8 +123,28 @@ function Admin({ personal = false }: { personal?: boolean }) {
setCurrentPrincipal(identity.principal_id ?? '');
if (!personal) setProjectRecords(availableProjects as Project[]);
}, [personal, offset]);
const refreshAdminData = useCallback(() => load(csrf), [load, csrf]);
const loadAdminPage = useCallback((page: number) => load(csrf, page), [load, csrf]);
const handleSessionError = useCallback((failure: unknown) => {
if (failure instanceof AdminRequestError && failure.status === 401) {
setCsrf(''); setCredentials([]); setEvents([]); setProjects([]); setIssued(null);
setMessage('Your session ended. Connect again to continue.');
}
}, []);
const refreshAdminData = useCallback(async () => {
try {
await load(csrf);
} catch (failure) {
handleSessionError(failure);
throw failure;
}
}, [load, csrf, handleSessionError]);
const loadAdminPage = useCallback(async (page: number) => {
try {
await load(csrf, page);
} catch (failure) {
handleSessionError(failure);
throw failure;
}
}, [load, csrf, handleSessionError]);
async function connect(session: { csrf_token: string }, isPasswordAccount: boolean, restored = false) {
setBusy(true); setError('');
+1 -1
View File
@@ -14,7 +14,7 @@ export function AdminDialog({ open, title, onClose, children, returnFocusRef }:
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return <dialog ref={dialogRef} className="admin-dialog" aria-label={title}
return <dialog ref={dialogRef} open={open ? true : undefined} className="admin-dialog" aria-label={title}
onCancel={event => { event.preventDefault(); onClose(); }}
onClose={() => { onClose(); returnFocusRef?.current?.focus(); }}>
<div className="admin-heading"><h2>{title}</h2><button className="text-button" type="button" onClick={onClose}>Close</button></div>
+126 -58
View File
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useLocation, useNavigate } from 'react-router';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -60,7 +60,9 @@ function mockFetch(
resultNode = node,
resultCitation = citation,
restoreSession = false,
browseContinuation = false,
) {
let browseRequests = 0;
const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => {
const [input] = args;
const path = String(input);
@@ -68,9 +70,30 @@ function mockFetch(
if (path === '/v1/session' && args[1]?.method === 'GET') return restoreSession ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) } as Response : { ok: false, status: 401, json: async () => ({}) } as Response;
if (path === '/v1/session') data = { csrf_token: 'gwc_test-token', expires_at: '2026-10-13T08:00:00Z' };
else if (path === '/v1/projects') data = ['dome-performance'];
else if (path === '/v1/search' && searchMode === 'error') {
else if (path === '/v1/knowledge/graph') data = {
schema_version: '1.0', api_version: '1.0', request_id: 'graph-test',
resolved_scope: { projects: ['dome-performance'], shared: false },
nodes: [resultNode], edges: [], truncated: false, center_node_id: null,
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), warnings: [],
};
else if ((path === '/v1/search' || path === '/v1/knowledge/browse') && searchMode === 'error') {
return { ok: false, status: 503, json: async () => ({ detail: 'Search index unavailable.' }) } as Response;
}
else if (path === '/v1/knowledge/browse' && searchMode === 'empty') data = {
nodes: [], project_node_count: 1, project_source_count: 1, shared_node_count: 0, shared_source_count: 0, filtered_node_count: 0,
facets: { tags: { 'installation': 1 }, audience: { developer: 1 }, use_for: { planning: 1 }, domain: { 'interactive systems': 1 } },
warnings: [], canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
else if (path === '/v1/knowledge/browse') {
const body = args[1]?.body ? JSON.parse(String(args[1].body)) as { cursor?: string } : {};
const restoringPageTwo = browseContinuation;
const isFirstBrowseRequest = browseRequests++ === 0;
data = {
nodes: [resultNode], project_node_count: restoringPageTwo ? 21 : 1, project_source_count: 1, shared_node_count: 0, shared_source_count: 0, filtered_node_count: restoringPageTwo ? 21 : 1,
facets: { tags: { 'real-time': 1, installation: 1 }, audience: { developer: 1 }, use_for: { planning: 1 }, domain: { 'interactive systems': 1 } },
warnings: [], canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: restoringPageTwo && isFirstBrowseRequest && !body.cursor ? 'opaque-browse-cursor' : null,
};
}
else if (path === '/v1/search' && searchMode === 'empty') data = {
data: [], citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
@@ -100,8 +123,9 @@ function mockFetch(
data: [], citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
else if (path === '/v1/sources') data = {
data: [{
else if (path === '/v1/sources') {
const body = args[1]?.body ? JSON.parse(String(args[1].body)) as { cursor?: string } : {};
const sourcePage = body.cursor ? [] : [{
source: {
source_id: 'dome:pilot-report',
revision_id: 'dome:pilot-report:r1',
@@ -115,10 +139,10 @@ function mockFetch(
reliability_grade: 'medium',
ingested_for: ['dome-performance'],
},
}],
citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
}];
data = { data: sourcePage, filtered_count: 21, citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: body.cursor ? null : 'sources-next' };
}
else if (path.endsWith('/history')) data = {
data: [
{ revision: '1'.repeat(40), node },
@@ -182,21 +206,26 @@ describe('GEWU research reader', () => {
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByLabelText('PROJECT')).toHaveValue('dome-performance');
await user.click(screen.getByRole('link', { name: /stale knowledge/i }));
expect(screen.getByLabelText('Past freshness date')).toBeChecked();
expect(await screen.findByRole('region', { name: 'Selected project collection' })).toHaveTextContent('1 knowledge nodes');
await user.click(screen.getByRole('checkbox', { name: 'Past freshness date' }));
expect(screen.getByRole('checkbox', { name: 'Past freshness date' })).toBeChecked();
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.type(screen.getByLabelText('As of from'), '2024-01-01');
await user.type(screen.getByLabelText('As of to'), '2024-12-31');
await user.click(screen.getByLabelText('medium'));
await user.click(screen.getByRole('button', { name: 'Search' }));
const result = await screen.findByRole('button', { name: /visitor count during the season/i });
const result = await screen.findByRole('link', { name: /visitor count during the season/i });
expect(result).toBeVisible();
await user.click(result);
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('node=dome%3Aseason-visitors');
expect((await screen.findAllByText(citation.quote, { exact: false })).length).toBeGreaterThan(0);
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
expect(await screen.findByRole('heading', { name: 'Assumptions and limitations' })).toBeVisible();
const claimHeading = await screen.findByRole('heading', { name: 'Claim assessments' });
expect(claimHeading).toBeVisible();
await user.click(claimHeading);
const assumptionHeading = await screen.findByRole('heading', { name: 'Assumptions and limitations' });
expect(assumptionHeading).toBeVisible();
await user.click(assumptionHeading);
expect(screen.getByText(/The source does not identify unique visitors\./)).toBeVisible();
expect(screen.getByText('The cited sentence states the count.')).toBeVisible();
expect(screen.getByText(/not a truth probability/)).toBeVisible();
@@ -229,13 +258,13 @@ describe('GEWU research reader', () => {
expect.objectContaining({ headers: { Authorization: 'Bearer viewer-secret' } }),
);
expect(fetchMock).toHaveBeenCalledWith(
'/v1/search',
'/v1/knowledge/browse',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({ 'X-GEWU-CSRF': 'gwc_test-token' }),
}),
);
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
const searchCall = [...fetchMock.mock.calls].reverse().find(([url]) => String(url) === '/v1/knowledge/browse');
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
stale_only: true,
confidences: ['medium'],
@@ -244,9 +273,15 @@ describe('GEWU research reader', () => {
});
expect(localStorage.length).toBe(0);
expect(screen.queryByRole('button', { name: /approve|edit|merge|generate|capture/i })).not.toBeInTheDocument();
await user.click(screen.getByRole('link', { name: /sources/i }));
await user.click(within(screen.getByRole('navigation', { name: 'Project collections' })).getByRole('button', { name: 'Sources' }));
await user.click(screen.getByRole('button', { name: 'Load source records' }));
expect(await screen.findByRole('heading', { name: 'Sources' })).toBeVisible();
expect(screen.getByText('1–1 of 21')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByText('0 of 21')).toBeVisible();
expect(screen.getByRole('button', { name: 'Previous' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Previous' }));
expect(await screen.findByText('1–1 of 21')).toBeVisible();
expect(
(await screen.findAllByRole('heading', { name: 'Pilot installation report' })).length,
).toBeGreaterThan(0);
@@ -280,7 +315,7 @@ describe('GEWU research reader', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
expect(await screen.findByText(/Transcript · 00:14:32\.000–00:14:36\.500 · cue-1 · 48–102/)).toBeVisible();
});
@@ -309,17 +344,16 @@ describe('GEWU research reader', () => {
if (mode === 'error') expect(screen.getByText('Search index unavailable.')).toBeVisible();
});
it('routes Problems and Topics through the typed search facets', async () => {
it('shows Problems and Concepts as knowledge type filter options', async () => {
const user = userEvent.setup();
mockFetch();
render(<MemoryRouter><App /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(screen.getByRole('link', { name: /problems/i }));
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('problem');
await user.click(screen.getByRole('link', { name: /topics/i }));
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('concept');
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveDisplayValue('All types');
expect(screen.getByRole('option', { name: 'Problems' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Concepts' })).toBeInTheDocument();
});
it('applies exact tag, audience, use and domain facets and stores them in the URL', async () => {
@@ -330,23 +364,31 @@ describe('GEWU research reader', () => {
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.type(screen.getByLabelText('Tag'), 'installation');
await user.type(screen.getByLabelText('Audience'), 'developer');
await user.type(screen.getByLabelText('Use'), 'planning');
await user.type(screen.getByLabelText('Domain'), 'interactive systems');
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search')).toHaveLength(0);
const filters = screen.getByText('Filters').closest('details');
if (!filters) throw new Error('Filters disclosure missing');
await user.click(within(filters).getByText('Filters'));
await user.click(within(filters).getByText('installation'));
await user.click(within(filters).getByText('developer'));
await user.click(within(filters).getByText('planning'));
await user.click(within(filters).getByText('interactive systems'));
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: 'Search' }));
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
const searchCall = [...searchCalls].reverse().find(([, init]) => {
const body = JSON.parse(String(init?.body)) as { domain?: string[] };
return body.domain?.includes('interactive systems');
});
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
tags: ['installation'],
audience: ['developer'],
use_for: ['planning'],
domain: ['interactive systems'],
});
expect(screen.getByLabelText('Current search URL')).toHaveTextContent(
'tag=installation&audience=developer&use=planning&domain=interactive+systems',
);
expect(screen.getByRole('checkbox', { name: /installation/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /developer/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /planning/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /interactive systems/ })).toBeChecked();
});
it('restores a shared search URL after connecting with its query and exact facets', async () => {
@@ -362,12 +404,12 @@ describe('GEWU research reader', () => {
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible();
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search');
expect(searchCalls).toHaveLength(1);
expect(JSON.parse(String(searchCalls[0]?.[1]?.body))).toMatchObject({
expect(await screen.findByRole('link', { name: /visitor count during the season/i })).toBeVisible();
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
expect(searchCalls.length).toBeGreaterThan(0);
expect(JSON.parse(String(searchCalls.at(-1)?.[1]?.body))).toMatchObject({
query: 'visitors',
scope: { projects: ['dome-performance'] },
scope: { projects: ['dome-performance'], include_projects: [], include_shared: false, all_projects: false },
tags: ['installation'],
audience: ['developer'],
confidences: ['medium'],
@@ -379,6 +421,20 @@ describe('GEWU research reader', () => {
expect(screen.getByLabelText('As of to')).toHaveValue('2024-12-31');
});
it('restores an opaque-cursor page from the page number in the URL', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch('normal', node, citation, false, true);
render(<MemoryRouter initialEntries={['/?project=dome-performance&page=2']}><App /><CurrentRoute /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await screen.findByRole('link', { name: /visitor count during the season/i });
const calls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
expect(calls).toHaveLength(2);
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ cursor: expect.any(String) });
expect(screen.getByText('21–21 of 21')).toBeVisible();
});
it('restores prior searches with one Back step and reruns them on browser navigation', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
@@ -403,30 +459,30 @@ describe('GEWU research reader', () => {
await user.click(screen.getByLabelText('medium'));
await user.type(screen.getByLabelText('As of to'), '2026-12-31');
await user.click(screen.getByRole('button', { name: 'Search' }));
await screen.findByText('Search complete. 1 result.');
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
await user.click(screen.getByRole('button', { name: 'Back in history' }));
await screen.findByText('Search complete. 1 result.');
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
expect(queryBox).toHaveValue('visitors');
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('confidence=medium');
expect(screen.getByLabelText('medium')).toBeChecked();
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
expect(screen.getByLabelText('As of to')).toHaveValue('');
let searchCalls = fetchMock.mock.calls
.filter(([url]) => String(url) === '/v1/search')
.filter(([url]) => String(url) === '/v1/knowledge/browse')
.map(([, init]) => JSON.parse(String(init?.body)).query);
expect(searchCalls).toEqual(['visitors', 'methods', 'visitors']);
expect(searchCalls.slice(-3)).toEqual(['visitors', 'methods', 'visitors']);
await user.click(screen.getByRole('button', { name: 'Forward in history' }));
await screen.findByText('Search complete. 1 result.');
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
expect(queryBox).toHaveValue('methods');
expect(screen.getByLabelText('medium')).not.toBeChecked();
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
expect(screen.getByLabelText('As of to')).toHaveValue('2026-12-31');
searchCalls = fetchMock.mock.calls
.filter(([url]) => String(url) === '/v1/search')
.filter(([url]) => String(url) === '/v1/knowledge/browse')
.map(([, init]) => JSON.parse(String(init?.body)).query);
expect(searchCalls).toEqual(['visitors', 'methods', 'visitors', 'methods']);
expect(searchCalls.slice(-4)).toEqual(['visitors', 'methods', 'visitors', 'methods']);
});
it('labels the problem reading view and its missing accepted approaches', async () => {
@@ -438,28 +494,21 @@ describe('GEWU research reader', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitor problem');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
expect(await screen.findByRole('heading', { name: 'Approaches and related knowledge' })).toBeVisible();
expect(screen.getByText(/No accepted approaches or other relationships are visible/)).toBeVisible();
});
it('browses problem records within the selected scope without requiring a text query', async () => {
it('offers problem and concept type filters for project browsing', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch('normal', { ...node, node_type: 'problem' });
mockFetch();
render(<MemoryRouter><App /><CurrentRoute /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('button', { name: 'Browse problems' }));
expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible();
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
query: '',
scope: { projects: ['dome-performance'] },
node_types: ['problem'],
});
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('type=problem');
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('');
expect(screen.getByRole('option', { name: 'Problems' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Concepts' })).toBeInTheDocument();
});
it('restores a linked canonical record after connecting with a shared node URL', async () => {
@@ -497,7 +546,7 @@ describe('GEWU research reader', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Back in history' }));
@@ -517,7 +566,7 @@ describe('GEWU research reader', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
await user.click(await screen.findByText('Revision history (2)'));
const neighborCallsBeforeHistoricalRead = fetchMock.mock.calls.filter(
([url]) => String(url) === '/v1/neighbors',
@@ -553,3 +602,22 @@ it('restores library access from the existing browser session without entering a
method: 'GET', credentials: 'same-origin', headers: { 'X-GEWU-Session': 'restore' },
}));
});
it('loads the authorized project graph when the Graph tab opens', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
render(<MemoryRouter initialEntries={['/']}><App /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click((await screen.findAllByRole('button', { name: 'Graph' }))[0]);
expect(await screen.findByText('1 nodes · 0 links')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Visitor count during the season (finding)' }));
expect(screen.getByRole('dialog', { name: 'Visitor count during the season' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Open node →' })).toBeVisible();
expect(screen.queryByRole('button', { name: 'Zoom in' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Project graph' })).not.toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith('/v1/knowledge/graph', expect.objectContaining({
method: 'POST', credentials: 'same-origin',
}));
});
+1272 -541
View File
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { MemoryRouter } from 'react-router';
import { afterEach, expect, it, vi } from 'vitest';
import type { components } from '../generated/api';
import { GraphCanvas } from './GraphCanvas';
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const node = {
node_id: 'node-1', project_id: 'demo', title: 'Shared scenes preserve story state',
summary: 'A scene can be reused when the relevant story state agrees.', node_type: 'technique',
} as components['schemas']['KnowledgeGraphEnvelope']['nodes'][number];
function Fixture() {
const [selected, setSelected] = useState<string | null>(null);
return <MemoryRouter><button>Outside graph</button><GraphCanvas
nodes={[node]} edges={[]} drawing={{ viewBox: '0 0 1000 640', points: new Map([['node-1', { x: 200, y: 200 }]]) }}
selectedId={selected} onSelect={setSelected} nodeHref={() => '/?node=node-1'}
/></MemoryRouter>;
}
it('opens the node link inside a graph popup and restores focus on close', async () => {
const user = userEvent.setup();
render(<Fixture />);
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(target);
const popup = screen.getByRole('dialog', { name: node.title });
expect(popup.closest('.graph-stage')).not.toBeNull();
expect(popup).toHaveFocus();
expect(within(popup).getByRole('link', { name: 'Open node →' })).toHaveAttribute('href', '/?node=node-1');
expect(target).toHaveAttribute('aria-expanded', 'true');
await user.click(within(popup).getByRole('button', { name: 'Close node preview' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(target).toHaveFocus();
});
it('supports keyboard activation and Escape without requiring hover', async () => {
const user = userEvent.setup();
render(<Fixture />);
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
target.focus();
await user.keyboard('{Enter}');
expect(screen.getByRole('dialog')).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(target).toHaveFocus();
await user.keyboard(' ');
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('dismisses on empty graph space or an outside pointer interaction', async () => {
const user = userEvent.setup();
render(<Fixture />);
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
await user.click(target);
fireEvent.click(document.querySelector('.graph-canvas')!);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(target);
await user.click(screen.getByRole('button', { name: 'Outside graph' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
function canvas() {
const element = document.querySelector<SVGSVGElement>('.graph-canvas')!;
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 1000, bottom: 640, width: 1000, height: 640, toJSON: () => ({}) });
return element;
}
function camera() {
const [x, y, zoom] = document.querySelector('.graph-camera')!.getAttribute('transform')!.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/g)!.map(Number);
return { x, y, zoom };
}
it('zooms around the mouse position and consumes wheel scrolling only on the graph', () => {
render(<Fixture />);
const graph = canvas();
const wheel = new WheelEvent('wheel', { clientX: 200, clientY: 200, deltaY: -100, bubbles: true, cancelable: true });
fireEvent(graph, wheel);
expect(wheel.defaultPrevented).toBe(true);
const view = camera();
expect(view.zoom).toBeGreaterThan(1);
expect(view.x + 200 * view.zoom).toBeCloseTo(200);
expect(view.y + 200 * view.zoom).toBeCloseTo(200);
});
it('pans by dragging a node without accidentally opening its popup', () => {
render(<Fixture />);
const graph = canvas();
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
fireEvent.pointerDown(target, { pointerId: 1, button: 0, clientX: 200, clientY: 200 });
fireEvent.pointerMove(graph, { pointerId: 1, clientX: 280, clientY: 240 });
fireEvent.pointerUp(graph, { pointerId: 1 });
fireEvent.click(target);
expect(camera().x).toBeCloseTo(80);
expect(camera().y).toBeCloseTo(40);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.pointerDown(target, { pointerId: 2, button: 0, clientX: 280, clientY: 240 });
fireEvent.pointerUp(target, { pointerId: 2 });
fireEvent.click(target);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('supports touch pinch zoom and keyboard navigation without toolbar buttons', () => {
render(<Fixture />);
const graph = canvas();
fireEvent.pointerDown(graph, { pointerId: 1, pointerType: 'touch', button: 0, clientX: 100, clientY: 200 });
fireEvent.pointerDown(graph, { pointerId: 2, pointerType: 'touch', button: 0, clientX: 300, clientY: 200 });
fireEvent.pointerMove(graph, { pointerId: 2, pointerType: 'touch', clientX: 400, clientY: 200 });
expect(camera().zoom).toBeCloseTo(1.5);
fireEvent.pointerCancel(graph, { pointerId: 1 });
fireEvent.pointerCancel(graph, { pointerId: 2 });
fireEvent.keyDown(graph, { key: 'Home' });
expect(camera()).toEqual({ x: 0, y: 0, zoom: 1 });
fireEvent.keyDown(graph, { key: 'ArrowRight' });
expect(camera().x).toBeLessThan(0);
fireEvent.keyDown(graph, { key: '+' });
expect(camera().zoom).toBeGreaterThan(1);
});
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { Link } from 'react-router';
import type { components } from '../generated/api';
type Graph = components['schemas']['KnowledgeGraphEnvelope'];
export function GraphCanvas({ nodes, edges, drawing, selectedId, onSelect, nodeHref }: {
nodes: Graph['nodes'];
edges: Graph['edges'];
drawing: { viewBox: string; points: Map<string, { x: number; y: number }> };
selectedId: string | null;
onSelect: (id: string | null) => void;
nodeHref: (id: string, project: string) => string;
}) {
const stage = useRef<HTMLDivElement>(null);
const svg = useRef<SVGSVGElement>(null);
const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 });
const [panning, setPanning] = useState(false);
const pointers = useRef(new Map<number, { x: number; y: number; startX: number; startY: number }>());
const dragged = useRef(false);
const popup = useRef<HTMLDivElement>(null);
const nodeElements = useRef(new Map<string, SVGGElement>());
const markerId = useId();
const titleId = useId();
const selected = nodes.find(node => node.node_id === selectedId);
const [, , width, height] = drawing.viewBox.split(' ').map(Number);
// Convert screen positions into SVG coordinates, including letterboxing.
function graphPoint(x: number, y: number) {
const bounds = svg.current!.getBoundingClientRect();
const unit = Math.min(bounds.width / width, bounds.height / height) || 1;
return {
x: (x - bounds.left - (bounds.width - width * unit) / 2) / unit,
y: (y - bounds.top - (bounds.height - height * unit) / 2) / unit,
};
}
useEffect(() => {
const canvas = svg.current;
if (!canvas) return;
// React's delegated wheel listener is passive; this surface owns its zoom.
const wheel = (event: WheelEvent) => {
event.preventDefault();
const bounds = canvas.getBoundingClientRect();
const unit = Math.min(bounds.width / width, bounds.height / height) || 1;
const point = {
x: (event.clientX - bounds.left - (bounds.width - width * unit) / 2) / unit,
y: (event.clientY - bounds.top - (bounds.height - height * unit) / 2) / unit,
};
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? bounds.height : 1);
setCamera(previous => {
const zoom = Math.max(.3, Math.min(8, previous.zoom * Math.exp(-Math.max(-100, Math.min(100, delta)) * .002)));
const ratio = zoom / previous.zoom;
return { zoom, x: point.x - (point.x - previous.x) * ratio, y: point.y - (point.y - previous.y) * ratio };
});
};
canvas.addEventListener('wheel', wheel, { passive: false });
return () => canvas.removeEventListener('wheel', wheel);
}, [width, height]);
function dismiss() {
onSelect(null);
if (selectedId) nodeElements.current.get(selectedId)?.focus();
}
useLayoutEffect(() => {
if (!selected) return;
const placePopup = () => {
const card = popup.current;
const container = stage.current;
const circle = nodeElements.current.get(selected.node_id)?.querySelector('circle');
if (!card || !container || !circle) return;
const bounds = container.getBoundingClientRect();
const anchor = circle.getBoundingClientRect();
const gap = 12;
const maxLeft = Math.max(gap, bounds.width - card.offsetWidth - gap);
let left = anchor.right - bounds.left + gap;
if (left > maxLeft) left = anchor.left - bounds.left - card.offsetWidth - gap;
card.style.left = `${Math.max(gap, Math.min(maxLeft, left))}px`;
card.style.top = `${Math.max(gap, Math.min(bounds.height - card.offsetHeight - gap, anchor.top - bounds.top - gap))}px`;
};
placePopup();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(placePopup);
if (stage.current) observer?.observe(stage.current);
if (popup.current) observer?.observe(popup.current);
window.addEventListener('resize', placePopup);
return () => { observer?.disconnect(); window.removeEventListener('resize', placePopup); };
}, [selected, drawing, camera]);
useEffect(() => { if (selectedId) popup.current?.focus(); }, [selectedId]);
useEffect(() => {
if (!selectedId) return;
const outside = (event: PointerEvent) => {
if (event.target instanceof Node && !stage.current?.contains(event.target)) onSelect(null);
};
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, [selectedId, onSelect]);
return (
<div className="graph-stage" ref={stage} onKeyDown={event => {
if (event.key === 'Escape' && selectedId) { event.stopPropagation(); dismiss(); }
}}>
<svg ref={svg} className="graph-canvas" data-panning={panning} viewBox={drawing.viewBox} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" aria-label="Knowledge graph. Drag to move, scroll or pinch to zoom. Arrow keys move; plus and minus zoom; Home resets." tabIndex={0}
onPointerDown={event => {
if (event.button !== 0) return;
if (!pointers.current.size) dragged.current = false;
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY, startX: event.clientX, startY: event.clientY });
}}
onPointerMove={event => {
const pointer = pointers.current.get(event.pointerId);
if (!pointer) return;
const before = [...pointers.current.values()].slice(0, 2).map(item => ({ x: item.x, y: item.y }));
const wasDragged = dragged.current;
pointer.x = event.clientX; pointer.y = event.clientY;
if (!dragged.current && pointers.current.size === 1 && Math.hypot(pointer.x - pointer.startX, pointer.y - pointer.startY) < 4) return;
dragged.current = true;
setPanning(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
const after = [...pointers.current.values()].slice(0, 2);
if (!wasDragged && before.length === 1) before[0] = { x: pointer.startX, y: pointer.startY };
const midpoint = (points: Array<{ x: number; y: number }>) => graphPoint(points.reduce((sum, item) => sum + item.x, 0) / points.length, points.reduce((sum, item) => sum + item.y, 0) / points.length);
const from = midpoint(before); const to = midpoint(after);
const distance = (points: Array<{ x: number; y: number }>) => Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
const factor = before.length === 2 && distance(before) > 0 ? distance(after) / distance(before) : 1;
setCamera(previous => {
const zoom = Math.max(.3, Math.min(8, previous.zoom * factor));
const ratio = zoom / previous.zoom;
return { zoom, x: to.x - (from.x - previous.x) * ratio, y: to.y - (from.y - previous.y) * ratio };
});
}}
onPointerUp={event => {
pointers.current.delete(event.pointerId);
if (!pointers.current.size) setPanning(false);
}}
onPointerCancel={event => { pointers.current.delete(event.pointerId); setPanning(false); }}
onLostPointerCapture={event => { pointers.current.delete(event.pointerId); if (!pointers.current.size) setPanning(false); }}
onClickCapture={event => {
if (dragged.current) { event.preventDefault(); event.stopPropagation(); dragged.current = false; }
}}
onKeyDown={event => {
if (event.target !== event.currentTarget) return;
const movement: Record<string, [number, number]> = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] };
if (event.key in movement) {
event.preventDefault();
const [x, y] = movement[event.key];
setCamera(previous => ({ ...previous, x: previous.x + x, y: previous.y + y }));
} else if (['+', '=', '-', '_'].includes(event.key)) {
event.preventDefault();
setCamera(previous => {
const zoom = Math.max(.3, Math.min(8, previous.zoom * (event.key === '+' || event.key === '=' ? 1.2 : 1 / 1.2)));
const ratio = zoom / previous.zoom;
return { zoom, x: width / 2 - (width / 2 - previous.x) * ratio, y: height / 2 - (height / 2 - previous.y) * ratio };
});
} else if (event.key === 'Home') { event.preventDefault(); setCamera({ x: 0, y: 0, zoom: 1 }); }
}}
onClick={event => {
if (!(event.target as Element).closest('.graph-node')) onSelect(null);
}}>
<g className="graph-camera" transform={`translate(${camera.x} ${camera.y}) scale(${camera.zoom})`}>
{edges.map((edge, index) => {
const a = drawing.points.get(edge.source.id); const b = drawing.points.get(edge.target.id);
if (!a || !b) return null;
return <g key={`${edge.edge_id}:${index}`} className="graph-link graph-link--accepted" aria-hidden="true">
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} markerEnd={`url(#${markerId})`} />
<text x={(a.x + b.x) / 2} y={(a.y + b.y) / 2 - 7} className="graph-link-label">{edge.edge_type.replaceAll('_', ' ')}</text>
</g>;
})}
<defs><marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" /></marker></defs>
{nodes.map(node => {
const point = drawing.points.get(node.node_id);
if (!point) return null;
return <g key={node.node_id} ref={element => {
if (element) nodeElements.current.set(node.node_id, element);
else nodeElements.current.delete(node.node_id);
}} className={`graph-node graph-node--${node.node_type}${selectedId === node.node_id ? ' graph-node--selected' : ''}`}
role="button" tabIndex={0} aria-label={`${node.title} (${node.node_type})`} aria-haspopup="dialog" aria-expanded={selectedId === node.node_id}
onClick={() => onSelect(node.node_id)} onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(node.node_id); }
}}>
<circle cx={point.x} cy={point.y} r="12" />
<text className="graph-node__title" x={point.x + 18} y={point.y + 4} aria-hidden="true">{node.title.length > 56 ? `${node.title.slice(0, 56)}…` : node.title}</text>
</g>;
})}
</g>
</svg>
{selected ? <div ref={popup} className="graph-popup" role="dialog" aria-labelledby={titleId} tabIndex={-1}>
<div className="graph-popup__header"><p className="eyebrow">{selected.node_type}</p><button className="graph-popup__close" type="button" aria-label="Close node preview" onClick={dismiss}>×</button></div>
<h3 id={titleId}>{selected.title}</h3>
<p>{selected.summary}</p>
<Link to={nodeHref(selected.node_id, selected.project_id)}>Open node →</Link>
</div> : null}
</div>
);
}
+44 -39
View File
@@ -1,60 +1,65 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import Admin from "./Admin";
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('creates a project from the admin form and makes it available for account and agent grants', async () => {
it("creates a project from the admin form and makes it available for account and agent grants", async () => {
let created = false;
let conflict = true;
const project = {
schema_version: 1, project_id: 'my-research', title: 'My research', status: 'active',
north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'],
owners: ['user:admin'], autonomy_policy_id: 'my-research-policy',
schema_version: 1, project_id: "my-research", title: "My research", status: "active",
north_star: "Investigate useful techniques.", key_questions: ["What works?", "Under what conditions?"],
owners: ["user:admin"], autonomy_policy_id: "my-research-policy",
};
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = [];
if (path === '/v1/users/session') result = { csrf_token: 'csrf-fixture' };
if (path === '/v1/me') result = { principal_id: 'user:admin' };
if (path === '/v1/admin/projects') {
if (init?.method === 'POST') {
if (conflict) return { ok: false, status: 409, json: async () => ({ detail: 'Project ID is already registered' }) };
if (path === "/v1/users/session") result = { csrf_token: "csrf-fixture" };
if (path === "/v1/me") result = { principal_id: "user:admin" };
if (path === "/v1/admin/projects") {
if (init?.method === "POST") {
if (conflict) return { ok: false, status: 409, json: async () => ({ detail: "Project ID is already registered" }) };
created = true; result = project;
} else result = created ? [project] : [];
}
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.type(await screen.findByLabelText('Username'), 'admin');
await user.type(screen.getByLabelText('Password'), 'password-fixture');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
const projects = await screen.findByRole('region', { name: 'Projects' });
expect(within(projects).getByText('No projects yet. Create your first project below.')).toBeVisible();
expect(within(projects).getByLabelText(/Owner ID/)).toHaveValue('user:admin');
await user.type(within(projects).getByLabelText('Project name'), 'My research');
expect(within(projects).getByLabelText(/Project ID/)).toHaveValue('my-research');
await user.type(within(projects).getByLabelText('Project purpose'), 'Investigate useful techniques.');
await user.type(within(projects).getByLabelText('Key research questions'), 'What works?\nUnder what conditions?');
await user.click(within(projects).getByRole('button', { name: 'Create project' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Project ID is already registered');
expect(within(projects).getByLabelText('Project name')).toHaveValue('My research');
await user.type(await screen.findByLabelText("Username"), "admin");
await user.type(screen.getByLabelText("Password"), "password-fixture");
await user.click(screen.getByRole("button", { name: "Sign in" }));
const projects = await screen.findByRole("region", { name: "Projects" });
expect(within(projects).getByText("No projects yet. Create your first project below.")).toBeVisible();
await user.click(within(projects).getByRole("button", { name: "Create project" }));
const dialog = await screen.findByRole("dialog", { name: "Create a project" });
expect(within(dialog).getByLabelText(/Owner ID/)).toHaveValue("user:admin");
await user.type(within(dialog).getByLabelText("Project name"), "My research");
expect(within(dialog).getByLabelText(/Project ID/)).toHaveValue("my-research");
await user.type(within(dialog).getByLabelText("Project purpose"), "Investigate useful techniques.");
await user.type(within(dialog).getByLabelText("Key research questions"), "What works?\nUnder what conditions?");
await user.click(within(dialog).getByRole("button", { name: "Create project" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Project ID is already registered");
expect(within(dialog).getByLabelText("Project name")).toHaveValue("My research");
conflict = false;
await user.click(within(projects).getByRole('button', { name: 'Create project' }));
await screen.findByText('Created My research. It is now available for user access and agent keys.');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/projects', expect.objectContaining({
method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
await user.click(within(dialog).getByRole("button", { name: "Create project" }));
await screen.findByText("Created My research. It is now available for user access and agent keys.");
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/projects", expect.objectContaining({
method: "POST", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
body: JSON.stringify({
project: { project_id: 'my-research', title: 'My research', status: 'active', owners: ['user:admin'], north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'], autonomy_policy_id: 'my-research-policy', default_includes: [] },
policy: { policy_id: 'my-research-policy', policy_version: 1, project_id: 'my-research', automatic_source_registration: true, automatic_node_types: ['finding', 'problem', 'technique', 'case', 'entity', 'event', 'concept', 'datapoint', 'synthesis'], require_supported_assessment: true },
project: { project_id: "my-research", title: "My research", status: "active", owners: ["user:admin"], north_star: "Investigate useful techniques.", key_questions: ["What works?", "Under what conditions?"], autonomy_policy_id: "my-research-policy", default_includes: [] },
policy: { policy_id: "my-research-policy", policy_version: 1, project_id: "my-research", automatic_source_registration: true, automatic_node_types: ["finding", "problem", "technique", "case", "entity", "event", "concept", "datapoint", "synthesis"], require_supported_assessment: true },
}),
}));
expect(within(projects).getByLabelText('Project name')).toHaveValue('');
await user.click(screen.getByRole('tab', { name: 'Users' }));
expect(within(screen.getByRole('region', { name: 'User accounts' })).getByLabelText('My research')).toBeVisible();
await user.click(screen.getByRole('tab', { name: 'Credentials' }));
expect(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('My research')).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Users" }));
await user.click(await screen.findByRole("button", { name: "Add user" }));
const usersDialog = await screen.findByRole("dialog", { name: "Add user" });
expect(within(usersDialog).getByLabelText("My research")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Credentials" }));
await user.click(await screen.findByRole("button", { name: "Add credential" }));
const credsDialog = await screen.findByRole("dialog", { name: "Add credential" });
expect(within(credsDialog).getByLabelText("My research")).toBeVisible();
});
+1 -1
View File
@@ -166,7 +166,7 @@ function ProjectsSection({ csrf, projects, currentOwner, onCreated, onError, onM
<label className="admin-wide">Key research questions<textarea required maxLength={16000} rows={4} value={editQuestions} onChange={event => setEditQuestions(event.target.value)} placeholder="One question per line" /></label>
<div className="admin-wide"><button disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button> <button className="text-button" type="button" disabled={busy} onClick={() => setEditing(null)}>Cancel</button></div>
</form>}
</li>)}</ul> : <p>No projects yet.</p>}
</li>)}</ul> : <p>No projects yet. Create your first project below.</p>}
<AdminDialog open={Boolean(viewing)} title={viewing?.title ?? 'Project details'} onClose={() => setViewing(null)} returnFocusRef={viewButtonRef}>
{viewing && <>
<p className="muted-copy">{viewing.project_id} · {viewing.status}</p>
+407
View File
@@ -0,0 +1,407 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { components } from '../generated/api';
type SourceRevisionRecord = components['schemas']['SourceRevisionRecord'];
type SourceRelatedNode = components['schemas']['SourceRelatedNode'];
type SourceRelatedNodesEnvelope = components['schemas']['SourceRelatedNodesEnvelope'];
type ReadEnvelope = components['schemas']['ReadEnvelope'];
interface SourceDetailProps {
project: string;
revisionId: string;
onBack: () => void;
onSelectNode: (nodeId: string, projectId: string) => void;
csrfToken?: string;
includeShared?: boolean;
}
function safeExternalUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString();
}
} catch {
return null;
}
return null;
}
export function SourceDetail({
project,
revisionId,
onBack,
onSelectNode,
csrfToken,
includeShared,
}: SourceDetailProps): React.JSX.Element {
const [source, setSource] = useState<SourceRevisionRecord | null>(null);
const [sourceLoading, setSourceLoading] = useState<boolean>(true);
const [sourceError, setSourceError] = useState<string | null>(null);
const [nodes, setNodes] = useState<SourceRelatedNode[]>([]);
const [totalNodes, setTotalNodes] = useState<number>(0);
const [nodesLoading, setNodesLoading] = useState<boolean>(true);
const [nodesError, setNodesError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [pageCursors, setPageCursors] = useState<(string | null)[]>([null]);
const [query, setQuery] = useState<string>('');
const [nodeType, setNodeType] = useState<string>('');
const effectiveProject = source?.ingested_for?.includes(project)
? project
: (source?.ingested_for?.[0] ?? project);
// Load source metadata with fallback to shared
useEffect(() => {
let active = true;
setSourceLoading(true);
setSourceError(null);
const headers: Record<string, string> = { Accept: 'application/json' };
if (csrfToken) {
headers['X-GEWU-CSRF'] = csrfToken;
}
const fetchSource = async () => {
let res = await fetch(`/v1/projects/${encodeURIComponent(project)}/sources/${encodeURIComponent(revisionId)}`, {
headers,
credentials: 'same-origin',
});
if (res.status === 404 && project !== 'shared') {
const sharedRes = await fetch(`/v1/projects/shared/sources/${encodeURIComponent(revisionId)}`, {
headers,
credentials: 'same-origin',
});
if (sharedRes.ok) {
res = sharedRes;
}
}
if (!res.ok) {
throw new Error(`Failed to load source (${res.status})`);
}
return res.json() as Promise<ReadEnvelope>;
};
fetchSource()
.then((data) => {
if (!active) return;
const found = (data.data?.[0] as unknown as SourceRevisionRecord) ?? null;
setSource(found);
setSourceLoading(false);
})
.catch((err) => {
if (!active) return;
setSourceError(err instanceof Error ? err.message : 'Error loading source');
setSourceLoading(false);
});
return () => {
active = false;
};
}, [project, revisionId, csrfToken]);
// Load citing/referencing nodes
const loadNodes = useCallback((nextCursor: string | null = null, resetPage = true) => {
setNodesLoading(true);
setNodesError(null);
const headers: Record<string, string> = { Accept: 'application/json' };
if (csrfToken) {
headers['X-GEWU-CSRF'] = csrfToken;
}
const targetProject = effectiveProject;
const params = new URLSearchParams();
params.set('limit', '20');
if (includeShared) {
params.set('include_shared', 'true');
}
if (nextCursor) params.set('cursor', nextCursor);
if (query.trim()) params.set('query', query.trim());
if (nodeType) params.set('node_type', nodeType);
const fetchRelatedNodes = async () => {
let res = await fetch(
`/v1/projects/${encodeURIComponent(targetProject)}/sources/${encodeURIComponent(revisionId)}/nodes?${params.toString()}`,
{ headers, credentials: 'same-origin' },
);
if (res.status === 404 && targetProject !== 'shared') {
const sharedRes = await fetch(
`/v1/projects/shared/sources/${encodeURIComponent(revisionId)}/nodes?${params.toString()}`,
{ headers, credentials: 'same-origin' },
);
if (sharedRes.ok) {
res = sharedRes;
}
}
if (!res.ok) {
throw new Error(`Failed to load related nodes (${res.status})`);
}
return res.json() as Promise<SourceRelatedNodesEnvelope>;
};
fetchRelatedNodes()
.then((data) => {
setNodes(data.nodes ?? []);
setTotalNodes(data.total ?? 0);
setCursor(data.next_cursor ?? null);
if (resetPage) {
setPageCursors([null]);
}
setNodesLoading(false);
})
.catch((err) => {
setNodesError(err instanceof Error ? err.message : 'Error loading related nodes');
setNodesLoading(false);
});
}, [effectiveProject, revisionId, csrfToken, query, nodeType, includeShared]);
useEffect(() => {
loadNodes(null, true);
}, [loadNodes]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
loadNodes(null, true);
};
const handlePrevPage = () => {
if (pageCursors.length <= 1) return;
const nextCursors = pageCursors.slice(0, -1);
setPageCursors(nextCursors);
loadNodes(nextCursors.at(-1) ?? null, false);
};
const handleNextPage = () => {
if (!cursor) return;
setPageCursors((prev) => [...prev, cursor]);
loadNodes(cursor, false);
};
return (
<article className="source-detail-view" aria-labelledby="source-detail-heading">
<div className="source-detail-header">
<button type="button" className="quiet-button back-button" onClick={onBack}>
← Back to Sources
</button>
</div>
{sourceLoading ? (
<p className="state-card" role="status">Loading source record…</p>
) : sourceError ? (
<div className="state-card" role="alert">
<h3>Source record unavailable</h3>
<p>{sourceError}</p>
<button type="button" onClick={() => onBack()}>Return to sources</button>
</div>
) : source ? (
<>
<header className="source-meta-card">
<div className="source-meta-badges">
<span className="badge badge--type">{source.source_type}</span>
<span className="badge badge--grade">{source.reliability_grade} reliability</span>
<span className="badge badge--status">{source.archive_status}</span>
</div>
<h1 id="source-detail-heading" className="source-detail-title">{source.title}</h1>
<p className="source-detail-publisher">
{source.publisher ? <strong>{source.publisher}</strong> : 'Publisher not recorded'}
{source.captured_at ? ` · Captured on ${source.captured_at.slice(0, 10)}` : ''}
{source.license ? ` · License: ${source.license}` : ''}
</p>
<div className="source-actions">
{safeExternalUrl(source.original_url ?? '') ? (
<a
className="action-button action-button--primary"
href={safeExternalUrl(source.original_url ?? '') ?? undefined}
target="_blank"
rel="noreferrer"
>
Open original URL ↗
</a>
) : null}
{source.archive_status === 'archived' ? (
<a
className="action-button action-button--secondary"
href={`/v1/projects/${encodeURIComponent(effectiveProject)}/sources/${encodeURIComponent(source.revision_id)}/artifact`}
download
>
Download archived artifact
</a>
) : (
<span className="muted-copy">No local artifact available</span>
)}
</div>
<details className="source-tech-details">
<summary>Technical & extraction metadata</summary>
<dl className="tech-meta-grid">
<div>
<dt>Source Revision ID</dt>
<dd><code>{source.revision_id}</code></dd>
</div>
<div>
<dt>Project</dt>
<dd>{project}</dd>
</div>
<div>
<dt>Media Type</dt>
<dd>{source.media_type || 'Unknown'}</dd>
</div>
<div>
<dt>Artifact SHA-256</dt>
<dd><code>{source.artifact_sha256 || 'None'}</code></dd>
</div>
<div>
<dt>Byte Length</dt>
<dd>{source.byte_length ? `${source.byte_length.toLocaleString()} bytes` : '0'}</dd>
</div>
<div>
<dt>Capture Method</dt>
<dd>{source.capture_method || 'Unknown'}</dd>
</div>
</dl>
</details>
</header>
<section className="source-related-nodes" aria-labelledby="related-nodes-heading">
<div className="section-heading">
<div>
<p className="eyebrow">RESEARCH EVIDENCE & CITATIONS</p>
<h2 id="related-nodes-heading">
Knowledge referencing this source
</h2>
<p className="scope-note">
Nodes that cite this source in their evidence claims or connect via knowledge graph edges.
</p>
</div>
<span className="result-count">{totalNodes} referencing {totalNodes === 1 ? 'node' : 'nodes'}</span>
</div>
<form className="source-nodes-filter" onSubmit={handleSearchSubmit}>
<input
type="search"
aria-label="Filter referencing nodes"
placeholder="Filter by keyword…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<select
aria-label="Filter by node type"
value={nodeType}
onChange={(e) => setNodeType(e.target.value)}
>
<option value="">All node types</option>
<option value="observation">Observation</option>
<option value="claim">Claim</option>
<option value="concept">Concept</option>
<option value="problem">Problem</option>
<option value="technique">Technique</option>
<option value="case">Case</option>
<option value="entity">Entity</option>
<option value="event">Event</option>
<option value="synthesis">Synthesis</option>
<option value="datapoint">Datapoint</option>
</select>
<button type="submit" disabled={nodesLoading}>Filter</button>
{(query || nodeType) && (
<button
type="button"
className="quiet-button"
onClick={() => {
setQuery('');
setNodeType('');
}}
>
Reset
</button>
)}
</form>
{nodesLoading ? (
<p className="state-card" role="status">Loading referencing knowledge…</p>
) : nodesError ? (
<div className="state-card" role="alert">
<h3>Could not load referencing nodes</h3>
<p>{nodesError}</p>
<button type="button" onClick={() => loadNodes(null, true)}>Retry</button>
</div>
) : nodes.length === 0 ? (
<div className="state-card">
<h3>No referencing knowledge nodes found</h3>
<p>
{query || nodeType
? 'No nodes match your filter criteria for this source.'
: 'This source record is not yet referenced by any accepted knowledge notes in this project scope.'}
</p>
</div>
) : (
<>
<ul className="result-list" aria-label="Referencing knowledge nodes">
{nodes.map(({ node, relation_basis }) => (
<li key={node.node_id}>
<div
className="result-row"
role="button"
tabIndex={0}
onClick={() => onSelectNode(node.node_id, node.project_id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectNode(node.node_id, node.project_id);
}
}}
>
<div className="result-row__meta">
<span className={`node-type-tag node-type--${node.node_type}`}>
{node.node_type}
</span>
<span>·</span>
<span className={`basis-badge basis-badge--${relation_basis}`}>
{relation_basis === 'citation'
? 'Cited in claim'
: relation_basis === 'edge'
? 'Graph relationship'
: 'Cited & Graph edge'}
</span>
</div>
<strong>{node.title}</strong>
<span className="result-row__summary">{node.summary}</span>
<span className="result-row__foot">
As of {node.as_of ?? 'undated'} · {node.lifecycle}
</span>
</div>
</li>
))}
</ul>
<div className="pagination-controls">
<button
type="button"
disabled={nodesLoading || pageCursors.length < 2}
onClick={handlePrevPage}
>
Previous
</button>
<span>
{nodes.length
? `${(pageCursors.length - 1) * 20 + 1}–${(pageCursors.length - 1) * 20 + nodes.length} of ${totalNodes}`
: `0 of ${totalNodes}`}
</span>
<button
type="button"
disabled={nodesLoading || !cursor}
onClick={handleNextPage}
>
Next
</button>
</div>
</>
)}
</section>
</>
) : null}
</article>
);
}