Skip to content
Open
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
36 changes: 34 additions & 2 deletions packages/web/src/contexts/GraphContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { valueFromASTUntyped, type FieldNode, type SelectionSetNode } from 'grap
import { GraphProvider, useGraph } from './GraphContext';
import { EXAMPLE_ITEM_TAG, EXAMPLE_MAX_FAILURES, EXAMPLE_PENDING_TAG, buildStarterWorkspace, exampleFailures } from '../lib/starterWorkspace';
import type { GraphContextType } from '../types/graph';
import { isExampleWrite } from '../lib/exampleOperations';

const user = { id: 'me', role: 'USER', name: 'New user' };
const team = { id: 'team-1', name: 'Team' };
Expand All @@ -33,6 +34,7 @@ function fakeBackend() {
const items: Row[] = [];
const edges: Row[] = [];
const operations: string[] = [];
const mutations: string[] = [];
let next = 0;
let release: () => void = () => undefined;
let held = false;
Expand Down Expand Up @@ -114,7 +116,8 @@ function fakeBackend() {
};
const link = new ApolloLink(operation => new Observable(observer => {
operations.push(operation.operationName);
const definition = getMainDefinition(operation.query) as { selectionSet: SelectionSetNode };
const definition = getMainDefinition(operation.query) as { selectionSet: SelectionSetNode; operation?: string };
if (definition.operation === 'mutation') mutations.push(operation.operationName);
(async () => {
const data: Row = {};
for (const field of definition.selectionSet.selections as FieldNode[]) {
Expand All @@ -125,7 +128,7 @@ function fakeBackend() {
observer.complete();
})().catch(error => observer.error(error));
}));
return { graphs, items, edges, operations, link, release, isHeld: () => held, failing };
return { graphs, items, edges, operations, mutations, link, release, isHeld: () => held, failing };
}

let graph: GraphContextType;
Expand All @@ -147,6 +150,8 @@ const exampleDone = (backend: ReturnType<typeof fakeBackend>) => {
&& localStorage.getItem('graphdone:starter-pending:me') === null;
};
const named = (backend: ReturnType<typeof fakeBackend>, name: string) => backend.graphs.find(g => g.name === name)!;
/** Writes the example made outside EXAMPLE_WRITE_OPERATIONS, which a view that must not write would count as its own. */
const unnamedExampleWrites = (backend: ReturnType<typeof fakeBackend>) => backend.mutations.filter(name => !isExampleWrite(name));

beforeEach(() => localStorage.clear());
afterEach(() => cleanup());
Expand Down Expand Up @@ -192,6 +197,8 @@ describe('a new user while the example project is still being written', () => {

expect(graph.currentGraph?.name).toBe('Welcome');
expect(localStorage.getItem('currentGraphId')).toBe(named(backend, 'Welcome').id);
expect(backend.mutations.length).toBeGreaterThan(0);
expect(unnamedExampleWrites(backend), 'every write the example makes is in EXAMPLE_WRITE_OPERATIONS').toEqual([]);
});

it('reads what it wrote with its own small queries, never the views\' item and connection queries', async () => {
Expand Down Expand Up @@ -231,6 +238,30 @@ describe('a new user while the example project is still being written', () => {
expect(backend.items.some(item => item.id === 'item-zzz'), 'the duplicate is removed').toBe(false);
expect(backend.edges.filter(edge => !backend.items.includes(edge.source as Row) || !backend.items.includes(edge.target as Row)), 'no connection is left without its item').toEqual([]);
expect(backend.items.filter(item => item.graph === launch).map(item => item.title).sort()).toEqual(steps.map(step => step.title).sort());
expect(unnamedExampleWrites(backend), 'every write the resume makes is in EXAMPLE_WRITE_OPERATIONS').toEqual([]);
});

it('removes an empty duplicate graph and clears the tag of a part the example no longer matches, with example writes only', async () => {
const backend = fakeBackend();
const fixBugs = plan.graphs.find(g => g.name === 'Fix bugs')!;
const graphRow = (id: string, name: string, type: string, parentGraphId: string | null, tags: string[], createdAt = '2026-11-09T12:00:00Z') =>
({ __typename: 'Graph', id, name, type, createdBy: 'me', parentGraphId, tags, path: parentGraphId ? [parentGraphId] : [], depth: parentGraphId ? 1 : 0, createdAt });
backend.graphs.push(
graphRow('gs', 'Getting started', 'PROJECT', null, ['example', EXAMPLE_PENDING_TAG]),
graphRow('w', 'Welcome', 'SUBGRAPH', 'gs', ['tutorial', 'welcome', 'getting-started']),
graphRow('fix-a', 'Fix bugs', fixBugs.type, 'gs', ['example', EXAMPLE_PENDING_TAG]),
graphRow('fix-b', 'Fix bugs', fixBugs.type, 'gs', ['example', EXAMPLE_PENDING_TAG], '2026-11-09T12:00:05Z'),
graphRow('renamed', 'My bugs', fixBugs.type, 'gs', ['example', 'mine', EXAMPLE_PENDING_TAG]),
);
localStorage.setItem('graphdone:starter-pending:me', JSON.stringify({ hierarchy: true, startedAt: Date.now(), inflight: [] }));
await start(backend);
await waitFor(() => expect(exampleDone(backend)).toBe(true), { timeout: 10000 });

expect(backend.graphs.filter(g => g.name === 'Fix bugs' && g.parentGraphId === 'gs'), 'the empty duplicate is removed').toHaveLength(1);
expect(backend.graphs.find(g => g.id === 'renamed')?.tags, 'the renamed part keeps its other tags').toEqual(['example', 'mine']);
expect(backend.mutations).toContain('DeleteStarterGraph');
expect(backend.mutations).toContain('UpdateStarterGraph');
expect(unnamedExampleWrites(backend), 'every write the resume makes is in EXAMPLE_WRITE_OPERATIONS').toEqual([]);
});

it('stops resuming a part after three failed tries: clears its tag, keeps its other tags, and leaves what exists', async () => {
Expand Down Expand Up @@ -259,5 +290,6 @@ describe('a new user while the example project is still being written', () => {
expect(backend.graphs.filter(g => (g.tags as string[]).includes(EXAMPLE_PENDING_TAG)).map(g => g.name)).toEqual([]);
expect(backend.operations.slice(before).filter(operation => operation.startsWith('CreateStarter'))).toEqual([]);
expect(localStorage.getItem('graphdone:starter-pending:me')).toBeNull();
expect(unnamedExampleWrites(backend), 'counting failures and giving up are in EXAMPLE_WRITE_OPERATIONS too').toEqual([]);
});
});
17 changes: 11 additions & 6 deletions packages/web/src/contexts/GraphContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
STARTER_OWN_GRAPHS,
UPDATE_GRAPH,
DELETE_GRAPH,
GET_CHILD_GRAPHS
GET_CHILD_GRAPHS,
UPDATE_STARTER_GRAPH,
DELETE_STARTER_GRAPH
} from '../graphql/graph';
import { GET_WORK_ITEMS, DELETE_WORK_ITEM, CREATE_WORK_ITEM, CREATE_EDGE } from '../lib/queries';
import { awaitExampleIdle, buildStarterWorkspace, createStarterWorkspace, exampleIdleMs, examplePendingParts, starterGraphCount, withExampleFailure, withoutPendingTag, type StarterApi, type StarterJournal } from '../lib/starterWorkspace';
Expand Down Expand Up @@ -110,6 +112,9 @@ export function GraphProvider({ children }: GraphProviderProps) {
const [createStarterGraphsMutation] = useMutation(CREATE_STARTER_GRAPHS);
const [updateGraphMutation] = useMutation(UPDATE_GRAPH);
const [deleteGraphMutation] = useMutation(DELETE_GRAPH);
// Every write the example makes is a …Starter… operation; see UPDATE_STARTER_GRAPH.
const [updateStarterGraph] = useMutation(UPDATE_STARTER_GRAPH);
const [deleteStarterGraph] = useMutation(DELETE_STARTER_GRAPH);
const [deleteWorkItemMutation] = useMutation(DELETE_WORK_ITEM);
const [createWorkItemMutation] = useMutation(CREATE_WORK_ITEM);
const [createEdgeMutation] = useMutation(CREATE_EDGE);
Expand Down Expand Up @@ -223,7 +228,7 @@ export function GraphProvider({ children }: GraphProviderProps) {
});
},
setCounts: async (graphId, { nodeCount, edgeCount, tags }) => {
await updateGraphMutation({
await updateStarterGraph({
variables: { id: graphId, input: { nodeCount, edgeCount, lastActivity: new Date().toISOString(), ...(tags && { tags }) } }
});
},
Expand All @@ -236,7 +241,7 @@ export function GraphProvider({ children }: GraphProviderProps) {
await apolloClient.mutate({ mutation: DELETE_STARTER_CONTENT, variables: { items, edges }, fetchPolicy: 'no-cache' });
},
setTags: async (graphId, tags) => {
await updateGraphMutation({ variables: { id: graphId, input: { tags } } });
await updateStarterGraph({ variables: { id: graphId, input: { tags } } });
},
idle: yieldToView(),
filled: async graphId => {
Expand All @@ -253,7 +258,7 @@ export function GraphProvider({ children }: GraphProviderProps) {
const created = await createStarterWorkspace(plan, {
...api,
deleteGraph: async graphId => {
await deleteGraphMutation({ variables: { id: graphId } });
await deleteStarterGraph({ variables: { id: graphId } });
},
opened: async graphId => {
const { data } = await refetchGraphs();
Expand Down Expand Up @@ -286,7 +291,7 @@ export function GraphProvider({ children }: GraphProviderProps) {
const countExampleFailure = async (userId: string) => {
const { data } = await apolloClient.query({ query: STARTER_OWN_GRAPHS, variables: { userId }, fetchPolicy: 'no-cache' });
for (const graph of examplePendingParts(data?.graphs || []).open) {
await updateGraphMutation({ variables: { id: graph.id, input: { tags: withExampleFailure(graph) } } });
await updateStarterGraph({ variables: { id: graph.id, input: { tags: withExampleFailure(graph) } } });
}
};

Expand All @@ -297,7 +302,7 @@ export function GraphProvider({ children }: GraphProviderProps) {
const local = readStarterPending(userId);
const { open, abandoned } = examplePendingParts(offlineEnabled ? [] : data?.userGraphs || []);
for (const graph of abandoned) {
await updateGraphMutation({ variables: { id: graph.id, input: { tags: withoutPendingTag(graph.tags) } } });
await updateStarterGraph({ variables: { id: graph.id, input: { tags: withoutPendingTag(graph.tags) } } });
}
// On the self-hosted server the backend says what is unfinished; this browser's marker only covers a first write still in flight.
const unfinished = offlineEnabled ? !!local : open.length > 0 || (!!local?.inflight.length && seenGraphCount(data) === 0);
Expand Down
28 changes: 28 additions & 0 deletions packages/web/src/graphql/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,34 @@ export const UPDATE_GRAPH = gql`
}
`;

/**
* The example workspace's own graph writes (counts, tags, removing an empty
* duplicate). Named apart from the views' UpdateGraph and DeleteGraph so every
* write the example makes is a `…Starter…` operation, which a view that must
* not write (and its tests) can tell from its own.
*/
export const UPDATE_STARTER_GRAPH = gql`
mutation UpdateStarterGraph($id: ID!, $input: GraphUpdateInput!) {
updateGraphs(where: { id: $id }, update: $input) {
graphs {
id
tags
nodeCount
edgeCount
lastActivity
}
}
}
`;

export const DELETE_STARTER_GRAPH = gql`
mutation DeleteStarterGraph($id: ID!) {
deleteGraphs(where: { id: $id }) {
nodesDeleted
}
}
`;

/** The graphs inside a graph, asked of the backend before a delete rather than read from a list that may be stale. */
export const GET_CHILD_GRAPHS = gql`
query GetChildGraphs($id: String!) {
Expand Down
17 changes: 17 additions & 0 deletions packages/web/src/lib/exampleOperations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Every write the example workspace makes, by GraphQL operation name. Nothing
* else uses these names, so a view that must not write (and its tests) can let
* the example finish in the background and still count every other write.
* GraphContext's tests check that the example sends no write outside this list.
*/
export const EXAMPLE_WRITE_OPERATIONS = [
'CreateStarterGraphs',
'CreateStarterItems',
'CreateStarterEdges',
'DeleteStarterContent',
'UpdateStarterGraph',
'DeleteStarterGraph',
] as const;

export const isExampleWrite = (operationName: string | null | undefined): boolean =>
!!operationName && (EXAMPLE_WRITE_OPERATIONS as readonly string[]).includes(operationName);
3 changes: 2 additions & 1 deletion tests/e2e/graph/inspector-readability.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ownedGraphIdsFixture,
type OwnedGraphs,
} from '../../lib/ownedGraphIdsFixture';
import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations';

const test = base.extend<{ ownedGraphIds: OwnedGraphs }>({
ownedGraphIds: [ownedGraphIdsFixture, { timeout: 60000 }],
Expand Down Expand Up @@ -136,7 +137,7 @@ for (const width of [1440, 390]) {
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/graphql', async route => {
const body = route.request().postDataJSON();
if (/^\s*mutation\b/.test(body?.query ?? '')) {
if (/^\s*mutation\b/.test(body?.query ?? '') && !isExampleWrite(body?.operationName)) {
writes.push(body.operationName ?? 'mutation');
await route.abort('blockedbyclient');
} else await route.continue();
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/graph/item-symbols.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
inspectItemSymbols,
readItemSymbols,
} from '../../lib/itemSymbolVisual';
import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations';

const test = base.extend<{ ownedGraphIds: OwnedGraphs }>({
ownedGraphIds: [ownedGraphIdsFixture, { timeout: 60000 }],
Expand Down Expand Up @@ -68,7 +69,8 @@ for (const { width, diagonal } of [
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/graphql', async route => {
if (
/^\s*mutation\b/.test(route.request().postDataJSON()?.query ?? '')
/^\s*mutation\b/.test(route.request().postDataJSON()?.query ?? '') &&
!isExampleWrite(route.request().postDataJSON()?.operationName)
) {
writes.push(
route.request().postDataJSON().operationName ?? 'mutation'
Expand Down
5 changes: 3 additions & 2 deletions tests/e2e/graph/item-wire-consumers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ownedGraphIdsFixture,
type OwnedGraphs,
} from '../../lib/ownedGraphIdsFixture';
import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations';

const test = base.extend<{ ownedGraphIds: OwnedGraphs }>({
ownedGraphIds: [ownedGraphIdsFixture, { timeout: 60000 }],
Expand Down Expand Up @@ -46,7 +47,7 @@ test('item wire paths, hitboxes, arrows and dragged labels retain original relat
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/graphql', async route => {
const body = route.request().postDataJSON();
if (/^\s*mutation\b/.test(body?.query ?? '')) {
if (/^\s*mutation\b/.test(body?.query ?? '') && !isExampleWrite(body?.operationName)) {
writes.push(body.operationName || 'mutation');
await route.abort('blockedbyclient');
} else await route.continue();
Expand Down Expand Up @@ -398,7 +399,7 @@ test('initially hidden item labels retain full conservative sizing @item-wires',
writes: string[] = [];
await page.route('**/api/graphql', async route => {
const body = route.request().postDataJSON();
if (/^\s*mutation\b/.test(body?.query ?? '')) {
if (/^\s*mutation\b/.test(body?.query ?? '') && !isExampleWrite(body?.operationName)) {
writes.push(body.operationName || 'mutation');
await route.abort('blockedbyclient');
} else await route.continue();
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/graph/large-graph-symbols.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { inspectItemSymbols } from '../../lib/itemSymbolVisual';
import { inspectOverviewBus } from '../../lib/overviewBusInspection';
import { inspectSchematicOverview } from '../../lib/schematicOverview';
import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations';

const SIZE = 2000;
const test = base.extend<{
Expand Down Expand Up @@ -96,7 +97,7 @@ test('2000 loaded items and 5000 relationships survive overview and detail zoom
errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/graphql', async route => {
if (/^\s*mutation\b/.test(route.request().postDataJSON()?.query ?? '')) {
if (/^\s*mutation\b/.test(route.request().postDataJSON()?.query ?? '') && !isExampleWrite(route.request().postDataJSON()?.operationName)) {
writes.push(route.request().postDataJSON().operationName ?? 'mutation');
await route.abort();
} else await route.continue();
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/graph/local-item-lod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ownedGraphIdsFixture,
type OwnedGraphs,
} from '../../lib/ownedGraphIdsFixture';
import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations';

type Item = {
id: string;
Expand Down Expand Up @@ -157,7 +158,7 @@ for (const [width, height] of [
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/graphql', async route => {
const body = route.request().postDataJSON();
if (!/^\s*mutation\b/.test(body?.query ?? '')) return route.continue();
if (!/^\s*mutation\b/.test(body?.query ?? '') || isExampleWrite(body?.operationName)) return route.continue();
const variables = body.variables;
if (
allowedEdit &&
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/graph/nested-semantic-lod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../../lib/ownedGraphIdsFixture';
import type { SeededGraph } from '../../lib/seedGraph';
import { inspectSchematicOverview } from '../../lib/schematicOverview';
import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations';

const test = base.extend<{
ownedGraphIds: OwnedGraphs;
Expand Down Expand Up @@ -111,7 +112,7 @@ for (const width of [1440, 390])
expect(before.edges).toHaveLength(seeded.edgeCount);
await page.route('**/api/graphql', async route => {
const body = route.request().postDataJSON();
if (/^\s*mutation\b/.test(body?.query ?? '')) {
if (/^\s*mutation\b/.test(body?.query ?? '') && !isExampleWrite(body?.operationName)) {
writes.push(body.operationName || 'mutation');
await route.abort('blockedbyclient');
} else await route.continue();
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/graph/relationship-priority.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ownedGraphIdsFixture,
type OwnedGraphs,
} from '../../lib/ownedGraphIdsFixture';
import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations';

const test = base.extend<{ ownedGraphIds: OwnedGraphs }>({
ownedGraphIds: [ownedGraphIdsFixture, { timeout: 60000 }],
Expand Down Expand Up @@ -91,7 +92,7 @@ for (const width of [1440, 390]) {
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/graphql', async route => {
const body = route.request().postDataJSON();
if (/^\s*mutation\b/.test(body?.query ?? '')) {
if (/^\s*mutation\b/.test(body?.query ?? '') && !isExampleWrite(body?.operationName)) {
writes.push({
operation: body.operationName,
variables: body.variables,
Expand Down
7 changes: 6 additions & 1 deletion tests/e2e/graph/semantic-lod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ownedGraphIdsFixture,
type OwnedGraphs,
} from '../../lib/ownedGraphIdsFixture';
import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations';

// Cold Neo4j query planning belongs to fixture setup, not the interaction
// budget. Both budgets are bounded; slow clicks and assertions still fail.
Expand Down Expand Up @@ -122,7 +123,11 @@ for (const width of [1440, 390])
const originalEdges = (await gql(page, 'query($id:ID!){edges(where:{source:{graph:{id:$id}}}){id type source{id} target{id}}}', { id: seeded.graphId })).edges;
await page.route('**/api/graphql', async route => {
const body = route.request().postDataJSON();
if (/^\s*mutation\b/.test(body?.query ?? '')) {
// The example workspace may still be finishing for this shared user in
// the background (a context an earlier spec closed left it unfinished).
// Its writes are all …Starter… operations, never navigation, so they
// pass; every other write is navigation writing, and fails the test.
if (/^\s*mutation\b/.test(body?.query ?? '') && !isExampleWrite(body?.operationName)) {
attemptedWrites.push(body.operationName ?? 'unnamed mutation');
await route.abort('blockedbyclient');
} else await route.continue();
Expand Down
Loading