test(e2e): pin the request payload on the mutations that carry one

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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-21 22:41:34 +07:00
co-authored by Claude Opus 4.8
parent 66fd432edd
commit a6f867cdae
5 changed files with 58 additions and 8 deletions
+13 -1
View File
@@ -17,9 +17,21 @@ const stableStringify = (value: unknown): string =>
: node,
) ?? 'undefined';
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
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<string, unknown>, actual?: Record<string, unknown>): 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<void>((resolve) => setTimeout(resolve, delayMs));
+11 -1
View File
@@ -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) },
+15 -1
View File
@@ -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' } }] },
}),
});
+5 -1
View File
@@ -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: {
+14 -4
View File
@@ -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: [
{