Skip to content
Draft
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
540 changes: 540 additions & 0 deletions cypress/e2e/graph-audit.cy.ts

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions cypress/e2e/graph-parity.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Visual-parity captures against the Figma design frames (1280x720).
* Not an assertion suite: the screenshots land in cypress/screenshots for a
* side-by-side review next to the design renders.
*/

const PUBKY = Cypress.env('GRAPH_PUBKY') ?? 'gujx6qd8ksydh1makdphd3bxu351d9b8waqka8hfg6q7hnqkxexo';

describe('graph visual parity captures', () => {
it('captures the default explorer view at the design frame size', () => {
cy.viewport(1280, 720);
cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood');
cy.visit(`/graph?user=${PUBKY}`);
cy.wait('@neighborhood', { timeout: 30000 });
cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist');
// Let the data land, the simulation settle, and avatars/chips rasterize.
// settled() is briefly true on the empty pre-data engine, so gate on
// nodes being present too.
cy.window({ timeout: 60000 }).should((win) => {
const dbg = win.__graphDebug;
expect(dbg?.nodeIds().user.length ?? 0, 'users on canvas').to.be.greaterThan(0);
expect(dbg?.settled(), 'simulation settled').to.eq(true);
});
cy.wait(2000);
// cy.screenshot captures GPU-composited canvases as black in headless
// Chrome; dump the bitmap straight from the canvas instead
cy.window().then((win) => {
const canvas = win.document.querySelector('[data-cy="social-graph"] canvas') as HTMLCanvasElement;
cy.writeFile('screenshots/parity-canvas-1280.png.b64', canvas.toDataURL('image/png').split(',')[1]);
});
cy.screenshot('parity-default-1280', { capture: 'viewport', overwrite: true });
});

it('captures the mobile layout', () => {
cy.viewport(390, 844);
cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood');
cy.visit(`/graph?user=${PUBKY}`);
cy.wait('@neighborhood', { timeout: 30000 });
cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist');
cy.wait(3000);
cy.screenshot('parity-mobile-390', { capture: 'viewport', overwrite: true });
});
});

export {};
24 changes: 24 additions & 0 deletions cypress/e2e/graph-public.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Public deep-link exploration: /graph is an explore route, so a signed-out
// visitor can inspect any user's neighborhood via ?user=<pubky>.
describe('graph explorer (public deep link)', () => {
it('loads a neighborhood from nexus and renders the canvas', () => {
// Any pubky known to the connected Nexus; overridable per environment
const pubky = Cypress.env('GRAPH_PUBKY') ?? 'gujx6qd8ksydh1makdphd3bxu351d9b8waqka8hfg6q7hnqkxexo';

cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood');
cy.visit(`/graph?user=${pubky}`);

cy.get('[data-cy="graph-page"]').should('exist');
cy.wait('@neighborhood', { timeout: 30000 }).its('response.statusCode').should('eq', 200);

// The canvas mounts once data is in
cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist');
cy.get('[data-cy="graph-controls"]').should('be.visible');
// Design default: no on-canvas legend; signed out also means no recenter pill
cy.get('[data-cy="graph-legend"]').should('not.exist');
cy.get('[data-cy="graph-recenter"]').should('not.exist');
// The legend still exists behind the advanced popover
cy.get('[data-cy="graph-advanced"]').click();
cy.get('[data-cy="graph-legend"]').should('be.visible');
});
});
139 changes: 139 additions & 0 deletions cypress/e2e/graph-signed-in.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { BackupType } from '../support/types/enums';

/**
* Signed-in graph surface against a live stack: hover-card actions and the
* how-are-we-connected mode. A fresh throwaway account exists only on the
* homeserver, so the path endpoint is stubbed with a canned payload; that
* still exercises the full frontend path machinery (trace entry, exclusive
* filtering, exit). Real path data and the feed graph layout need an
* indexed account and stay on the CI/manual pass.
*/

