-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathplaywright-eventual-wait.test.ts
More file actions
170 lines (162 loc) · 7.33 KB
/
Copy pathplaywright-eventual-wait.test.ts
File metadata and controls
170 lines (162 loc) · 7.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
import { describe, expect, test } from 'vitest';
import { renderPlaywrightSuite } from '../../materializer/src/playwright/emitter.ts';
import type { EndpointScenarioCollection } from '../../path-analyser/src/types.ts';
// Layer-2 guards for the eventual-state wait injection (#159 PR B).
//
// The emitter renders a planner-annotated `eventualWaitsAfter` as an
// `awaitEventually(witnessUrl, { predicate })` block immediately after
// the producer step. The tests below pin both the shape of the emitted
// block AND the "import + render in lockstep" contract — the
// `await-eventually` import must be present whenever any step carries
// an eventualWaitsAfter, even when no step is itself an EC read.
function buildCollectionWithWait(): EndpointScenarioCollection {
return {
endpoint: { operationId: 'deleteWidget', method: 'POST', path: '/widgets/{id}/deletion' },
requiredSemanticTypes: [],
optionalSemanticTypes: [],
scenarios: [
{
id: 'sc1',
name: 'create then delete with wait',
operations: [
{ operationId: 'createWidget', method: 'POST', path: '/widgets' },
{ operationId: 'deleteWidget', method: 'POST', path: '/widgets/{id}/deletion' },
],
producedSemanticTypes: [],
satisfiedSemanticTypes: [],
requestPlan: [
{
operationId: 'createWidget',
method: 'POST',
pathTemplate: '/widgets',
expect: { status: 200 },
extract: [{ fieldPath: 'id', bind: 'idVar' }],
eventualWaitsAfter: [
{
state: 'WidgetReady',
witness: {
operationId: 'getWidget',
method: 'GET',
pathTemplate: '/widgets/{id}',
predicate: { path: 'state', equals: 'READY' },
},
},
],
},
{
operationId: 'deleteWidget',
method: 'POST',
pathTemplate: '/widgets/{id}/deletion',
expect: { status: 204 },
},
],
},
],
};
}
describe('emitter: eventual-state wait injection (#159 PR B)', () => {
test('renders an awaitEventually block immediately after the producer step', () => {
const src = renderPlaywrightSuite(buildCollectionWithWait(), {
suiteName: 'deleteWidget',
mode: 'feature',
});
// The wait must appear in the source AND sit between the producer
// (createWidget) and the consumer (deleteWidget). Position is part of
// the contract — a wait emitted before the producer (or after the
// consumer) doesn't fix the motivating case. The per-step marker is
// `await test.step('<operationId>', ...)`.
const producerIdx = src.indexOf('test.step("createWidget"');
const consumerIdx = src.indexOf('test.step("deleteWidget"');
const waitIdx = src.indexOf('await awaitEventually(');
expect(producerIdx).toBeGreaterThan(0);
expect(consumerIdx).toBeGreaterThan(producerIdx);
expect(waitIdx).toBeGreaterThan(producerIdx);
expect(waitIdx).toBeLessThan(consumerIdx);
});
test('imports await-eventually even when no step is itself an EC read', () => {
// Pre-PR-B the import was gated only on stepNeedsAwait() (read-shape EC
// ops). A wait-after-producer must lift the import on its own — otherwise
// the emitted suite references awaitEventually without an import.
const src = renderPlaywrightSuite(buildCollectionWithWait(), {
suiteName: 'deleteWidget',
mode: 'feature',
});
expect(src).toContain("import { awaitEventually } from './support/await-eventually';");
});
test('emits the witness URL using the ctx-binding rewrite (path param resolves to ctx.<name>Var)', () => {
// The witness URL must thread through buildUrlExpression so the
// `{id}` path-param picks up `ctx.idVar` extracted by the producer
// step (binds the wait to the producer's response).
const src = renderPlaywrightSuite(buildCollectionWithWait(), {
suiteName: 'deleteWidget',
mode: 'feature',
});
expect(src).toContain('const witnessUrl = baseUrl +');
expect(src).toContain('ctx.idVar');
});
test('renders a structured predicate that compares the configured path against the configured scalar', () => {
// The predicate is generated from the structured WitnessPredicate
// shape, NOT interpolated from a user-supplied string. The emitted
// function narrows `unknown` to a record, reads the configured
// top-level field via bracket-access (biome rewrites this to dot
// access in the regenerated suite, but the emitter outputs the
// bracket form), and compares with `===`.
const src = renderPlaywrightSuite(buildCollectionWithWait(), {
suiteName: 'deleteWidget',
mode: 'feature',
});
// Predicate body shape — `state` and `'READY'` come from the spec above.
expect(src).toContain("const v = (body as Record<string, unknown>)['state'];");
// Pre-biome quoting: JSON.stringify produces double quotes; biome
// rewrites them to single quotes in the regenerated suite. The
// assertion targets the emitter's raw output.
expect(src).toContain('return v === "READY";');
});
test('captures the awaitEventually response and asserts its status (#159 PR B review)', () => {
// awaitEventually returns early (without throwing) on hard-fail
// statuses (401/403/422/5xx etc.) so callers can produce a useful
// expect-vs-actual diff. Pre-review the emitter ignored the return
// value — a 401 against the witness would silently let the scenario
// proceed and the failure would be misattributed to the consumer
// step. The new shape captures the response, logs the body on
// mismatch (mirroring the request-step pattern), and asserts 200.
const src = renderPlaywrightSuite(buildCollectionWithWait(), {
suiteName: 'deleteWidget',
mode: 'feature',
});
expect(src).toContain('const witnessResp1 = await awaitEventually(');
expect(src).toContain('if (witnessResp1.status() !== 200)');
expect(src).toContain("console.error('Witness response body:'");
expect(src).toContain('expect(witnessResp1.status()).toBe(200);');
});
test('omits the wait block (and the await-eventually import gate) when no step has eventualWaitsAfter', () => {
// Sanity guard: a collection without any wait annotation must NOT
// emit the wait scaffolding. Catches a regression where the gate
// accidentally fires for every step.
const collection: EndpointScenarioCollection = {
endpoint: { operationId: 'createWidget', method: 'POST', path: '/widgets' },
requiredSemanticTypes: [],
optionalSemanticTypes: [],
scenarios: [
{
id: 'sc1',
name: 'simple',
operations: [{ operationId: 'createWidget', method: 'POST', path: '/widgets' }],
producedSemanticTypes: [],
satisfiedSemanticTypes: [],
requestPlan: [
{
operationId: 'createWidget',
method: 'POST',
pathTemplate: '/widgets',
expect: { status: 200 },
},
],
},
],
};
const src = renderPlaywrightSuite(collection, { suiteName: 'createWidget', mode: 'feature' });
expect(src).not.toContain('awaitEventually');
expect(src).not.toContain("from './support/await-eventually'");
});
});