@aventara/testing 0.0.0-stage → 0.1.0-pilot.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +29 -2
- package/dist/adapters/adapter-factory.d.ts +3 -0
- package/dist/adapters/adapter-factory.js +1 -0
- package/dist/adapters/fake-adapter.d.ts +28 -0
- package/dist/adapters/fake-adapter.js +65 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/protocol/corpus-contract.fixture.d.ts +418 -0
- package/dist/protocol/corpus-contract.fixture.js +334 -0
- package/dist/protocol/in-process.driver.d.ts +11 -0
- package/dist/protocol/in-process.driver.js +26 -0
- package/dist/protocol/per-route.driver.d.ts +12 -0
- package/dist/protocol/per-route.driver.js +36 -0
- package/dist/protocol/protocol-conformance.d.ts +32 -0
- package/dist/protocol/protocol-conformance.js +112 -0
- package/dist/protocol/protocol-driver.d.ts +18 -0
- package/dist/protocol/protocol-driver.js +1 -0
- package/dist/protocol/protocol-fixture.d.ts +53 -0
- package/dist/protocol/protocol-fixture.js +1 -0
- package/dist/protocol/protocol-fixtures.d.ts +2 -0
- package/dist/protocol/protocol-fixtures.js +998 -0
- package/package.json +31 -3
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { createFramework, Decimal, FrameworkError } from "@aventara/core";
|
|
2
|
+
import { createFakeAdapter } from "../adapters/fake-adapter.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Framework every driven corpus row runs against: one model, one
|
|
5
|
+
* configuration, and an in-memory Adapter whose answer is a pure function of
|
|
6
|
+
* the operation it is handed — so a row's expected bytes are fixed facts, and
|
|
7
|
+
* no row needs a provider (plan §13).
|
|
8
|
+
*
|
|
9
|
+
* Grows with the corpus (plan §6): each slice adds the resources its rows
|
|
10
|
+
* need, and moves {@link CORPUS_CLIENT_CONTRACT_HASH} with them.
|
|
11
|
+
*/
|
|
12
|
+
const identityString = {
|
|
13
|
+
kind: "scalar",
|
|
14
|
+
type: { scalar: "string" },
|
|
15
|
+
nullable: false,
|
|
16
|
+
list: false,
|
|
17
|
+
lifecycle: [],
|
|
18
|
+
capabilities: { select: true, filter: ["equals"] },
|
|
19
|
+
};
|
|
20
|
+
const writableString = {
|
|
21
|
+
...identityString,
|
|
22
|
+
capabilities: {
|
|
23
|
+
select: true,
|
|
24
|
+
filter: ["equals"],
|
|
25
|
+
create: [],
|
|
26
|
+
update: [],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
/** A non-JSON-native scalar the corpus reads (family 13): selectable, never filtered. */
|
|
30
|
+
const wired = (scalar) => ({
|
|
31
|
+
kind: "scalar",
|
|
32
|
+
type: { scalar },
|
|
33
|
+
nullable: false,
|
|
34
|
+
list: false,
|
|
35
|
+
lifecycle: [],
|
|
36
|
+
capabilities: { select: true, create: [] },
|
|
37
|
+
});
|
|
38
|
+
/** One `find.many`: what the corpus's supporting Resources advertise. */
|
|
39
|
+
const findMany = { find: { many: true } };
|
|
40
|
+
/**
|
|
41
|
+
* The corpus's adapter model:
|
|
42
|
+
*
|
|
43
|
+
* - `authors` — every standard operation (family 2); its `secret` field is
|
|
44
|
+
* hidden at client scope (R1's client-hidden field);
|
|
45
|
+
* - `books` — references `authors`, and deleting an author cascades into it
|
|
46
|
+
* ({@link CORPUS_REFERENTIAL_ACTIONS}; a plan's `V1019`); the client scope
|
|
47
|
+
* adds a computed `label` to it ({@link CORPUS_CLIENT_CONFIG}, F-828);
|
|
48
|
+
* - `secrets` — its one operation restricted away at client scope (R1's
|
|
49
|
+
* client-restricted variant; M6's `operations: {}`);
|
|
50
|
+
* - `hiddenRes` — hidden at client scope (R1's client-hidden Resource, M7);
|
|
51
|
+
* - `samples` — one field of each scalar whose wire form is not its runtime
|
|
52
|
+
* form (`bigint`, `datetime`, `decimal`, `bytes`; family 13, P1, C1).
|
|
53
|
+
*/
|
|
54
|
+
export const CORPUS_MODEL = {
|
|
55
|
+
transactions: "interactive",
|
|
56
|
+
scalars: {
|
|
57
|
+
string: { builtin: true },
|
|
58
|
+
bigint: { builtin: true },
|
|
59
|
+
datetime: { builtin: true },
|
|
60
|
+
decimal: { builtin: true },
|
|
61
|
+
bytes: { builtin: true },
|
|
62
|
+
},
|
|
63
|
+
enums: {},
|
|
64
|
+
resources: {
|
|
65
|
+
authors: {
|
|
66
|
+
fields: {
|
|
67
|
+
id: identityString,
|
|
68
|
+
name: writableString,
|
|
69
|
+
secret: identityString,
|
|
70
|
+
},
|
|
71
|
+
identifiers: [["id"]],
|
|
72
|
+
operations: {
|
|
73
|
+
find: { first: true, unique: true, many: true, count: true },
|
|
74
|
+
create: { one: true, many: true, count: true },
|
|
75
|
+
update: { first: true, unique: true, many: true, count: true },
|
|
76
|
+
delete: { first: true, unique: true, many: true, count: true },
|
|
77
|
+
upsert: { unique: true },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
books: {
|
|
81
|
+
fields: { id: identityString, authorId: identityString },
|
|
82
|
+
identifiers: [["id"]],
|
|
83
|
+
operations: findMany,
|
|
84
|
+
},
|
|
85
|
+
secrets: {
|
|
86
|
+
fields: { id: identityString },
|
|
87
|
+
identifiers: [["id"]],
|
|
88
|
+
operations: findMany,
|
|
89
|
+
},
|
|
90
|
+
hiddenRes: {
|
|
91
|
+
fields: { id: identityString },
|
|
92
|
+
identifiers: [["id"]],
|
|
93
|
+
operations: findMany,
|
|
94
|
+
},
|
|
95
|
+
samples: {
|
|
96
|
+
fields: {
|
|
97
|
+
id: identityString,
|
|
98
|
+
count: wired("bigint"),
|
|
99
|
+
at: wired("datetime"),
|
|
100
|
+
amount: wired("decimal"),
|
|
101
|
+
blob: wired("bytes"),
|
|
102
|
+
},
|
|
103
|
+
identifiers: [["id"]],
|
|
104
|
+
operations: { find: { unique: true }, create: { one: true } },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
/** Deleting an author cascades into its books (an edge under `books`). */
|
|
109
|
+
export const CORPUS_REFERENTIAL_ACTIONS = {
|
|
110
|
+
books: [{ field: "author", target: "authors", onDelete: "cascade" }],
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The `where.id` a row sends to make the corpus Framework fail one way
|
|
114
|
+
* (families 10 and 12, "a fake adapter returning the code, labelled so"):
|
|
115
|
+
*
|
|
116
|
+
* - `A4000`, `A4001`, `A4002`, `A3000` — the client guard throws
|
|
117
|
+
* `FrameworkError("A4000")`, denies (`false`), throws
|
|
118
|
+
* `FrameworkError("A4002")`, or throws a plain `Error`;
|
|
119
|
+
* - `A2008`, `A2013`, `A2014` — the Adapter throws `FrameworkError` with that
|
|
120
|
+
* conflict code; `A3001` — it throws a plain `Error`;
|
|
121
|
+
* - `A3002` — a plan step carrying it runs, and the Adapter's transaction then
|
|
122
|
+
* fails to commit;
|
|
123
|
+
* - `A3003` — `samples.find.unique` answers a datetime the wire cannot spell.
|
|
124
|
+
*/
|
|
125
|
+
export const CORPUS_TRIGGER = {
|
|
126
|
+
unauthenticated: "A4000",
|
|
127
|
+
guardDenies: "A4001",
|
|
128
|
+
policyDenies: "A4002",
|
|
129
|
+
guardThrows: "A3000",
|
|
130
|
+
conflict: "A2008",
|
|
131
|
+
concurrentModification: "A2013",
|
|
132
|
+
staleSelection: "A2014",
|
|
133
|
+
adapterThrows: "A3001",
|
|
134
|
+
commitFails: "A3002",
|
|
135
|
+
unencodable: "A3003",
|
|
136
|
+
};
|
|
137
|
+
/** The `where.id` an operation's arguments name, when it names one as a string. */
|
|
138
|
+
function whereIdOf(args) {
|
|
139
|
+
const where = typeof args === "object" && args !== null
|
|
140
|
+
? args.where
|
|
141
|
+
: undefined;
|
|
142
|
+
const id = typeof where === "object" && where !== null
|
|
143
|
+
? where.id
|
|
144
|
+
: undefined;
|
|
145
|
+
return typeof id === "string" ? id : undefined;
|
|
146
|
+
}
|
|
147
|
+
/** The corpus's one client guard: it decides nothing unless a row names a trigger. */
|
|
148
|
+
function corpusGuard(context) {
|
|
149
|
+
switch (whereIdOf(context.args)) {
|
|
150
|
+
case CORPUS_TRIGGER.unauthenticated:
|
|
151
|
+
throw new FrameworkError("A4000", "Authentication is required.");
|
|
152
|
+
case CORPUS_TRIGGER.guardDenies:
|
|
153
|
+
return false;
|
|
154
|
+
case CORPUS_TRIGGER.policyDenies:
|
|
155
|
+
throw new FrameworkError("A4002", "Denied by the corpus policy.");
|
|
156
|
+
case CORPUS_TRIGGER.guardThrows:
|
|
157
|
+
throw new Error("the corpus guard failed");
|
|
158
|
+
default:
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The corpus's client-scope configuration: what the ClientContract hides or
|
|
164
|
+
* restricts, and `books.label` — a VIRTUAL field added at client scope and
|
|
165
|
+
* computed on read from `id` (F-828, plan S12).
|
|
166
|
+
*/
|
|
167
|
+
export const CORPUS_CLIENT_CONFIG = {
|
|
168
|
+
fields: {
|
|
169
|
+
books: {
|
|
170
|
+
label: {
|
|
171
|
+
type: { scalar: "string" },
|
|
172
|
+
nullable: false,
|
|
173
|
+
list: false,
|
|
174
|
+
lifecycle: ["VIRTUAL"],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
behaviors: {
|
|
179
|
+
books: {
|
|
180
|
+
fields: {
|
|
181
|
+
label: {
|
|
182
|
+
read: {
|
|
183
|
+
dependsOn: ["id"],
|
|
184
|
+
compute: ({ record, }) => `book:${String(record.id)}`,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
pipelines: { guards: [corpusGuard] },
|
|
191
|
+
restrictions: {
|
|
192
|
+
authors: { fields: { secret: { hidden: true } } },
|
|
193
|
+
secrets: { operations: { find: { many: false } } },
|
|
194
|
+
hiddenRes: { hidden: true },
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
/** The corpus Framework's mount point; every row's path is relative to it. */
|
|
198
|
+
export const CORPUS_ENTRYPOINT = "/api";
|
|
199
|
+
/**
|
|
200
|
+
* The corpus ClientContract's hash: what a generated client for it sends as
|
|
201
|
+
* `Aventara-Contract-Hash`. A literal, as a generated client holds it;
|
|
202
|
+
* `corpus-contract.spec.ts` fails, naming this constant, when the compiled
|
|
203
|
+
* contract moves.
|
|
204
|
+
*/
|
|
205
|
+
export const CORPUS_CLIENT_CONTRACT_HASH = "sha256:07b1a75a6e8e88e30115ae81bcf7e3a12fa493bddce995a01867e2381bd158d0";
|
|
206
|
+
/**
|
|
207
|
+
* The corpus ClientContract as `GET /_contract` serves it: its JCS-canonical
|
|
208
|
+
* bytes (Q8), `protocol.hash` included (M22). A literal, as a host must
|
|
209
|
+
* serve it byte for byte; `corpus-contract.spec.ts` fails, naming this
|
|
210
|
+
* constant, when the compiled contract moves.
|
|
211
|
+
*/
|
|
212
|
+
export const CORPUS_CLIENT_CONTRACT_DOCUMENT = '{"enums":{},"limits":{"maxBooleanNodes":50,"maxListLimit":250,"maxNestingDepth":12,"maxRequestBytes":1048576,"maxTransactionOperations":20},"protocol":{"hash":"sha256:07b1a75a6e8e88e30115ae81bcf7e3a12fa493bddce995a01867e2381bd158d0","version":1},"resources":{"authors":{"fields":{"id":{"capabilities":{"filter":["equals"],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"string"}},"name":{"capabilities":{"create":[],"filter":["equals"],"select":true,"update":[]},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"string"}}},"identifiers":[["id"]],"operations":{"create":{"count":true,"many":true,"one":true},"delete":{"count":true,"first":true,"many":true,"unique":true},"find":{"count":true,"first":true,"many":true,"unique":true},"update":{"count":true,"first":true,"many":true,"unique":true},"upsert":{"unique":true}}},"books":{"fields":{"authorId":{"capabilities":{"filter":["equals"],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"string"}},"id":{"capabilities":{"filter":["equals"],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"string"}},"label":{"capabilities":{"select":true},"kind":"scalar","lifecycle":["COMPUTED_ON_READ","VIRTUAL"],"list":false,"nullable":false,"type":{"scalar":"string"}}},"identifiers":[["id"]],"operations":{"find":{"many":true}}},"samples":{"fields":{"amount":{"capabilities":{"create":[],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"decimal"}},"at":{"capabilities":{"create":[],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"datetime"}},"blob":{"capabilities":{"create":[],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"bytes"}},"count":{"capabilities":{"create":[],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"bigint"}},"id":{"capabilities":{"filter":["equals"],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"string"}}},"identifiers":[["id"]],"operations":{"create":{"one":true},"find":{"unique":true}}},"secrets":{"fields":{"id":{"capabilities":{"filter":["equals"],"select":true},"kind":"scalar","lifecycle":[],"list":false,"nullable":false,"type":{"scalar":"string"}}},"identifiers":[["id"]],"operations":{}}},"scalars":{"bigint":{"builtin":true},"bytes":{"builtin":true},"datetime":{"builtin":true},"decimal":{"builtin":true},"string":{"builtin":true}},"transactions":"interactive"}';
|
|
213
|
+
/**
|
|
214
|
+
* The hash of the corpus's SECOND ClientContract — the same model and
|
|
215
|
+
* configuration with `transactions: "none"` (family 6's `/_transactions`
|
|
216
|
+
* absence). A row targets it by sending this hash, exactly as a client
|
|
217
|
+
* generated against it would; the runner gives the row the Framework whose
|
|
218
|
+
* ClientContract it names (the director, 2026-10-05). A literal, held to the
|
|
219
|
+
* compiled value by `corpus-contract.spec.ts`.
|
|
220
|
+
*/
|
|
221
|
+
export const CORPUS_NONE_CLIENT_CONTRACT_HASH = "sha256:d333e8396b9bf9127e0859ed69b9874f230058a597e2129e6b9e9b380daef618";
|
|
222
|
+
/** The one record the corpus Adapter knows. */
|
|
223
|
+
export const CORPUS_AUTHOR = { id: "a1", name: "Ada" };
|
|
224
|
+
/** The `where.id` that matches nothing. */
|
|
225
|
+
export const CORPUS_NOBODY = "nobody";
|
|
226
|
+
/**
|
|
227
|
+
* The one `samples` record, in runtime forms — what an Adapter hands core,
|
|
228
|
+
* and what the HTTP encoders must spell in wire forms (P1).
|
|
229
|
+
*/
|
|
230
|
+
export const CORPUS_SAMPLE = {
|
|
231
|
+
id: "s1",
|
|
232
|
+
count: 9007199254740993n,
|
|
233
|
+
at: new Date("2026-10-05T12:34:56.789Z"),
|
|
234
|
+
amount: new Decimal("12.50"),
|
|
235
|
+
blob: new Uint8Array([255, 0, 16]),
|
|
236
|
+
};
|
|
237
|
+
/** The conflict codes the corpus Adapter raises, and the text it raises them with. */
|
|
238
|
+
const CONFLICTS = {
|
|
239
|
+
[CORPUS_TRIGGER.conflict]: "A2008",
|
|
240
|
+
[CORPUS_TRIGGER.concurrentModification]: "A2013",
|
|
241
|
+
[CORPUS_TRIGGER.staleSelection]: "A2014",
|
|
242
|
+
};
|
|
243
|
+
/** The one record of each supporting Resource. */
|
|
244
|
+
const SUPPORTING_RECORDS = {
|
|
245
|
+
books: { id: "b1", authorId: CORPUS_AUTHOR.id },
|
|
246
|
+
secrets: { id: "s1" },
|
|
247
|
+
hiddenRes: { id: "h1" },
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* What the corpus Adapter answers an operation. A trigger `where.id` fails it
|
|
251
|
+
* ({@link CORPUS_TRIGGER}). Otherwise: for {@link CORPUS_NOBODY}, nothing — `0`
|
|
252
|
+
* for a count, `[]` for a many, `null` for a single record; else `2` for a
|
|
253
|
+
* count, the Resource's one record in a list for a many, and the one record
|
|
254
|
+
* for a single-record operation (`samples`: {@link CORPUS_SAMPLE}).
|
|
255
|
+
*/
|
|
256
|
+
function corpusAnswer(operation) {
|
|
257
|
+
const id = whereIdOf(operation.arguments);
|
|
258
|
+
const conflict = id === undefined ? undefined : CONFLICTS[id];
|
|
259
|
+
if (conflict !== undefined) {
|
|
260
|
+
throw new FrameworkError(conflict, `The corpus Adapter answered ${conflict}.`);
|
|
261
|
+
}
|
|
262
|
+
if (id === CORPUS_TRIGGER.adapterThrows) {
|
|
263
|
+
throw new Error("the corpus Adapter failed");
|
|
264
|
+
}
|
|
265
|
+
const nothing = id === CORPUS_NOBODY;
|
|
266
|
+
if (operation.variant === "count") {
|
|
267
|
+
return nothing ? 0 : 2;
|
|
268
|
+
}
|
|
269
|
+
if (operation.variant === "many") {
|
|
270
|
+
return nothing
|
|
271
|
+
? []
|
|
272
|
+
: [SUPPORTING_RECORDS[operation.resource] ?? CORPUS_AUTHOR];
|
|
273
|
+
}
|
|
274
|
+
if (nothing) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (operation.resource === "samples") {
|
|
278
|
+
return id === CORPUS_TRIGGER.unencodable
|
|
279
|
+
? { ...CORPUS_SAMPLE, at: new Date("+010000-01-01T00:00:00.000Z") }
|
|
280
|
+
: CORPUS_SAMPLE;
|
|
281
|
+
}
|
|
282
|
+
return CORPUS_AUTHOR;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The fake Adapter's transaction, made to fail its commit when a step of the
|
|
286
|
+
* plan named {@link CORPUS_TRIGGER.commitFails} — an infrastructure failure
|
|
287
|
+
* after the work ran (`A3002`).
|
|
288
|
+
*/
|
|
289
|
+
function failingCommit(transaction) {
|
|
290
|
+
return async (work) => {
|
|
291
|
+
let doomed = false;
|
|
292
|
+
const result = await transaction((scoped) => work({
|
|
293
|
+
execute: (operation) => {
|
|
294
|
+
if (whereIdOf(operation.arguments) === CORPUS_TRIGGER.commitFails) {
|
|
295
|
+
doomed = true;
|
|
296
|
+
}
|
|
297
|
+
return scoped.execute(operation);
|
|
298
|
+
},
|
|
299
|
+
}));
|
|
300
|
+
if (doomed) {
|
|
301
|
+
throw new Error("the corpus Adapter failed to commit");
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* A fresh corpus Framework, and the count of operations its Adapter has been
|
|
308
|
+
* handed so far — a row's `adapterCalls` is read from it. `contractHash`
|
|
309
|
+
* selects which corpus ClientContract it serves: the `transactions: "none"`
|
|
310
|
+
* one when it is {@link CORPUS_NONE_CLIENT_CONTRACT_HASH}, the main one
|
|
311
|
+
* otherwise (a stale or absent hash included).
|
|
312
|
+
*/
|
|
313
|
+
export async function createCorpusFramework(contractHash) {
|
|
314
|
+
// Over the widened model type: which model is discovered is a runtime fact,
|
|
315
|
+
// and a literal-typed Framework costs type-checking the corpus never uses.
|
|
316
|
+
const fake = createFakeAdapter({
|
|
317
|
+
model: contractHash === CORPUS_NONE_CLIENT_CONTRACT_HASH
|
|
318
|
+
? { ...CORPUS_MODEL, transactions: "none" }
|
|
319
|
+
: CORPUS_MODEL,
|
|
320
|
+
execute: corpusAnswer,
|
|
321
|
+
});
|
|
322
|
+
const adapter = Object.assign(fake, {
|
|
323
|
+
referentialActions: CORPUS_REFERENTIAL_ACTIONS,
|
|
324
|
+
...(fake.transaction === undefined
|
|
325
|
+
? {}
|
|
326
|
+
: { transaction: failingCommit(fake.transaction) }),
|
|
327
|
+
});
|
|
328
|
+
const framework = await createFramework({
|
|
329
|
+
entrypoint: CORPUS_ENTRYPOINT,
|
|
330
|
+
adapter,
|
|
331
|
+
client: CORPUS_CLIENT_CONFIG,
|
|
332
|
+
});
|
|
333
|
+
return { framework, adapterCalls: () => adapter.operations.length };
|
|
334
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AvProtocolDriver } from "./protocol-driver.js";
|
|
2
|
+
/**
|
|
3
|
+
* Driver 1 (plan S8, S9): the protocol hosted in process, with no HTTP stack —
|
|
4
|
+
* a catch-all host (Q12) that hands `/_contract` to `protocol.encodeContract`,
|
|
5
|
+
* `/_transactions` to the bound shortcut `protocol.handleTransaction`, every
|
|
6
|
+
* other request to `protocol.handleOperation`, and writes back exactly what
|
|
7
|
+
* they answer. What
|
|
8
|
+
* it proves is the framework's own answer, host-free; driver 2 (S13) must
|
|
9
|
+
* answer every row identically through a real server.
|
|
10
|
+
*/
|
|
11
|
+
export declare const inProcessDriver: AvProtocolDriver;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AvProtocol } from "@aventara/core/protocol";
|
|
2
|
+
/**
|
|
3
|
+
* Driver 1 (plan S8, S9): the protocol hosted in process, with no HTTP stack —
|
|
4
|
+
* a catch-all host (Q12) that hands `/_contract` to `protocol.encodeContract`,
|
|
5
|
+
* `/_transactions` to the bound shortcut `protocol.handleTransaction`, every
|
|
6
|
+
* other request to `protocol.handleOperation`, and writes back exactly what
|
|
7
|
+
* they answer. What
|
|
8
|
+
* it proves is the framework's own answer, host-free; driver 2 (S13) must
|
|
9
|
+
* answer every row identically through a real server.
|
|
10
|
+
*/
|
|
11
|
+
export const inProcessDriver = async (framework) => {
|
|
12
|
+
const protocol = AvProtocol.bind(framework);
|
|
13
|
+
return {
|
|
14
|
+
send: async (request) => {
|
|
15
|
+
switch (request.path) {
|
|
16
|
+
case "/_contract":
|
|
17
|
+
return protocol.encodeContract(request);
|
|
18
|
+
case "/_transactions":
|
|
19
|
+
return protocol.handleTransaction(request);
|
|
20
|
+
default:
|
|
21
|
+
return protocol.handleOperation(request);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
close: async () => undefined,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AvProtocolDriver } from "./protocol-driver.js";
|
|
2
|
+
/**
|
|
3
|
+
* The per-route driver (plan S10): the protocol hosted in process as a host
|
|
4
|
+
* that mounts ONE route per `protocol.surface()` entry — each operation on
|
|
5
|
+
* `handleOperation`, `/_transactions` on `handleTransaction`, `/_contract` on
|
|
6
|
+
* `encodeContract`, matched by method and path — and `protocol.answerAbsent`
|
|
7
|
+
* as the fallback for everything it did not mount (Q12's first style).
|
|
8
|
+
* `undefined` from the fallback is a path outside the protocol, which this
|
|
9
|
+
* host answers with its own bodiless 404. Every corpus row must answer here
|
|
10
|
+
* exactly as through driver 1's catch-all.
|
|
11
|
+
*/
|
|
12
|
+
export declare const perRouteDriver: AvProtocolDriver;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AvProtocol } from "@aventara/core/protocol";
|
|
2
|
+
/** What this host answers a path the protocol says is the host's: its own bodiless 404. */
|
|
3
|
+
const HOST_NOT_FOUND = Object.freeze({
|
|
4
|
+
status: 404,
|
|
5
|
+
headers: Object.freeze({}),
|
|
6
|
+
body: "",
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* The per-route driver (plan S10): the protocol hosted in process as a host
|
|
10
|
+
* that mounts ONE route per `protocol.surface()` entry — each operation on
|
|
11
|
+
* `handleOperation`, `/_transactions` on `handleTransaction`, `/_contract` on
|
|
12
|
+
* `encodeContract`, matched by method and path — and `protocol.answerAbsent`
|
|
13
|
+
* as the fallback for everything it did not mount (Q12's first style).
|
|
14
|
+
* `undefined` from the fallback is a path outside the protocol, which this
|
|
15
|
+
* host answers with its own bodiless 404. Every corpus row must answer here
|
|
16
|
+
* exactly as through driver 1's catch-all.
|
|
17
|
+
*/
|
|
18
|
+
export const perRouteDriver = async (framework) => {
|
|
19
|
+
const protocol = AvProtocol.bind(framework);
|
|
20
|
+
const mounted = new Map(protocol.surface().map((route) => [`${route.method} ${route.path}`, route]));
|
|
21
|
+
return {
|
|
22
|
+
send: async (request) => {
|
|
23
|
+
switch (mounted.get(`${request.method} ${request.path}`)?.kind) {
|
|
24
|
+
case "operation":
|
|
25
|
+
return protocol.handleOperation(request);
|
|
26
|
+
case "transactions":
|
|
27
|
+
return protocol.handleTransaction(request);
|
|
28
|
+
case "contract":
|
|
29
|
+
return protocol.encodeContract(request);
|
|
30
|
+
default:
|
|
31
|
+
return protocol.answerAbsent(request) ?? HOST_NOT_FOUND;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
close: async () => undefined,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AvProtocolDriver } from "./protocol-driver.js";
|
|
2
|
+
/**
|
|
3
|
+
* `runAvProtocolConformance(driver)` — the corpus, driven through one host.
|
|
4
|
+
*
|
|
5
|
+
* Every row a host answers is driven — the rows whose `client.outcome` is
|
|
6
|
+
* `"transport-error"` describe what a client receives instead of a framework
|
|
7
|
+
* answer, so they are skipped (the director, 2026-10-05). Each driven row runs
|
|
8
|
+
* against a fresh corpus Framework, through a fresh driver session, with one
|
|
9
|
+
* request — the corpus Framework whose ClientContract hash the row's
|
|
10
|
+
* `Aventara-Contract-Hash` names, as a generated client names it (the main
|
|
11
|
+
* corpus contract for any other hash; the director, 2026-10-05) — and its
|
|
12
|
+
* answer is held to the row's `server` block:
|
|
13
|
+
*
|
|
14
|
+
* - `status`, exactly;
|
|
15
|
+
* - `code`: the body is a framework envelope (`AvProtocol.isOperationResponse`,
|
|
16
|
+
* the one envelope test) carrying that code — or, for `null`, is none;
|
|
17
|
+
* - `causeKeys`: the exact sorted key set of `cause`;
|
|
18
|
+
* - `issues`: each issue's `code` and `path`, in order;
|
|
19
|
+
* - `adapterCalls`: the operations the corpus Adapter was handed, exactly;
|
|
20
|
+
* - `responseHeaders`: each named header present (names matched without
|
|
21
|
+
* regard to case) with exactly that value — a host may add its own;
|
|
22
|
+
* - `bodyBytes`: the body text, exactly.
|
|
23
|
+
*
|
|
24
|
+
* Resolves one result per driven row, in corpus order, each with the
|
|
25
|
+
* mismatches found — none when the host conforms. A driver that throws is a
|
|
26
|
+
* mismatch of its row, not a rejection of the run. Test-runner free: a
|
|
27
|
+
* caller asserts on the results with whatever runner it uses.
|
|
28
|
+
*/
|
|
29
|
+
export declare function runAvProtocolConformance(driver: AvProtocolDriver): Promise<readonly {
|
|
30
|
+
readonly label: string;
|
|
31
|
+
readonly mismatches: readonly string[];
|
|
32
|
+
}[]>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { AvProtocol } from "@aventara/core/protocol";
|
|
2
|
+
import { createCorpusFramework } from "./corpus-contract.fixture.js";
|
|
3
|
+
import { AvProtocolFixtures } from "./protocol-fixtures.js";
|
|
4
|
+
/**
|
|
5
|
+
* `runAvProtocolConformance(driver)` — the corpus, driven through one host.
|
|
6
|
+
*
|
|
7
|
+
* Every row a host answers is driven — the rows whose `client.outcome` is
|
|
8
|
+
* `"transport-error"` describe what a client receives instead of a framework
|
|
9
|
+
* answer, so they are skipped (the director, 2026-10-05). Each driven row runs
|
|
10
|
+
* against a fresh corpus Framework, through a fresh driver session, with one
|
|
11
|
+
* request — the corpus Framework whose ClientContract hash the row's
|
|
12
|
+
* `Aventara-Contract-Hash` names, as a generated client names it (the main
|
|
13
|
+
* corpus contract for any other hash; the director, 2026-10-05) — and its
|
|
14
|
+
* answer is held to the row's `server` block:
|
|
15
|
+
*
|
|
16
|
+
* - `status`, exactly;
|
|
17
|
+
* - `code`: the body is a framework envelope (`AvProtocol.isOperationResponse`,
|
|
18
|
+
* the one envelope test) carrying that code — or, for `null`, is none;
|
|
19
|
+
* - `causeKeys`: the exact sorted key set of `cause`;
|
|
20
|
+
* - `issues`: each issue's `code` and `path`, in order;
|
|
21
|
+
* - `adapterCalls`: the operations the corpus Adapter was handed, exactly;
|
|
22
|
+
* - `responseHeaders`: each named header present (names matched without
|
|
23
|
+
* regard to case) with exactly that value — a host may add its own;
|
|
24
|
+
* - `bodyBytes`: the body text, exactly.
|
|
25
|
+
*
|
|
26
|
+
* Resolves one result per driven row, in corpus order, each with the
|
|
27
|
+
* mismatches found — none when the host conforms. A driver that throws is a
|
|
28
|
+
* mismatch of its row, not a rejection of the run. Test-runner free: a
|
|
29
|
+
* caller asserts on the results with whatever runner it uses.
|
|
30
|
+
*/
|
|
31
|
+
export async function runAvProtocolConformance(driver) {
|
|
32
|
+
const results = [];
|
|
33
|
+
for (const row of AvProtocolFixtures) {
|
|
34
|
+
if (row.client.outcome === "transport-error") {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const corpus = await createCorpusFramework(contractHashOf(row.request));
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
const session = await driver(corpus.framework);
|
|
41
|
+
try {
|
|
42
|
+
response = await session.send(row.request);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await session.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
results.push({
|
|
50
|
+
label: row.label,
|
|
51
|
+
mismatches: [`driver threw: ${error}`],
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
results.push({
|
|
56
|
+
label: row.label,
|
|
57
|
+
mismatches: mismatchesOf(row.server, response, corpus.adapterCalls()),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
/** Every way `response` departs from what the row's server block states. */
|
|
63
|
+
function mismatchesOf(server, response, adapterCalls) {
|
|
64
|
+
const mismatches = [];
|
|
65
|
+
const expect = (what, actual, expected) => {
|
|
66
|
+
const [a, e] = [JSON.stringify(actual), JSON.stringify(expected)];
|
|
67
|
+
if (a !== e) {
|
|
68
|
+
mismatches.push(`${what}: expected ${e}, received ${a}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
expect("status", response.status, server.status);
|
|
72
|
+
expect("adapter calls", adapterCalls, server.adapterCalls);
|
|
73
|
+
const body = parsedBody(response.body);
|
|
74
|
+
if (server.code === null) {
|
|
75
|
+
expect("a framework envelope", AvProtocol.isOperationResponse(body), false);
|
|
76
|
+
}
|
|
77
|
+
else if (AvProtocol.isOperationResponse(body)) {
|
|
78
|
+
expect("code", body.code, server.code);
|
|
79
|
+
const cause = body.cause;
|
|
80
|
+
if (server.causeKeys !== undefined) {
|
|
81
|
+
expect("cause keys", Object.keys(cause ?? {}).sort(), [...server.causeKeys].sort());
|
|
82
|
+
}
|
|
83
|
+
if (server.issues !== undefined) {
|
|
84
|
+
expect("issues", (cause?.issues ?? []).map(({ code, path }) => path === undefined ? { code } : { code, path }), server.issues);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
mismatches.push(`a framework envelope carrying ${server.code}: none`);
|
|
89
|
+
}
|
|
90
|
+
for (const [name, value] of Object.entries(server.responseHeaders ?? {})) {
|
|
91
|
+
const received = Object.entries(response.headers).find(([candidate]) => candidate.toLowerCase() === name.toLowerCase());
|
|
92
|
+
expect(`header ${name}`, received?.[1], value);
|
|
93
|
+
}
|
|
94
|
+
if (server.bodyBytes !== undefined) {
|
|
95
|
+
expect("body", response.body, server.bodyBytes);
|
|
96
|
+
}
|
|
97
|
+
return mismatches;
|
|
98
|
+
}
|
|
99
|
+
/** The row's `Aventara-Contract-Hash`, its name matched without regard to case. */
|
|
100
|
+
function contractHashOf(request) {
|
|
101
|
+
const value = Object.entries(request.headers).find(([name]) => name.toLowerCase() === "aventara-contract-hash")?.[1];
|
|
102
|
+
return typeof value === "string" ? value : undefined;
|
|
103
|
+
}
|
|
104
|
+
/** The parsed body, or `undefined` — which no envelope is — for text that is not JSON. */
|
|
105
|
+
function parsedBody(text) {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Framework } from "@aventara/core";
|
|
2
|
+
import type { AvProtocolRequest, AvProtocolResponse } from "@aventara/core/protocol";
|
|
3
|
+
/**
|
|
4
|
+
* One host under test, as the protocol conformance run drives it
|
|
5
|
+
* (`runAvProtocolConformance`): given the corpus's Framework, mount the
|
|
6
|
+
* protocol the way that host does, then answer each corpus request as that
|
|
7
|
+
* host would answer it over HTTP — status, headers and body text, as an
|
|
8
|
+
* `AvProtocolResponse`.
|
|
9
|
+
*
|
|
10
|
+
* Driver 1 (`in-process.driver.ts`) calls the bound protocol's shortcuts
|
|
11
|
+
* directly; a real host (a `node:http` server, Phase 11's Nest module) sends
|
|
12
|
+
* the request over its own stack and reads back what arrived. The run calls
|
|
13
|
+
* `close` once per row, after its one request.
|
|
14
|
+
*/
|
|
15
|
+
export type AvProtocolDriver = (framework: Framework) => Promise<{
|
|
16
|
+
readonly send: (request: AvProtocolRequest) => Promise<AvProtocolResponse>;
|
|
17
|
+
readonly close: () => Promise<void>;
|
|
18
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { OperationCode, ValidationCode } from "@aventara/core";
|
|
2
|
+
import type { AvProtocolRequest } from "@aventara/core/protocol";
|
|
3
|
+
/**
|
|
4
|
+
* One row of the Phase 10 protocol fixture corpus: the HTTP request a host
|
|
5
|
+
* receives (`request`), what the host must answer for it (`server`), and what
|
|
6
|
+
* a generated client must do with that answer (`client`). Types only; the
|
|
7
|
+
* rows live in `protocol-fixtures.ts`.
|
|
8
|
+
*
|
|
9
|
+
* `request` is core's own request value, `AvProtocolRequest` from
|
|
10
|
+
* `@aventara/core/protocol` (Q9), so a row is exactly what a host hands the
|
|
11
|
+
* bound protocol: no second request shape exists for the corpus to drift from.
|
|
12
|
+
*/
|
|
13
|
+
export type AvProtocolFixture = {
|
|
14
|
+
/**
|
|
15
|
+
* Stable across slices; the coverage gate reads it. A row that covers one
|
|
16
|
+
* of the specification's success rows begins with that row's operation
|
|
17
|
+
* name (`find.many …`, `create.one …`) or with `transaction` for the
|
|
18
|
+
* committed-plan row.
|
|
19
|
+
*/
|
|
20
|
+
readonly label: string;
|
|
21
|
+
/** Method, entrypoint-relative path, headers, and a raw or parsed body (Q11). */
|
|
22
|
+
readonly request: AvProtocolRequest;
|
|
23
|
+
readonly server: {
|
|
24
|
+
/**
|
|
25
|
+
* The HTTP status. `0` only on a transport-error row (family 11) where no
|
|
26
|
+
* HTTP response arrived at all — the Fetch API's network-error status.
|
|
27
|
+
*/
|
|
28
|
+
readonly status: number;
|
|
29
|
+
/** `null`: the host produces no framework envelope at all. */
|
|
30
|
+
readonly code: OperationCode | null;
|
|
31
|
+
/** The exact, sorted key set of `cause`. */
|
|
32
|
+
readonly causeKeys?: readonly string[];
|
|
33
|
+
readonly issues?: readonly {
|
|
34
|
+
readonly code: ValidationCode;
|
|
35
|
+
readonly path?: readonly (string | number)[];
|
|
36
|
+
}[];
|
|
37
|
+
/** Always stated: `0` is an assertion, not a default. */
|
|
38
|
+
readonly adapterCalls: number;
|
|
39
|
+
readonly responseHeaders?: Readonly<Record<string, string>>;
|
|
40
|
+
/** The exact body, where bytes matter. */
|
|
41
|
+
readonly bodyBytes?: string;
|
|
42
|
+
};
|
|
43
|
+
readonly client: {
|
|
44
|
+
/**
|
|
45
|
+
* What a generated client does with the answer. `"transport-error"`
|
|
46
|
+
* also marks a row no host produces (family 11): a host driver filters
|
|
47
|
+
* on `client.outcome === "transport-error"` and does not drive those rows.
|
|
48
|
+
*/
|
|
49
|
+
readonly outcome: "resolve" | "framework-error" | "transport-error";
|
|
50
|
+
/** Present iff `outcome` is `"framework-error"`. */
|
|
51
|
+
readonly code?: OperationCode;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|