From 35db991a6dca29e621c2c5ae4aca705e63e5d4d0 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 11 Nov 2026 05:00:44 -0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20name=20every=20example=20write=20?= =?UTF-8?q?=E2=80=A6Starter=E2=80=A6,=20so=20semantic-lod's=20no-writes=20?= =?UTF-8?q?check=20ignores=20only=20those=20(#277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit semantic-lod failed once on CI (#281, run 42995673129 attempt 1): its no-writes window saw CreateStarterItems and UpdateGraph. The shared admin user's example workspace was left unfinished by a context an earlier spec closed, and the next sign-in finished it in the background, as designed. The example's graph tag and count updates and its duplicate delete used the views' UpdateGraph/DeleteGraph, so nothing could tell them from navigation writes. They now use UpdateStarterGraph/DeleteStarterGraph, every write the example makes is a …Starter… operation, and semantic-lod lets those through while still blocking and counting every other write. GraphContext tests assert it on the create, resume-with-duplicates and failure-count paths; all three fail on the previous GraphContext. Co-Authored-By: Claude Opus 5.5 --- .../web/src/contexts/GraphContext.test.tsx | 12 ++++++-- packages/web/src/contexts/GraphContext.tsx | 17 +++++++---- packages/web/src/graphql/graph.ts | 28 +++++++++++++++++++ tests/e2e/graph/semantic-lod.spec.ts | 6 +++- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/web/src/contexts/GraphContext.test.tsx b/packages/web/src/contexts/GraphContext.test.tsx index 17e313fc..3fdcd3bd 100644 --- a/packages/web/src/contexts/GraphContext.test.tsx +++ b/packages/web/src/contexts/GraphContext.test.tsx @@ -33,6 +33,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; @@ -114,7 +115,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[]) { @@ -125,7 +127,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; @@ -147,6 +149,8 @@ const exampleDone = (backend: ReturnType) => { && localStorage.getItem('graphdone:starter-pending:me') === null; }; const named = (backend: ReturnType, name: string) => backend.graphs.find(g => g.name === name)!; +/** Writes the example made under a view's own operation name, which a view that must not write would count as its own. */ +const unnamedExampleWrites = (backend: ReturnType) => backend.mutations.filter(name => !/Starter/.test(name)); beforeEach(() => localStorage.clear()); afterEach(() => cleanup()); @@ -192,6 +196,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 a …Starter… operation').toEqual([]); }); it('reads what it wrote with its own small queries, never the views\' item and connection queries', async () => { @@ -231,6 +237,7 @@ 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 a …Starter… operation').toEqual([]); }); it('stops resuming a part after three failed tries: clears its tag, keeps its other tags, and leaves what exists', async () => { @@ -259,5 +266,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 …Starter… operations too').toEqual([]); }); }); diff --git a/packages/web/src/contexts/GraphContext.tsx b/packages/web/src/contexts/GraphContext.tsx index 143c1ccd..238cce00 100644 --- a/packages/web/src/contexts/GraphContext.tsx +++ b/packages/web/src/contexts/GraphContext.tsx @@ -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'; @@ -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); @@ -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 }) } } }); }, @@ -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 => { @@ -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(); @@ -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) } } }); } }; @@ -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); diff --git a/packages/web/src/graphql/graph.ts b/packages/web/src/graphql/graph.ts index ee5bf4f6..98c11cd9 100644 --- a/packages/web/src/graphql/graph.ts +++ b/packages/web/src/graphql/graph.ts @@ -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!) { diff --git a/tests/e2e/graph/semantic-lod.spec.ts b/tests/e2e/graph/semantic-lod.spec.ts index aed7c641..2c1b778d 100644 --- a/tests/e2e/graph/semantic-lod.spec.ts +++ b/tests/e2e/graph/semantic-lod.spec.ts @@ -122,7 +122,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 ?? '') && !/Starter/.test(body?.operationName ?? '')) { attemptedWrites.push(body.operationName ?? 'unnamed mutation'); await route.abort('blockedbyclient'); } else await route.continue(); From 2b27dacdaf38a38a688c314661ca18889ed87052 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 11 Nov 2026 05:16:54 -0800 Subject: [PATCH 2/3] test: one exact list of example writes, used by every no-writes spec (#277) Review of #286: - EXAMPLE_WRITE_OPERATIONS (lib/exampleOperations.ts) names the six writes the example makes; isExampleWrite matches them exactly, not a substring. - Eight specs that block and count writes for the shared admin user let those through and still count every other write: semantic-lod, nested-semantic-lod, inspector-readability, item-wire-consumers (2), local-item-lod, item-symbols, large-graph-symbols, relationship-priority. Blocking the example there could also be what left it unfinished for the next spec. - A GraphContext test resumes with an empty duplicate graph and a renamed pending part, covering DeleteStarterGraph and setTags; reverting either call to the views' operation now fails it (both checked). Co-Authored-By: Claude Opus 5.5 --- .../web/src/contexts/GraphContext.test.tsx | 34 ++++++++++++++++--- packages/web/src/lib/exampleOperations.ts | 17 ++++++++++ tests/e2e/graph/inspector-readability.spec.ts | 3 +- tests/e2e/graph/item-symbols.spec.ts | 4 ++- tests/e2e/graph/item-wire-consumers.spec.ts | 5 +-- tests/e2e/graph/large-graph-symbols.spec.ts | 3 +- tests/e2e/graph/local-item-lod.spec.ts | 3 +- tests/e2e/graph/nested-semantic-lod.spec.ts | 3 +- tests/e2e/graph/relationship-priority.spec.ts | 3 +- tests/e2e/graph/semantic-lod.spec.ts | 3 +- 10 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 packages/web/src/lib/exampleOperations.ts diff --git a/packages/web/src/contexts/GraphContext.test.tsx b/packages/web/src/contexts/GraphContext.test.tsx index 3fdcd3bd..83246995 100644 --- a/packages/web/src/contexts/GraphContext.test.tsx +++ b/packages/web/src/contexts/GraphContext.test.tsx @@ -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' }; @@ -149,8 +150,8 @@ const exampleDone = (backend: ReturnType) => { && localStorage.getItem('graphdone:starter-pending:me') === null; }; const named = (backend: ReturnType, name: string) => backend.graphs.find(g => g.name === name)!; -/** Writes the example made under a view's own operation name, which a view that must not write would count as its own. */ -const unnamedExampleWrites = (backend: ReturnType) => backend.mutations.filter(name => !/Starter/.test(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) => backend.mutations.filter(name => !isExampleWrite(name)); beforeEach(() => localStorage.clear()); afterEach(() => cleanup()); @@ -197,7 +198,7 @@ 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 a …Starter… operation').toEqual([]); + 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 () => { @@ -237,7 +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 a …Starter… operation').toEqual([]); + 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 () => { @@ -266,6 +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 …Starter… operations too').toEqual([]); + expect(unnamedExampleWrites(backend), 'counting failures and giving up are in EXAMPLE_WRITE_OPERATIONS too').toEqual([]); }); }); diff --git a/packages/web/src/lib/exampleOperations.ts b/packages/web/src/lib/exampleOperations.ts new file mode 100644 index 00000000..938f9bfa --- /dev/null +++ b/packages/web/src/lib/exampleOperations.ts @@ -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); diff --git a/tests/e2e/graph/inspector-readability.spec.ts b/tests/e2e/graph/inspector-readability.spec.ts index af5e6a34..6babec36 100644 --- a/tests/e2e/graph/inspector-readability.spec.ts +++ b/tests/e2e/graph/inspector-readability.spec.ts @@ -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 }], @@ -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(); diff --git a/tests/e2e/graph/item-symbols.spec.ts b/tests/e2e/graph/item-symbols.spec.ts index 942bb7a4..2cd4b0fa 100644 --- a/tests/e2e/graph/item-symbols.spec.ts +++ b/tests/e2e/graph/item-symbols.spec.ts @@ -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 }], @@ -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' diff --git a/tests/e2e/graph/item-wire-consumers.spec.ts b/tests/e2e/graph/item-wire-consumers.spec.ts index 49994a3c..49edeafa 100644 --- a/tests/e2e/graph/item-wire-consumers.spec.ts +++ b/tests/e2e/graph/item-wire-consumers.spec.ts @@ -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 }], @@ -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(); @@ -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(); diff --git a/tests/e2e/graph/large-graph-symbols.spec.ts b/tests/e2e/graph/large-graph-symbols.spec.ts index 31513aac..afebb382 100644 --- a/tests/e2e/graph/large-graph-symbols.spec.ts +++ b/tests/e2e/graph/large-graph-symbols.spec.ts @@ -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<{ @@ -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(); diff --git a/tests/e2e/graph/local-item-lod.spec.ts b/tests/e2e/graph/local-item-lod.spec.ts index a9eb0a03..c74b2e04 100644 --- a/tests/e2e/graph/local-item-lod.spec.ts +++ b/tests/e2e/graph/local-item-lod.spec.ts @@ -11,6 +11,7 @@ import { ownedGraphIdsFixture, type OwnedGraphs, } from '../../lib/ownedGraphIdsFixture'; +import { isExampleWrite } from '../../packages/web/src/lib/exampleOperations'; type Item = { id: string; @@ -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 && diff --git a/tests/e2e/graph/nested-semantic-lod.spec.ts b/tests/e2e/graph/nested-semantic-lod.spec.ts index b3b1ea61..f9179f8b 100644 --- a/tests/e2e/graph/nested-semantic-lod.spec.ts +++ b/tests/e2e/graph/nested-semantic-lod.spec.ts @@ -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; @@ -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(); diff --git a/tests/e2e/graph/relationship-priority.spec.ts b/tests/e2e/graph/relationship-priority.spec.ts index 8cfc9aaa..713d06fb 100644 --- a/tests/e2e/graph/relationship-priority.spec.ts +++ b/tests/e2e/graph/relationship-priority.spec.ts @@ -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 }], @@ -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, diff --git a/tests/e2e/graph/semantic-lod.spec.ts b/tests/e2e/graph/semantic-lod.spec.ts index 2c1b778d..44e8ff9c 100644 --- a/tests/e2e/graph/semantic-lod.spec.ts +++ b/tests/e2e/graph/semantic-lod.spec.ts @@ -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. @@ -126,7 +127,7 @@ for (const width of [1440, 390]) // 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 ?? '') && !/Starter/.test(body?.operationName ?? '')) { + if (/^\s*mutation\b/.test(body?.query ?? '') && !isExampleWrite(body?.operationName)) { attemptedWrites.push(body.operationName ?? 'unnamed mutation'); await route.abort('blockedbyclient'); } else await route.continue(); From 81d8a899529f3efdb5948452dbdf4046d867ecad Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 11 Nov 2026 05:43:47 -0800 Subject: [PATCH 3/3] test: fix the example-operations import depth in five graph specs tests/e2e/graph/*.spec.ts sit three levels below the repo root; five imports went up two, so those specs failed to load on CI. All eight now resolve and `playwright test --list` loads 16 tests in 8 files. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/graph/inspector-readability.spec.ts | 2 +- tests/e2e/graph/item-wire-consumers.spec.ts | 2 +- tests/e2e/graph/local-item-lod.spec.ts | 2 +- tests/e2e/graph/nested-semantic-lod.spec.ts | 2 +- tests/e2e/graph/semantic-lod.spec.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/e2e/graph/inspector-readability.spec.ts b/tests/e2e/graph/inspector-readability.spec.ts index 6babec36..492ef555 100644 --- a/tests/e2e/graph/inspector-readability.spec.ts +++ b/tests/e2e/graph/inspector-readability.spec.ts @@ -6,7 +6,7 @@ import { ownedGraphIdsFixture, type OwnedGraphs, } from '../../lib/ownedGraphIdsFixture'; -import { isExampleWrite } from '../../packages/web/src/lib/exampleOperations'; +import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations'; const test = base.extend<{ ownedGraphIds: OwnedGraphs }>({ ownedGraphIds: [ownedGraphIdsFixture, { timeout: 60000 }], diff --git a/tests/e2e/graph/item-wire-consumers.spec.ts b/tests/e2e/graph/item-wire-consumers.spec.ts index 49edeafa..db032fe4 100644 --- a/tests/e2e/graph/item-wire-consumers.spec.ts +++ b/tests/e2e/graph/item-wire-consumers.spec.ts @@ -6,7 +6,7 @@ import { ownedGraphIdsFixture, type OwnedGraphs, } from '../../lib/ownedGraphIdsFixture'; -import { isExampleWrite } from '../../packages/web/src/lib/exampleOperations'; +import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations'; const test = base.extend<{ ownedGraphIds: OwnedGraphs }>({ ownedGraphIds: [ownedGraphIdsFixture, { timeout: 60000 }], diff --git a/tests/e2e/graph/local-item-lod.spec.ts b/tests/e2e/graph/local-item-lod.spec.ts index c74b2e04..6186d592 100644 --- a/tests/e2e/graph/local-item-lod.spec.ts +++ b/tests/e2e/graph/local-item-lod.spec.ts @@ -11,7 +11,7 @@ import { ownedGraphIdsFixture, type OwnedGraphs, } from '../../lib/ownedGraphIdsFixture'; -import { isExampleWrite } from '../../packages/web/src/lib/exampleOperations'; +import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations'; type Item = { id: string; diff --git a/tests/e2e/graph/nested-semantic-lod.spec.ts b/tests/e2e/graph/nested-semantic-lod.spec.ts index f9179f8b..7b7614ae 100644 --- a/tests/e2e/graph/nested-semantic-lod.spec.ts +++ b/tests/e2e/graph/nested-semantic-lod.spec.ts @@ -12,7 +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'; +import { isExampleWrite } from '../../../packages/web/src/lib/exampleOperations'; const test = base.extend<{ ownedGraphIds: OwnedGraphs; diff --git a/tests/e2e/graph/semantic-lod.spec.ts b/tests/e2e/graph/semantic-lod.spec.ts index 44e8ff9c..45a78f89 100644 --- a/tests/e2e/graph/semantic-lod.spec.ts +++ b/tests/e2e/graph/semantic-lod.spec.ts @@ -9,7 +9,7 @@ import { ownedGraphIdsFixture, type OwnedGraphs, } from '../../lib/ownedGraphIdsFixture'; -import { isExampleWrite } from '../../packages/web/src/lib/exampleOperations'; +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.