From a6f867cdaeb42f833ed3c37fb4d60451682edaf3 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Tue, 21 Jul 2026 22:41:34 +0700 Subject: [PATCH] test(e2e): pin the request payload on the mutations that carry one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven payload-bearing cassette entries answered success no matter what the app sent: the four flow mutations, both create mutations, and the mkdir POST. Since each assertion then reads canned cassette data, a wrong payload — a stale closure deleting flow 6 while flow 5 is open, an unbound name field — repainted exactly the asserted UI and the suite stayed green. Verified by flipping a pinned flow id and a pinned path: both specs now fail on the unmatched request, and both passed before. Matching had to grow a level for this: `bodySubset` documented a deep subset but compared nested values whole, so `{ input: { name } }` demanded the entire input. It now recurses, which is also what makes the API-token pin possible — its `ttl` is derived from the clock at submit time and drifts a second between runs, so only the operator-entered name is pinned. Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/mocks/world.ts | 14 +++++++++++++- frontend/e2e/specs/crud/api-tokens.spec.ts | 12 +++++++++++- frontend/e2e/specs/crud/knowledges.spec.ts | 16 +++++++++++++++- frontend/e2e/specs/crud/resources.spec.ts | 6 +++++- frontend/e2e/specs/flows/lifecycle.spec.ts | 18 ++++++++++++++---- 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/frontend/e2e/mocks/world.ts b/frontend/e2e/mocks/world.ts index 755a31dd..eaacd916 100644 --- a/frontend/e2e/mocks/world.ts +++ b/frontend/e2e/mocks/world.ts @@ -17,9 +17,21 @@ const stableStringify = (value: unknown): string => : node, ) ?? 'undefined'; +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +// Recurses so a nested pin stays a subset — `{ input: { name } }` must not demand +// that the whole `input` match, or any sibling the app also sends (a time-derived +// TTL, a default) would make the entry unmatchable. const isSubsetMatch = (expected?: Record, actual?: Record): boolean => !expected || - Object.entries(expected).every(([key, value]) => stableStringify(actual?.[key]) === stableStringify(value)); + Object.entries(expected).every(([key, value]) => { + const found = actual?.[key]; + + return isPlainObject(value) && isPlainObject(found) + ? isSubsetMatch(value, found) + : stableStringify(found) === stableStringify(value); + }); const sleep = (delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs)); diff --git a/frontend/e2e/specs/crud/api-tokens.spec.ts b/frontend/e2e/specs/crud/api-tokens.spec.ts index 0b455718..9953bac9 100644 --- a/frontend/e2e/specs/crud/api-tokens.spec.ts +++ b/frontend/e2e/specs/crud/api-tokens.spec.ts @@ -20,7 +20,17 @@ test.describe('api tokens crud', { tag: '@crud' }, () => { test.use({ cassette: apiTokensCassette({ - mutations: { createAPIToken: [{ data: created, setFlag: 'token-created' }] }, + mutations: { + createAPIToken: [ + // `ttl` is derived from the clock at submit time and drifts by a second + // between runs, so only the operator-entered name is pinned. + { + data: created, + setFlag: 'token-created', + variables: { input: { name: 'E2E created token' } }, + }, + ], + }, queries: { apiTokens: [ { data: tokensList(SEED_TOKEN) }, diff --git a/frontend/e2e/specs/crud/knowledges.spec.ts b/frontend/e2e/specs/crud/knowledges.spec.ts index 19906199..c2a83b13 100644 --- a/frontend/e2e/specs/crud/knowledges.spec.ts +++ b/frontend/e2e/specs/crud/knowledges.spec.ts @@ -44,7 +44,21 @@ test.describe('knowledges crud', { tag: '@crud' }, () => { test.use({ cassette: knowledgesCassette({ - mutations: { createKnowledgeDocument: [{ data: created }] }, + mutations: { + createKnowledgeDocument: [ + { + data: created, + variables: { + input: { + answerType: 'other', + content: 'E2E knowledge content', + docType: 'answer', + question: 'What is the E2E answer?', + }, + }, + }, + ], + }, queries: { knowledgeDocument: [{ data: detail, variables: { id: '301' } }] }, }), }); diff --git a/frontend/e2e/specs/crud/resources.spec.ts b/frontend/e2e/specs/crud/resources.spec.ts index 3e7fce57..5bb529d7 100644 --- a/frontend/e2e/specs/crud/resources.spec.ts +++ b/frontend/e2e/specs/crud/resources.spec.ts @@ -29,7 +29,11 @@ test.describe('resources', { tag: '@coverage' }, () => { cassette: resourcesCassette({ rest: { 'POST /api/v1/resources/mkdir': [ - { body: { data: {}, status: 'success' }, setFlag: 'folder-created' }, + { + body: { data: {}, status: 'success' }, + bodySubset: { path: 'new-folder' }, + setFlag: 'folder-created', + }, ], }, subscriptions: { diff --git a/frontend/e2e/specs/flows/lifecycle.spec.ts b/frontend/e2e/specs/flows/lifecycle.spec.ts index 5d8bf5ae..3df3f2d2 100644 --- a/frontend/e2e/specs/flows/lifecycle.spec.ts +++ b/frontend/e2e/specs/flows/lifecycle.spec.ts @@ -26,7 +26,15 @@ test.describe('flow lifecycle', { tag: '@flows' }, () => { test.use({ cassette: flowsCassette({ - mutations: { renameFlow: [{ data: renamed, setFlag: 'flow-renamed' }] }, + mutations: { + renameFlow: [ + { + data: renamed, + setFlag: 'flow-renamed', + variables: { flowId: '5', title: 'E2E Alpha Renamed' }, + }, + ], + }, subscriptions: { flowUpdated: [ { @@ -60,7 +68,7 @@ test.describe('flow lifecycle', { tag: '@flows' }, () => { test.use({ cassette: flowsCassette({ - mutations: { finishFlow: [{ data: finished, setFlag: 'flow-finished' }] }, + mutations: { finishFlow: [{ data: finished, setFlag: 'flow-finished', variables: { flowId: '5' } }] }, subscriptions: { flowUpdated: [ { @@ -101,7 +109,9 @@ test.describe('flow lifecycle', { tag: '@flows' }, () => { // asserts below before the frame lands. test.use({ cassette: flowsCassette({ - mutations: { addFavoriteFlow: [{ data: favorited, setFlag: 'flow-favorited' }] }, + mutations: { + addFavoriteFlow: [{ data: favorited, setFlag: 'flow-favorited', variables: { flowId: '5' } }], + }, subscriptions: { settingsUserUpdated: [ { @@ -146,7 +156,7 @@ test.describe('flow lifecycle', { tag: '@flows' }, () => { test.use({ cassette: flowsCassette({ - mutations: { deleteFlow: [{ data: deleted, setFlag: 'flow-deleted' }] }, + mutations: { deleteFlow: [{ data: deleted, setFlag: 'flow-deleted', variables: { flowId: '5' } }] }, subscriptions: { flowDeleted: [ {