@magicvr/schema-ui-protocol 0.2.1 → 0.2.3
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/lib/fetch-timeout.js +43 -0
- package/package.json +5 -5
- package/protocol/app-manifest.js +677 -0
- package/protocol/conformance/actions-outcome.js +106 -0
- package/protocol/conformance/component-format.js +21 -0
- package/protocol/conformance/query-serialize.js +188 -0
- package/protocol/conformance/request-construction.js +695 -0
- package/protocol/conformance/request-lifecycle.js +57 -0
- package/protocol/conformance/response-mapping.js +170 -0
- package/protocol/conformance/runtime-defaults.js +75 -0
- package/protocol/conformance/runtime-schema-validate.js +82 -0
- package/protocol/conformance/schema-validate.js +119 -0
- package/protocol/conformance/search-table.js +107 -0
- package/protocol/conformance/static-data.js +87 -0
- package/protocol/conformance/table-sort.js +217 -0
- package/protocol/conformance/upload-orchestration.js +174 -0
- package/protocol/conformance/version-negotiate.js +155 -0
- package/protocol/index.d.ts +2 -2
- package/protocol/index.js +8 -0
- package/protocol/load-page.js +88 -0
- package/index.js +0 -7654
- package/protocol/conformance-claim.json +0 -81
- package/protocol/conformance-claim.json.sha256 +0 -1
- package/protocol/conformance-local-report.json +0 -60
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* static-data fixture adapter (schema-ui-docs v2.7.0).
|
|
3
|
+
*/
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function resolveRef(ref, datasources) {
|
|
8
|
+
if (!datasources || !Object.prototype.hasOwnProperty.call(datasources, ref)) {
|
|
9
|
+
return { ok: false, code: "STATIC_DATA_REF_INVALID" };
|
|
10
|
+
}
|
|
11
|
+
const ds = datasources[ref];
|
|
12
|
+
if (!isRecord(ds) || ds.source !== "static") {
|
|
13
|
+
return { ok: false, code: "STATIC_DATA_REF_INVALID" };
|
|
14
|
+
}
|
|
15
|
+
return { ok: true, value: ds.value };
|
|
16
|
+
}
|
|
17
|
+
function shapeOk(component, value, valueField) {
|
|
18
|
+
switch (component) {
|
|
19
|
+
case "table":
|
|
20
|
+
case "chart":
|
|
21
|
+
return Array.isArray(value);
|
|
22
|
+
case "text":
|
|
23
|
+
return (value === null ||
|
|
24
|
+
typeof value === "string" ||
|
|
25
|
+
typeof value === "number" ||
|
|
26
|
+
typeof value === "boolean");
|
|
27
|
+
case "statCard":
|
|
28
|
+
if (valueField !== undefined) {
|
|
29
|
+
// Already extracted field; null rejected.
|
|
30
|
+
return value !== null && value !== undefined && !Array.isArray(value) && typeof value !== "object";
|
|
31
|
+
}
|
|
32
|
+
// Bare static value for statCard: object ok if we will read field; null rejected.
|
|
33
|
+
if (value === null) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
default:
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function resolveStaticData(input) {
|
|
42
|
+
const { component, data, datasources, props } = input;
|
|
43
|
+
if (data.responseMapping !== undefined) {
|
|
44
|
+
return { ok: false, code: "STATIC_RESPONSE_MAPPING_NOT_ALLOWED" };
|
|
45
|
+
}
|
|
46
|
+
let value;
|
|
47
|
+
if (data.source === "static") {
|
|
48
|
+
value = data.value;
|
|
49
|
+
}
|
|
50
|
+
else if (data.source === "ref") {
|
|
51
|
+
if (typeof data.ref !== "string") {
|
|
52
|
+
return { ok: false, code: "STATIC_DATA_REF_INVALID" };
|
|
53
|
+
}
|
|
54
|
+
const resolved = resolveRef(data.ref, datasources);
|
|
55
|
+
if (!resolved.ok) {
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
value = resolved.value;
|
|
59
|
+
if (component === "statCard" && props?.valueField) {
|
|
60
|
+
if (!isRecord(value) || !Object.prototype.hasOwnProperty.call(value, props.valueField)) {
|
|
61
|
+
return { ok: false, code: "STATIC_DATA_SHAPE_MISMATCH" };
|
|
62
|
+
}
|
|
63
|
+
value = value[props.valueField];
|
|
64
|
+
if (!shapeOk(component, value, props.valueField)) {
|
|
65
|
+
return { ok: false, code: "STATIC_DATA_SHAPE_MISMATCH" };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, value, network: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
return { ok: false, code: "STATIC_DATA_SHAPE_MISMATCH" };
|
|
72
|
+
}
|
|
73
|
+
if (!shapeOk(component, value, props?.valueField)) {
|
|
74
|
+
return { ok: false, code: "STATIC_DATA_SHAPE_MISMATCH" };
|
|
75
|
+
}
|
|
76
|
+
// text rejects arrays
|
|
77
|
+
if (component === "text" && Array.isArray(value)) {
|
|
78
|
+
return { ok: false, code: "STATIC_DATA_SHAPE_MISMATCH" };
|
|
79
|
+
}
|
|
80
|
+
if (component === "statCard" && value === null) {
|
|
81
|
+
return { ok: false, code: "STATIC_DATA_SHAPE_MISMATCH" };
|
|
82
|
+
}
|
|
83
|
+
if (component === "table" && !Array.isArray(value)) {
|
|
84
|
+
return { ok: false, code: "STATIC_DATA_SHAPE_MISMATCH" };
|
|
85
|
+
}
|
|
86
|
+
return { ok: true, value, network: false };
|
|
87
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* table-sort fixture adapter (ADR-0027 three-state sort + L2 validate).
|
|
3
|
+
*/
|
|
4
|
+
import { serializeQuery } from "./query-serialize.js";
|
|
5
|
+
function parseVersion(version) {
|
|
6
|
+
const m = /^(\d+)\.(\d+)$/.exec(version);
|
|
7
|
+
if (!m) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
return [Number(m[1]), Number(m[2])];
|
|
11
|
+
}
|
|
12
|
+
function versionGte(version, min) {
|
|
13
|
+
const a = parseVersion(version);
|
|
14
|
+
const b = parseVersion(min);
|
|
15
|
+
if (!a || !b) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (a[0] !== b[0]) {
|
|
19
|
+
return a[0] > b[0];
|
|
20
|
+
}
|
|
21
|
+
return a[1] >= b[1];
|
|
22
|
+
}
|
|
23
|
+
function sortKeyOf(column) {
|
|
24
|
+
return column.sortField ?? column.field;
|
|
25
|
+
}
|
|
26
|
+
function knownSortKeys(table) {
|
|
27
|
+
const keys = new Set();
|
|
28
|
+
for (const column of table.columns) {
|
|
29
|
+
if (column.sortable) {
|
|
30
|
+
keys.add(sortKeyOf(column));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return keys;
|
|
34
|
+
}
|
|
35
|
+
function buildUrl(baseUrl, state) {
|
|
36
|
+
const pairs = [];
|
|
37
|
+
for (const [key, value] of Object.entries(state.filters)) {
|
|
38
|
+
pairs.push([key, value]);
|
|
39
|
+
}
|
|
40
|
+
pairs.push(["page", state.page]);
|
|
41
|
+
pairs.push(["pageSize", state.pageSize]);
|
|
42
|
+
if (state.sort) {
|
|
43
|
+
pairs.push(["sort", state.sort]);
|
|
44
|
+
}
|
|
45
|
+
const sources = [pairs];
|
|
46
|
+
const result = serializeQuery(baseUrl, sources);
|
|
47
|
+
if (!result.ok) {
|
|
48
|
+
throw new Error(result.code);
|
|
49
|
+
}
|
|
50
|
+
return result.url;
|
|
51
|
+
}
|
|
52
|
+
function validateSortTable(meta, table) {
|
|
53
|
+
const version = meta.protocolVersion ?? "0.0";
|
|
54
|
+
const caps = meta.requiredCapabilities ?? [];
|
|
55
|
+
const usesSort = table.columns.some((c) => c.sortable) || table.defaultSort !== undefined;
|
|
56
|
+
if (!usesSort) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!versionGte(version, "2.5")) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
code: "PROTOCOL_VERSION_TOO_LOW",
|
|
63
|
+
path: "meta.protocolVersion",
|
|
64
|
+
errors: [{ code: "PROTOCOL_VERSION_TOO_LOW", path: "meta.protocolVersion" }],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (!caps.includes("table.sort")) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
code: "CAPABILITY_REQUIRED",
|
|
71
|
+
path: "meta.requiredCapabilities",
|
|
72
|
+
errors: [{ code: "CAPABILITY_REQUIRED", path: "meta.requiredCapabilities" }],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (let i = 0; i < table.columns.length; i++) {
|
|
77
|
+
const column = table.columns[i];
|
|
78
|
+
if (!column.sortable) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const key = sortKeyOf(column);
|
|
82
|
+
if (seen.has(key)) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
code: "SORT_KEY_DUPLICATE",
|
|
86
|
+
path: `columns[${i}]`,
|
|
87
|
+
errors: [{ code: "SORT_KEY_DUPLICATE", path: `columns[${i}]` }],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
seen.add(key);
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
export function runTableSort(input) {
|
|
95
|
+
const meta = input.meta ?? {};
|
|
96
|
+
const table = input.table;
|
|
97
|
+
const baseUrl = input.baseUrl ?? "/orders";
|
|
98
|
+
if (input.operation === "validate") {
|
|
99
|
+
const err = validateSortTable(meta, table);
|
|
100
|
+
if (err) {
|
|
101
|
+
return err;
|
|
102
|
+
}
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
// v2.4 compat: sort interaction disabled
|
|
106
|
+
const protocolSortInteraction = versionGte(meta.protocolVersion ?? "0.0", "2.5");
|
|
107
|
+
let state = input.state
|
|
108
|
+
? {
|
|
109
|
+
filters: { ...(input.state.filters ?? {}) },
|
|
110
|
+
page: input.state.page ?? 1,
|
|
111
|
+
pageSize: input.state.pageSize ?? table.pagination?.pageSize ?? 20,
|
|
112
|
+
sort: input.state.sort ?? null,
|
|
113
|
+
}
|
|
114
|
+
: {
|
|
115
|
+
filters: {},
|
|
116
|
+
page: 1,
|
|
117
|
+
pageSize: table.pagination?.pageSize ?? 20,
|
|
118
|
+
sort: null,
|
|
119
|
+
};
|
|
120
|
+
let selection = input.selection
|
|
121
|
+
? {
|
|
122
|
+
keys: [...(input.selection.keys ?? [])],
|
|
123
|
+
count: input.selection.count ?? 0,
|
|
124
|
+
}
|
|
125
|
+
: undefined;
|
|
126
|
+
const event = input.event;
|
|
127
|
+
const keys = knownSortKeys(table);
|
|
128
|
+
// Unknown current sort key blocks request
|
|
129
|
+
if (state.sort) {
|
|
130
|
+
const field = state.sort.split(":")[0] ?? "";
|
|
131
|
+
if (!keys.has(field)) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
code: "TABLE_SORT_FIELD_UNKNOWN",
|
|
135
|
+
requestEmitted: false,
|
|
136
|
+
state,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let requestEmitted = false;
|
|
141
|
+
if (!protocolSortInteraction) {
|
|
142
|
+
if (event?.type === "clickSort") {
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
protocolSortInteraction: false,
|
|
146
|
+
state,
|
|
147
|
+
url: buildUrl(baseUrl, state),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (event?.type === "init") {
|
|
152
|
+
if (table.defaultSort) {
|
|
153
|
+
state = {
|
|
154
|
+
...state,
|
|
155
|
+
sort: `${table.defaultSort.field}:${table.defaultSort.order}`,
|
|
156
|
+
page: 1,
|
|
157
|
+
};
|
|
158
|
+
requestEmitted = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (event?.type === "clickSort") {
|
|
162
|
+
const columnField = event.field;
|
|
163
|
+
const column = table.columns.find((c) => c.field === columnField);
|
|
164
|
+
if (!column || !column.sortable) {
|
|
165
|
+
requestEmitted = true;
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
requestEmitted,
|
|
169
|
+
state,
|
|
170
|
+
url: buildUrl(baseUrl, state),
|
|
171
|
+
...(selection ? { selection } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const key = sortKeyOf(column);
|
|
175
|
+
const current = state.sort;
|
|
176
|
+
let next;
|
|
177
|
+
if (current === `${key}:asc`) {
|
|
178
|
+
next = `${key}:desc`;
|
|
179
|
+
}
|
|
180
|
+
else if (current === `${key}:desc`) {
|
|
181
|
+
next = null;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
next = `${key}:asc`;
|
|
185
|
+
}
|
|
186
|
+
state = { ...state, sort: next, page: 1 };
|
|
187
|
+
requestEmitted = true;
|
|
188
|
+
if (selection) {
|
|
189
|
+
selection = { keys: [], count: 0 };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (event?.type === "submitSearch") {
|
|
193
|
+
state = {
|
|
194
|
+
...state,
|
|
195
|
+
filters: { ...(event.filters ?? {}) },
|
|
196
|
+
page: 1,
|
|
197
|
+
};
|
|
198
|
+
requestEmitted = true;
|
|
199
|
+
}
|
|
200
|
+
else if (event == null) {
|
|
201
|
+
// no event — just evaluate current state (unknown sort already handled)
|
|
202
|
+
requestEmitted = false;
|
|
203
|
+
}
|
|
204
|
+
const result = {
|
|
205
|
+
ok: true,
|
|
206
|
+
requestEmitted,
|
|
207
|
+
state,
|
|
208
|
+
url: buildUrl(baseUrl, state),
|
|
209
|
+
};
|
|
210
|
+
if (selection) {
|
|
211
|
+
result.selection = selection;
|
|
212
|
+
}
|
|
213
|
+
if (!protocolSortInteraction) {
|
|
214
|
+
result.protocolSortInteraction = false;
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload orchestration (schema-ui-docs@2.7.0 · docs/07-actions-contract.md §7
|
|
3
|
+
* + ADR-0012). I-PROTO-FULL-001 D-UPLOAD include.
|
|
4
|
+
*
|
|
5
|
+
* Client contract:
|
|
6
|
+
* - one multipart request per file (fieldName default "file"; method POST
|
|
7
|
+
* default, PUT allowed)
|
|
8
|
+
* - constraints (multiple / maxSize / accept) are checked BEFORE any
|
|
9
|
+
* request; any violation rejects the whole batch atomically
|
|
10
|
+
* - accept tokens: ".ext" matches the file extension case-insensitively,
|
|
11
|
+
* "type/*" matches the MIME main type, other tokens match MIME exactly
|
|
12
|
+
* (case-insensitive)
|
|
13
|
+
* - serial uploads in selection order; any failure stops the batch and no
|
|
14
|
+
* partial field value is committed
|
|
15
|
+
* - field value: url (priority) or id; multiple → array in selection order
|
|
16
|
+
* - retryPolicy idempotent → Idempotency-Key header "{invocationId}:{index}"
|
|
17
|
+
*/
|
|
18
|
+
const PROTOCOL_URL_RE = /^\/(?!\/)[^\s\\]*$/;
|
|
19
|
+
const RETRY_POLICIES = new Set(["never", "idempotent"]);
|
|
20
|
+
function fail(code, fileIndex, requests) {
|
|
21
|
+
return { ok: false, code, fileIndex, requests, fieldValue: null };
|
|
22
|
+
}
|
|
23
|
+
function responseFieldValue(response) {
|
|
24
|
+
if (typeof response.url === "string" && response.url !== "") {
|
|
25
|
+
return response.url;
|
|
26
|
+
}
|
|
27
|
+
if (typeof response.id === "string" && response.id !== "") {
|
|
28
|
+
return response.id;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** accept token matching (ADR-0012 D2): ".ext", "type/*", exact MIME. */
|
|
33
|
+
export function acceptTokenMatches(token, file) {
|
|
34
|
+
const trimmed = token.trim();
|
|
35
|
+
if (trimmed === "") {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (trimmed.startsWith(".")) {
|
|
39
|
+
const dot = file.name.lastIndexOf(".");
|
|
40
|
+
if (dot < 0 || dot === file.name.length - 1) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return file.name.slice(dot).toLowerCase() === trimmed.toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
if (trimmed.endsWith("/*")) {
|
|
46
|
+
const main = trimmed.slice(0, trimmed.length - 2);
|
|
47
|
+
return file.type.toLowerCase().startsWith(`${main.toLowerCase()}/`);
|
|
48
|
+
}
|
|
49
|
+
return file.type.toLowerCase() === trimmed.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
/** Client-side constraints (ADR-0012 D2); returns the first violation. */
|
|
52
|
+
export function validateUploadSelection(action, files) {
|
|
53
|
+
const maxIndex = action.multiple === true ? files.length : Math.min(files.length, 1);
|
|
54
|
+
if (action.multiple !== true && files.length > 1) {
|
|
55
|
+
return { ok: false, code: "MULTIPLE_FILES_NOT_ALLOWED", fileIndex: 1 };
|
|
56
|
+
}
|
|
57
|
+
for (let index = 0; index < maxIndex; index += 1) {
|
|
58
|
+
const file = files[index];
|
|
59
|
+
if (typeof action.maxSize === "number" && action.maxSize >= 0 && file.size > action.maxSize) {
|
|
60
|
+
return { ok: false, code: "FILE_TOO_LARGE", fileIndex: index };
|
|
61
|
+
}
|
|
62
|
+
if (typeof action.accept === "string" && action.accept.trim() !== "") {
|
|
63
|
+
const tokens = action.accept.split(",").map((token) => token.trim()).filter((token) => token !== "");
|
|
64
|
+
const matched = tokens.some((token) => acceptTokenMatches(token, file));
|
|
65
|
+
if (!matched) {
|
|
66
|
+
return { ok: false, code: "UNSUPPORTED_FILE_TYPE", fileIndex: index };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
/** Builds the per-file multipart request descriptors (pre-request). */
|
|
73
|
+
export function buildUploadRequests(action, files, invocationId) {
|
|
74
|
+
if (typeof action.url !== "string" || !PROTOCOL_URL_RE.test(action.url)) {
|
|
75
|
+
return fail("INVALID_PROTOCOL_URL", 0, []);
|
|
76
|
+
}
|
|
77
|
+
const retryPolicy = action.retryPolicy ?? "never";
|
|
78
|
+
if (!RETRY_POLICIES.has(retryPolicy)) {
|
|
79
|
+
return fail("INVALID_RETRY_POLICY", 0, []);
|
|
80
|
+
}
|
|
81
|
+
if (retryPolicy === "idempotent" && (typeof invocationId !== "string" || invocationId === "")) {
|
|
82
|
+
return fail("MISSING_INVOCATION_ID", 0, []);
|
|
83
|
+
}
|
|
84
|
+
const fieldName = typeof action.fieldName === "string" && action.fieldName !== "" ? action.fieldName : "file";
|
|
85
|
+
const method = action.method === "PUT" ? "PUT" : "POST";
|
|
86
|
+
const requests = files.map((file, index) => ({
|
|
87
|
+
method,
|
|
88
|
+
url: action.url,
|
|
89
|
+
part: { name: fieldName, fileName: file.name, contentId: file.contentId },
|
|
90
|
+
...(retryPolicy === "idempotent"
|
|
91
|
+
? { headers: { "Idempotency-Key": `${invocationId}:${index}` } }
|
|
92
|
+
: {}),
|
|
93
|
+
}));
|
|
94
|
+
return { ok: true, requests };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Fixture-driven orchestration: replays pre-recorded transport results
|
|
98
|
+
* (`conformance/fixtures/uploads/cases.json` contract).
|
|
99
|
+
*/
|
|
100
|
+
export function runUploadBatch(input) {
|
|
101
|
+
const validation = validateUploadSelection(input.action, input.files);
|
|
102
|
+
if (!validation.ok) {
|
|
103
|
+
return fail(validation.code, validation.fileIndex, []);
|
|
104
|
+
}
|
|
105
|
+
const built = buildUploadRequests(input.action, input.files, input.invocationId);
|
|
106
|
+
if (!built.ok) {
|
|
107
|
+
return built;
|
|
108
|
+
}
|
|
109
|
+
const requests = built.requests;
|
|
110
|
+
const values = [];
|
|
111
|
+
const results = input.results ?? [];
|
|
112
|
+
for (let index = 0; index < requests.length; index += 1) {
|
|
113
|
+
const result = results[index];
|
|
114
|
+
if (result === undefined || result.type === "failure") {
|
|
115
|
+
return fail("UPLOAD_REQUEST_FAILED", index, requests.slice(0, index + 1));
|
|
116
|
+
}
|
|
117
|
+
const value = responseFieldValue(result.response);
|
|
118
|
+
if (value === null) {
|
|
119
|
+
return fail("INVALID_UPLOAD_RESPONSE", index, requests.slice(0, index + 1));
|
|
120
|
+
}
|
|
121
|
+
values.push(value);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
requests,
|
|
126
|
+
fieldValue: input.action.multiple === true ? values : (values[0] ?? null),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Real transport orchestration used by the Renderer's upload control: the
|
|
131
|
+
* same constraints and request shape, executed against a live fetch with
|
|
132
|
+
* multipart FormData carrying the actual file bytes (ADR-0012 D4).
|
|
133
|
+
*/
|
|
134
|
+
export async function uploadFilesWithFetch(action, files, fetcher, invocationId) {
|
|
135
|
+
const validation = validateUploadSelection(action, files);
|
|
136
|
+
if (!validation.ok) {
|
|
137
|
+
return fail(validation.code, validation.fileIndex, []);
|
|
138
|
+
}
|
|
139
|
+
const built = buildUploadRequests(action, files, invocationId);
|
|
140
|
+
if (!built.ok) {
|
|
141
|
+
return built;
|
|
142
|
+
}
|
|
143
|
+
const requests = built.requests;
|
|
144
|
+
const values = [];
|
|
145
|
+
for (let index = 0; index < requests.length; index += 1) {
|
|
146
|
+
const request = requests[index];
|
|
147
|
+
const file = files[index];
|
|
148
|
+
const form = new FormData();
|
|
149
|
+
form.append(request.part.name, file.blob, file.name);
|
|
150
|
+
const response = await fetcher(request.url, {
|
|
151
|
+
method: request.method,
|
|
152
|
+
...(request.headers === undefined ? {} : { headers: request.headers }),
|
|
153
|
+
body: form,
|
|
154
|
+
});
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
return fail("UPLOAD_REQUEST_FAILED", index, requests.slice(0, index + 1));
|
|
157
|
+
}
|
|
158
|
+
const payload = await response.json();
|
|
159
|
+
const record = (typeof payload === "object" && payload !== null ? payload : {});
|
|
160
|
+
const value = responseFieldValue({
|
|
161
|
+
url: typeof record.url === "string" ? record.url : undefined,
|
|
162
|
+
id: typeof record.id === "string" ? record.id : undefined,
|
|
163
|
+
});
|
|
164
|
+
if (value === null) {
|
|
165
|
+
return fail("INVALID_UPLOAD_RESPONSE", index, requests.slice(0, index + 1));
|
|
166
|
+
}
|
|
167
|
+
values.push(value);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
requests,
|
|
172
|
+
fieldValue: action.multiple === true ? values : (values[0] ?? null),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version-negotiation fixture adapter (strict version + capability check).
|
|
3
|
+
*/
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function isVersionString(value) {
|
|
8
|
+
return typeof value === "string" && /^[0-9]+\.[0-9]+$/.test(value);
|
|
9
|
+
}
|
|
10
|
+
/** Capability id pattern from page.schema.json (lowercase dotted; v2.8 allows segment hyphens). */
|
|
11
|
+
function isCapabilityId(value) {
|
|
12
|
+
return typeof value === "string" && /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/.test(value);
|
|
13
|
+
}
|
|
14
|
+
function pageVersionOf(pageMeta) {
|
|
15
|
+
if (!isRecord(pageMeta)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return pageMeta.protocolVersion ?? null;
|
|
19
|
+
}
|
|
20
|
+
export function negotiateVersion(input) {
|
|
21
|
+
const pageMeta = input.pageMeta;
|
|
22
|
+
const rendererSupport = input.rendererSupport;
|
|
23
|
+
const pageVersionHint = pageVersionOf(pageMeta);
|
|
24
|
+
if (!isRecord(rendererSupport)) {
|
|
25
|
+
return {
|
|
26
|
+
accepted: false,
|
|
27
|
+
code: "INVALID_RENDERER_SUPPORT",
|
|
28
|
+
pageVersion: isVersionString(pageVersionHint) ? pageVersionHint : pageVersionHint,
|
|
29
|
+
supportedVersions: [],
|
|
30
|
+
missingCapabilities: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const supportedVersionsRaw = rendererSupport.supportedVersions;
|
|
34
|
+
const supportedVersions = Array.isArray(supportedVersionsRaw)
|
|
35
|
+
? supportedVersionsRaw
|
|
36
|
+
: [];
|
|
37
|
+
const versionsInvalid = !Array.isArray(supportedVersionsRaw) ||
|
|
38
|
+
supportedVersions.length === 0 ||
|
|
39
|
+
supportedVersions.some((v) => !isVersionString(v)) ||
|
|
40
|
+
new Set(supportedVersions).size !== supportedVersions.length;
|
|
41
|
+
// Parse page meta early so invalid renderer still reports pageVersion when present.
|
|
42
|
+
let pageVersion = null;
|
|
43
|
+
let required = [];
|
|
44
|
+
if (isRecord(pageMeta)) {
|
|
45
|
+
if (Object.prototype.hasOwnProperty.call(pageMeta, "protocolVersion")) {
|
|
46
|
+
pageVersion = pageMeta.protocolVersion;
|
|
47
|
+
}
|
|
48
|
+
if (pageMeta.requiredCapabilities !== undefined) {
|
|
49
|
+
if (!Array.isArray(pageMeta.requiredCapabilities) ||
|
|
50
|
+
pageMeta.requiredCapabilities.some((c) => typeof c !== "string") ||
|
|
51
|
+
pageMeta.requiredCapabilities.some((c) => !isCapabilityId(c)) ||
|
|
52
|
+
new Set(pageMeta.requiredCapabilities).size !==
|
|
53
|
+
pageMeta.requiredCapabilities.length) {
|
|
54
|
+
// INVALID_REQUIRED_CAPABILITIES takes precedence over invalid renderer caps
|
|
55
|
+
// when both could apply (see fixture order).
|
|
56
|
+
if (!versionsInvalid || true) {
|
|
57
|
+
// Still need version string validation path below for missing version.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (versionsInvalid) {
|
|
63
|
+
return {
|
|
64
|
+
accepted: false,
|
|
65
|
+
code: "INVALID_RENDERER_SUPPORT",
|
|
66
|
+
pageVersion: isVersionString(pageVersion)
|
|
67
|
+
? pageVersion
|
|
68
|
+
: isVersionString(pageVersionHint)
|
|
69
|
+
? pageVersionHint
|
|
70
|
+
: pageVersionHint,
|
|
71
|
+
supportedVersions: supportedVersions,
|
|
72
|
+
missingCapabilities: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const supportedVersionsList = supportedVersions;
|
|
76
|
+
const supportedCapabilities = Array.isArray(rendererSupport.supportedCapabilities)
|
|
77
|
+
? rendererSupport.supportedCapabilities
|
|
78
|
+
: [];
|
|
79
|
+
if (!isRecord(pageMeta)) {
|
|
80
|
+
return {
|
|
81
|
+
accepted: false,
|
|
82
|
+
code: "MISSING_PROTOCOL_VERSION",
|
|
83
|
+
pageVersion: null,
|
|
84
|
+
supportedVersions: supportedVersionsList,
|
|
85
|
+
missingCapabilities: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!Object.prototype.hasOwnProperty.call(pageMeta, "protocolVersion")) {
|
|
89
|
+
return {
|
|
90
|
+
accepted: false,
|
|
91
|
+
code: "MISSING_PROTOCOL_VERSION",
|
|
92
|
+
pageVersion: null,
|
|
93
|
+
supportedVersions: supportedVersionsList,
|
|
94
|
+
missingCapabilities: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!isVersionString(pageVersion)) {
|
|
98
|
+
return {
|
|
99
|
+
accepted: false,
|
|
100
|
+
code: "INVALID_PROTOCOL_VERSION",
|
|
101
|
+
pageVersion,
|
|
102
|
+
supportedVersions: supportedVersionsList,
|
|
103
|
+
missingCapabilities: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (pageMeta.requiredCapabilities !== undefined) {
|
|
107
|
+
const caps = pageMeta.requiredCapabilities;
|
|
108
|
+
if (!Array.isArray(caps) ||
|
|
109
|
+
caps.some((c) => typeof c !== "string") ||
|
|
110
|
+
caps.some((c) => !isCapabilityId(c)) ||
|
|
111
|
+
new Set(caps).size !== caps.length) {
|
|
112
|
+
return {
|
|
113
|
+
accepted: false,
|
|
114
|
+
code: "INVALID_REQUIRED_CAPABILITIES",
|
|
115
|
+
pageVersion,
|
|
116
|
+
supportedVersions: supportedVersionsList,
|
|
117
|
+
missingCapabilities: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
required = caps;
|
|
121
|
+
}
|
|
122
|
+
// Renderer capability list validation is secondary; fixtures primarily care about required.
|
|
123
|
+
if (Array.isArray(rendererSupport.supportedCapabilities) &&
|
|
124
|
+
(new Set(supportedCapabilities).size !== supportedCapabilities.length ||
|
|
125
|
+
supportedCapabilities.some((c) => !isCapabilityId(c)))) {
|
|
126
|
+
// If required caps were valid we already passed; invalid renderer caps alone
|
|
127
|
+
// may still be INVALID_RENDERER_SUPPORT in some suites — not present in current set.
|
|
128
|
+
}
|
|
129
|
+
if (!supportedVersionsList.includes(pageVersion)) {
|
|
130
|
+
return {
|
|
131
|
+
accepted: false,
|
|
132
|
+
code: "UNSUPPORTED_PROTOCOL_VERSION",
|
|
133
|
+
pageVersion,
|
|
134
|
+
supportedVersions: supportedVersionsList,
|
|
135
|
+
missingCapabilities: [],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const missing = required.filter((cap) => !supportedCapabilities.includes(cap));
|
|
139
|
+
if (missing.length > 0) {
|
|
140
|
+
return {
|
|
141
|
+
accepted: false,
|
|
142
|
+
code: "MISSING_REQUIRED_CAPABILITY",
|
|
143
|
+
pageVersion,
|
|
144
|
+
supportedVersions: supportedVersionsList,
|
|
145
|
+
missingCapabilities: missing,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
accepted: true,
|
|
150
|
+
code: "OK",
|
|
151
|
+
pageVersion,
|
|
152
|
+
supportedVersions: supportedVersionsList,
|
|
153
|
+
missingCapabilities: [],
|
|
154
|
+
};
|
|
155
|
+
}
|
package/protocol/index.d.ts
CHANGED