const CENTER = Cypress.env('GRAPH_PUBKY') ?? 'gujx6qd8ksydh1makdphd3bxu351d9b8waqka8hfg6q7hnqkxexo';
const MID = 'nkcct8tzquo8n4z5ysz9t963ye9kq1w7gb55aad1z4tmsgjjhmto';

type DebugSurface = NonNullable<Window['__graphDebug']>;

function visiblePos(win: Window, dbg: DebugSurface, id: string): { x: number; y: number } | null {
const pos = dbg.screenPositionOf(id);
if (!pos) return null;
const canvas = win.document.querySelector('[data-cy="social-graph"] canvas');
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
if (pos.x < 8 || pos.y < 8 || pos.x > rect.width - 8 || pos.y > rect.height - 8) return null;
return { x: rect.left + pos.x, y: rect.top + pos.y };
}

function hoverAt(x: number, y: number) {
cy.get('[data-cy="social-graph"] canvas')
.trigger('pointermove', { clientX: x + 2, clientY: y + 2, pageX: x + 2, pageY: y + 2, force: true })
.trigger('mousemove', { clientX: x + 2, clientY: y + 2, pageX: x + 2, pageY: y + 2, force: true })
.trigger('pointermove', { clientX: x, clientY: y, pageX: x, pageY: y, force: true })
.trigger('mousemove', { clientX: x, clientY: y, pageX: x, pageY: y, force: true });
}

function hoverResolve(id: string, tries: number, out: { ok: boolean; x: number; y: number }) {
cy.window().then((w) => {
if (out.ok || tries <= 0) return;
const p = visiblePos(w, w.__graphDebug!, id);
if (!p) return;
const hx = p.x + (tries % 3);
const hy = p.y - (tries % 2);
hoverAt(hx, hy);
cy.wait(450);
cy.window().then((w2) => {
const hovered = w2.__graphDebug?.hoveredId() ?? null;
if (hovered === id) {
out.ok = true;
out.x = hx;
out.y = hy;
return;
}
if (hovered !== null) return;
hoverResolve(id, tries - 1, out);
});
});
}

describe('graph signed-in surface', () => {
it('hover card offers follow + how-connected, and path mode filters exclusively', () => {
cy.onboardAsNewUser('Graph QA', 'temporary audit account', [BackupType.RecoveryPhraseWithoutConfirmation]);

cy.window().then((win) => {
// Canned shortest path: me -> mid -> center, using real clone users for
// the intermediate hops so satellites/avatars behave
const mePk = win.localStorage.getItem('pubky') ?? '';
cy.intercept('GET', '**/v0/graph/path/**', (req) => {
const me = decodeURIComponent(req.url.split('/v0/graph/path/')[1].split('/')[0]);
req.reply({
nodes: [
{ kind: 'user', id: `user:${me}`, pubky: me, name: 'Me', image: null },
{ kind: 'user', id: `user:${MID}`, pubky: MID, name: 'Mid', image: null },
{ kind: 'user', id: `user:${CENTER}`, pubky: CENTER, name: 'Center', image: null },
],
edges: [
{ source: `user:${me}`, target: `user:${MID}`, type: 'FOLLOWS' },
{ source: `user:${MID}`, target: `user:${CENTER}`, type: 'FOLLOWS' },
],
});
}).as('path');
void mePk;
});

cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood');
cy.visit(`/graph?user=${CENTER}`);
cy.wait('@neighborhood', { timeout: 30000 });
cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist');
cy.window({ timeout: 60000 }).should((win) => {
expect(win.__graphDebug?.nodeIds().user.length ?? 0).to.be.greaterThan(0);
expect(win.__graphDebug?.settled()).to.eq(true);
});
cy.window().then((win) => {
win.__graphDebug?.setPaused(true);
});
cy.wait(300);

// Signed in: the recenter-to-me pill exists
cy.get('[data-cy="graph-recenter"]').should('exist');

// Hover a user: card shows the signed-in action set
cy.window().then((win) => {
const dbg = win.__graphDebug!;
const candidates = dbg.nodeIds().user.filter((id) => visiblePos(win, dbg, id) !== null);
const state = { done: false };
for (const id of candidates.slice(0, 6)) {
cy.then(() => {
if (state.done) return;
const out = { ok: false, x: 0, y: 0 };
hoverResolve(id, 4, out);
cy.then(() => {
if (!out.ok || state.done) return;
state.done = true;
cy.get('[data-cy="graph-hover-card"]', { timeout: 10000 }).should('exist');
cy.get('[data-cy="graph-hover-trace"]', { timeout: 10000 }).should('exist');
// Enter path mode through the design's entry point
cy.get('[data-cy="graph-hover-trace"]').click();
cy.wait('@path', { timeout: 15000 });
// Exclusive view: only the canned path users (+ their satellites)
cy.window({ timeout: 15000 }).should((w3) => {
const users = w3.__graphDebug?.nodeIds().user ?? [];
expect(users.length, `path mode shows only path users (${users.length})`).to.be.within(2, 3);
expect(w3.__graphDebug?.pathIds(), 'pathIds set').to.not.eq(null);
});
cy.get('[data-cy="graph-path-exit"]').should('exist');
cy.get('[data-cy="graph-path-exit"]').click();
cy.window({ timeout: 15000 }).should((w4) => {
expect(w4.__graphDebug?.pathIds(), 'path cleared').to.eq(null);
expect(w4.__graphDebug?.nodeIds().user.length ?? 0, 'full view restored').to.be.greaterThan(3);
});
});
});
}
cy.then(() => {
expect(state.done, 'a hover candidate resolved').to.eq(true);
});
});
});
});

export {};
30 changes: 30 additions & 0 deletions cypress/e2e/graph.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { slowCypressDown } from 'cypress-slow-down';
import { BackupType } from '../support/types/enums';

describe('graph explorer', () => {
before(() => {
slowCypressDown();
cy.deleteDownloadsFolder();
});

it('renders the graph page shell with the pill controls and advanced legend', () => {
cy.onboardAsNewUser('Graph Explorer', 'I map the social universe', [BackupType.RecoveryPhraseWithoutConfirmation]);

cy.visit('/graph');
cy.get('[data-cy="graph-page"]').should('exist');
cy.get('[data-cy="graph-controls"]').should('be.visible');
// The design's default view carries no legend; it lives in the advanced popover
cy.get('[data-cy="graph-legend"]').should('not.exist');

// A fresh account has no follows: the empty state invites growing the graph
cy.contains('Nothing to explore yet', { timeout: 20000 }).should('be.visible');

// Advanced popover hosts the legend; its rows double as class toggles
cy.get('[data-cy="graph-advanced"]').click();
cy.get('[data-cy="graph-advanced-panel"]').should('be.visible');
cy.get('[data-cy="graph-legend"]').should('be.visible');
cy.get('[data-cy="graph-legend-post"]').should('have.attr', 'aria-pressed', 'true');
cy.get('[data-cy="graph-legend-post"]').click();
cy.get('[data-cy="graph-legend-post"]').should('have.attr', 'aria-pressed', 'false');
});
});
16 changes: 16 additions & 0 deletions cypress/support/types/graph-debug.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// QA surface published by debug builds (see src/hooks/useGraphDebug); the
// cypress TS project does not include src, so the shape is mirrored here.
interface Window {
__graphDebug?: {
nodeIds: () => Record<'user' | 'post' | 'tag' | 'profile_tag', string[]>;
screenPositionOf: (nodeId: string) => { x: number; y: number } | null;
screenMidpointOf: (aId: string, bId: string) => { x: number; y: number } | null;
pinnedIds: () => string[];
settled: () => boolean;
zoom: () => number | null;
hoveredId: () => string | null;
focusId: () => string | null;
pathIds: () => string[] | null;
setPaused: (paused: boolean) => void;
};
}
37 changes: 37 additions & 0 deletions docs/graph-explorer-experiment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Graph Explorer experiment (`experiment/graph-viz`)

An experimental social-graph exploration feature spanning two branches:

- **franky** `experiment/graph-viz`: the `/graph` explorer page + a "Graph" feed layout.
- **pubky-nexus** `experiment/graph-viz`: the graph API it talks to.

## What you get

- **`/graph`** (top-nav Waypoints icon, or `/graph?user=<pubky>` deep link, public explore route): an ego-centric canvas of users, posts, and tags. Click = inspector panel (real post cards, reply-in-place, follow, social proof). Double-click = expand that node's neighborhood. Hover a user = profile card. Legend rows filter classes; controls hold declutter, communities (Louvain), the time machine, and physics pause/pinning. "How am I connected?" traces the shortest follow path with particles.
- **Edge colors carry data**: follows touching the focused user keep relationship colors (the legend palette); follows between neighbors fade by age (fresh = warm bright, old = gray); with communities on, intra-community edges take the community tint and bridges between communities stay bright neutral. Tag edges use their label's color, and their count chips are tap targets. The legend teaches this: a gradient row for follow age, plus community/bridge rows whenever communities mode is on, and hovering any of them spotlights the matching edges on the canvas.
- **Feed "Graph" layout** (left sidebar layout switcher on Home/Custom/Search, desktop): renders the current stream as a constellation. Authors carry their posts, replies/reposts draw lineage chains, the stream's hottest tags become hubs. "Merge more" grows the graph a page at a time; the time machine replays the feed assembling.

## Backend endpoints (nexus)

- `GET /v0/graph/{kind}/{id}` with `kind` in `user|post|tag`; params `depth` (1..2, user centers only), `limit` (1..50, default 30), `kinds` csv filter. Node ids are prefixed (`user:{pubky}`, `post:{author}:{post_id}`, `tag:{label}`); FOLLOWS/TAGGED edges carry `indexed_at`.
- `GET /v0/graph/path/{from}/{to}`: undirected shortest FOLLOWS path, max 6 hops, nodes path-ordered.
- `nexusd db reindex`: rebuilds the Redis index from the Neo4j graph (flushes Redis first; connection settings from `config.toml` in the config dir). You need this once after restoring a graph backup, or feed streams come back empty.

## Running it locally against a production clone

1. Check out both `experiment/graph-viz` branches.
2. Restore a Neo4j backup into the compose volume (`docker/.database/neo4j/data`; stop the container first, `chown -R 7474:7474` after extracting). Set the backup's password in `~/.pubky-nexus/config.toml` AND `~/.pubky-nexus/migrations/config.toml`.
3. `cargo run -p nexusd -- db reindex` (populates Redis), then `cargo run -p nexusd -- api` (port 8080).
4. In franky `.env`: `NEXT_PUBLIC_NEXUS_URL=http://localhost:8080` and make sure `NEXT_PUBLIC_PKARR_RELAYS` is the JSON-array form.
5. `npm run dev` and open `/graph?user=<any pubky in the clone>`.

For the mock dataset instead: `cargo run -p nexusd -- db mock`, then use the fixture user `4snwyct86m383rsduhw5xgcxpw7c63j3pq8x4ycqikxgik8y64ro`.

## The two-minute demo

Sign in, switch Home to Following, flip the layout to Graph, hit the time machine's play button and watch the week assemble. Then open `/graph`, double-click a friend, hover people, select a post and reply to it from the panel, and hit "How am I connected?" on a stranger.

## Tests

- Nexus: `cargo nextest run -p nexus-webapi` (needs the mock dataset loaded; 13 graph tests among 508).
- Franky: `npm test` (synthesizer, hooks, canvas, chrome all covered); `cypress/e2e/graph-public.cy.ts` runs against a live stack.
Loading