@polyengine/ct-runner 0.1.0-pre.g633468a
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 +202 -0
- package/README.md +23 -0
- package/esm/context.js +48 -0
- package/esm/import-analysis.js +67 -0
- package/esm/mod.js +12 -0
- package/esm/package.json +3 -0
- package/esm/run-suite.js +299 -0
- package/esm/tags.js +164 -0
- package/package.json +55 -0
- package/types/context.d.ts +28 -0
- package/types/import-analysis.d.ts +35 -0
- package/types/mod.d.ts +4 -0
- package/types/run-suite.d.ts +117 -0
- package/types/tags.d.ts +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @polyengine/ct-runner
|
|
2
|
+
|
|
3
|
+
The polyengine execution runner for component-test-results (L1) conformance suites.
|
|
4
|
+
|
|
5
|
+
Part of [polyengine](https://github.com/polymorph-components/polyengine), a
|
|
6
|
+
WebAssembly Component Model host for JavaScript engines. This is the npm
|
|
7
|
+
distribution; the same package is published to JSR as `jsr:@polyengine/ct-runner`, and the
|
|
8
|
+
two are built from the same sources at the same version.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @polyengine/ct-runner
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import * as api from "@polyengine/ct-runner";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Entry points: `.`, `./context`, `./imports`, `./run`.
|
|
19
|
+
|
|
20
|
+
ESM only, Node >= 22.14. Documentation, examples and the embedder API contract
|
|
21
|
+
live in the [repository](https://github.com/polymorph-components/polyengine).
|
|
22
|
+
|
|
23
|
+
Apache-2.0.
|
package/esm/context.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The host-side `test-context` provider (L1 contract's runner-side growth
|
|
2
|
+
// surface): a host-implemented resource class per contracts/embedder-api.md
|
|
3
|
+
// §"Resources" (host provides a plain class; the runtime owns instance<->rep
|
|
4
|
+
// mapping). The `context` resource's `diagnostic` is an `async func` import
|
|
5
|
+
// (WIT: wit/tests.wit `test-context.diagnostic: async func(msg: string)`,
|
|
6
|
+
// vendored at examples/guests/test-suite/wit/tests.wit) — per
|
|
7
|
+
// contracts/embedder-api.md §"Functions and async", an async-func import may
|
|
8
|
+
// be a plain `async` JS function.
|
|
9
|
+
//
|
|
10
|
+
// ARCHITECTURE.md Rule 3 ("introspecting host runner"): this file *is* the L2
|
|
11
|
+
// context-provider component, reimplemented host-side rather than composed in
|
|
12
|
+
// as wasm — permitted as long as it stays behaviorally equivalent to the
|
|
13
|
+
// layered path. It must never grow beyond what `test-context` names.
|
|
14
|
+
/** The frozen L1 interface id `test-context` is provided under. */
|
|
15
|
+
export const TEST_CONTEXT_INTERFACE = "polymorph:test/test-context@0.1.0";
|
|
16
|
+
/**
|
|
17
|
+
* One case's diagnostic sink. Host-side: no reps, no side tables — see
|
|
18
|
+
* contracts/embedder-api.md's host-implemented-resource column.
|
|
19
|
+
*
|
|
20
|
+
* `diagnostic` "may block cooperatively" per the WIT doc comment; this
|
|
21
|
+
* in-process host implementation never blocks (no backpressure to model),
|
|
22
|
+
* so it resolves immediately — still a valid `async func` implementation
|
|
23
|
+
* (contracts/embedder-api.md: "sync implementations remain legal").
|
|
24
|
+
*/
|
|
25
|
+
export class Context {
|
|
26
|
+
#onDiagnostic;
|
|
27
|
+
constructor(onDiagnostic) {
|
|
28
|
+
this.#onDiagnostic = onDiagnostic;
|
|
29
|
+
}
|
|
30
|
+
// deno-lint-ignore require-await
|
|
31
|
+
async diagnostic(msg) {
|
|
32
|
+
this.#onDiagnostic(msg);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the `test-context` import-record entry
|
|
37
|
+
* (`{ [TEST_CONTEXT_INTERFACE]: { Context } }`), per contracts/embedder-api.md
|
|
38
|
+
* §"Module wiring and instantiation" (resource classes sit at the resource's
|
|
39
|
+
* position in the record, PascalCase).
|
|
40
|
+
*
|
|
41
|
+
* The class is registered once per `instantiate` call regardless of how many
|
|
42
|
+
* cases run against that instance; the runner never asks the guest to
|
|
43
|
+
* construct a `context` (the WIT resource has no constructor — the host
|
|
44
|
+
* always initiates the borrow itself when calling `run`).
|
|
45
|
+
*/
|
|
46
|
+
export function testContextImportRecord() {
|
|
47
|
+
return { [TEST_CONTEXT_INTERFACE]: { Context } };
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Translate-only import analysis: `requiredImports()` (contracts/embedder-api.md
|
|
2
|
+
// §"Module wiring and instantiation") plus the runner's own test-context
|
|
3
|
+
// auto-wiring policy, without ever instantiating the suite. Used both by
|
|
4
|
+
// `runSuite` (to fail fast, before an async instantiate) and standalone (the
|
|
5
|
+
// "translate-only acceptance" gate: enumerate what a not-yet-executable suite
|
|
6
|
+
// is missing, without trying to run it).
|
|
7
|
+
import { NameCollisionError, requiredImports, } from "@polyengine/runtime/embedder";
|
|
8
|
+
import { ImportResolver } from "@polyengine/runtime/embedder";
|
|
9
|
+
import { TEST_CONTEXT_INTERFACE } from "./context.js";
|
|
10
|
+
/** One or more of the suite's import leaves cannot be resolved (yet). */
|
|
11
|
+
export class MissingImportsError extends Error {
|
|
12
|
+
/** The unresolved leaves, in `requiredImports()` order. */
|
|
13
|
+
leaves;
|
|
14
|
+
/** The distinct top-level record keys (interface ids / bare names) missing. */
|
|
15
|
+
missing;
|
|
16
|
+
constructor(missing, leaves) {
|
|
17
|
+
super(`suite is missing ${missing.length} host import(s): ` +
|
|
18
|
+
missing.map((m) => `'${m}'`).join(", "));
|
|
19
|
+
this.name = "MissingImportsError";
|
|
20
|
+
this.missing = missing;
|
|
21
|
+
this.leaves = leaves;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Analyze a suite's import surface against a caller-provided imports record,
|
|
26
|
+
* WITHOUT instantiating anything (translate-only; `requiredImports` reads
|
|
27
|
+
* only the plan). Detects:
|
|
28
|
+
* - whether `test-context` is imported (only then does the runner provide
|
|
29
|
+
* it — pre-composed bundles with the provider already linked must work
|
|
30
|
+
* too, per the dispatch);
|
|
31
|
+
* - a caller/runner collision on `test-context` (the runner "merges,
|
|
32
|
+
* erroring on collisions" per the dispatch's import-wiring spec);
|
|
33
|
+
* - every other top-level import key the suite needs but `provided` lacks,
|
|
34
|
+
* via the same version-canonical resolution `instantiate` itself uses
|
|
35
|
+
* (`ImportResolver`, contracts/embedder-api.md §"Version canonicalization").
|
|
36
|
+
*/
|
|
37
|
+
export function analyzeImports(plan, provided = {}) {
|
|
38
|
+
const leaves = requiredImports(plan);
|
|
39
|
+
const requiresTestContext = leaves.some((l) => l.interfaceId === TEST_CONTEXT_INTERFACE);
|
|
40
|
+
if (requiresTestContext && TEST_CONTEXT_INTERFACE in provided) {
|
|
41
|
+
throw new NameCollisionError(`'${TEST_CONTEXT_INTERFACE}' was provided explicitly, but this suite ` +
|
|
42
|
+
`imports test-context and the ct-runner always supplies it itself ` +
|
|
43
|
+
`(contracts/embedder-api.md's "merges, erroring on collisions" ` +
|
|
44
|
+
`policy). Remove it from your imports record — a pre-composed ` +
|
|
45
|
+
`bundle that already links a provider should not import ` +
|
|
46
|
+
`test-context in the first place.`);
|
|
47
|
+
}
|
|
48
|
+
const resolver = new ImportResolver(provided);
|
|
49
|
+
const groups = new Set(leaves.map((l) => l.interfaceId));
|
|
50
|
+
const missing = [];
|
|
51
|
+
for (const id of groups) {
|
|
52
|
+
if (requiresTestContext && id === TEST_CONTEXT_INTERFACE)
|
|
53
|
+
continue; // the runner supplies it
|
|
54
|
+
if (resolver.resolve(id) === undefined)
|
|
55
|
+
missing.push(id);
|
|
56
|
+
}
|
|
57
|
+
return { leaves, requiresTestContext, missing };
|
|
58
|
+
}
|
|
59
|
+
/** `analyzeImports`, throwing `MissingImportsError` if anything is missing. */
|
|
60
|
+
export function requireImportsResolved(plan, provided = {}) {
|
|
61
|
+
const analysis = analyzeImports(plan, provided);
|
|
62
|
+
if (analysis.missing.length > 0) {
|
|
63
|
+
const leaves = analysis.leaves.filter((l) => analysis.missing.includes(l.interfaceId));
|
|
64
|
+
throw new MissingImportsError(analysis.missing, leaves);
|
|
65
|
+
}
|
|
66
|
+
return analysis;
|
|
67
|
+
}
|
package/esm/mod.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// ct-runner: polyengine's L3 execution runner for polymorph-test's L1
|
|
2
|
+
// suite contract (TRACK C2-D; docs/consumers.md, C2).
|
|
3
|
+
//
|
|
4
|
+
// "Execute an L1 suite component, emit canonical results JSONL (L4)" — an
|
|
5
|
+
// introspecting host runner (ARCHITECTURE.md Rule 3): it drives
|
|
6
|
+
// `polymorph:test/tests@0.1.0` directly against a host-side `test-context`
|
|
7
|
+
// provider, never composing wasm for L2. See src/run-suite.ts for the case
|
|
8
|
+
// loop and src/context.ts for the host resource.
|
|
9
|
+
export { runSuite, TESTS_INTERFACE, } from "./run-suite.js";
|
|
10
|
+
export { analyzeImports, MissingImportsError, requireImportsResolved, } from "./import-analysis.js";
|
|
11
|
+
export { Context, TEST_CONTEXT_INTERFACE, testContextImportRecord } from "./context.js";
|
|
12
|
+
export { applies, collectTagsSections, firstExcluding, loadTagsInventory, parseTagsRecords, TAGS_SECTION, tagsOf, } from "./tags.js";
|
package/esm/package.json
ADDED
package/esm/run-suite.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The L3 case-loop driver: instantiate the suite behind the embedder
|
|
2
|
+
// conventions, enumerate cases, execute them, emit canonical L4 results
|
|
3
|
+
// JSONL. Mirrors the semantics of polymorph-test's own JS legs
|
|
4
|
+
// (js/viewer/harness.mjs `runCases`/`runSuiteJsonl`) rather than reinventing
|
|
5
|
+
// a policy — an "introspecting host runner" per ARCHITECTURE.md Rule 3 must
|
|
6
|
+
// stay behaviorally equivalent to the layered path.
|
|
7
|
+
//
|
|
8
|
+
// L4 schema authority: polymorph-test crates/component-test-results/src/lib.rs
|
|
9
|
+
// (`Envelope`, `RunInfo`, `CaseResult`, `Status`, `Provenance`,
|
|
10
|
+
// `wire_vocabulary_pinned` test) — the canonical wire types; cross-checked
|
|
11
|
+
// against expected/verify-pipeline-fixture.jsonl and
|
|
12
|
+
// expected/verify-compose-sample.jsonl (golden samples of the same format).
|
|
13
|
+
import { instantiate, Trap, ComponentException, } from "@polyengine/runtime/embedder";
|
|
14
|
+
import { Context, testContextImportRecord } from "./context.js";
|
|
15
|
+
import { requireImportsResolved } from "./import-analysis.js";
|
|
16
|
+
import { applies, firstExcluding, loadTagsInventory, tagsOf, } from "./tags.js";
|
|
17
|
+
/** The suite's `tests` interface id (wit/tests.wit `interface tests`, v0.1.0). */
|
|
18
|
+
export const TESTS_INTERFACE = "polymorph:test/tests@0.1.0";
|
|
19
|
+
/** `js/viewer/harness.mjs`'s `resolveTestsExport`, ported: the suite's
|
|
20
|
+
* `tests` interface from an instantiated component, whichever spelling the
|
|
21
|
+
* producer used (verbatim interface id is what this runtime always uses,
|
|
22
|
+
* but the fallback costs nothing and documents the contract). */
|
|
23
|
+
// deno-lint-ignore no-explicit-any
|
|
24
|
+
function resolveTestsExport(exports) {
|
|
25
|
+
const tests = exports[TESTS_INTERFACE] ?? exports["tests"];
|
|
26
|
+
if (tests === undefined) {
|
|
27
|
+
throw new Error(`suite instance exports no '${TESTS_INTERFACE}' interface: ` +
|
|
28
|
+
`${Object.keys(exports)}`);
|
|
29
|
+
}
|
|
30
|
+
return tests;
|
|
31
|
+
}
|
|
32
|
+
async function sha256Hex(bytes) {
|
|
33
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
|
|
34
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0"))
|
|
35
|
+
.join("");
|
|
36
|
+
}
|
|
37
|
+
function describeThrow(e) {
|
|
38
|
+
return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Run one suite end to end: instantiate, enumerate, execute every case,
|
|
42
|
+
* emit the complete results-JSONL stream (envelope, one line per case,
|
|
43
|
+
* terminator) through `opts.emit`. Throws `MissingImportsError` up front
|
|
44
|
+
* (contracts/embedder-api.md's `requiredImports`) if the caller's imports
|
|
45
|
+
* cannot satisfy the suite, and a plain `Error` if the census is empty (an
|
|
46
|
+
* empty selection is a run error, per component-test-results/src/lib.rs's
|
|
47
|
+
* `fold_jsonl` and harness.mjs's `runSuiteJsonl` — both refuse it).
|
|
48
|
+
*/
|
|
49
|
+
export async function runSuite(artifacts, opts) {
|
|
50
|
+
const provided = opts.imports ?? {};
|
|
51
|
+
// Fail fast, translate-only, before any instantiate (gate 3's contract):
|
|
52
|
+
// detect whether test-context is imported at all — a pre-composed bundle
|
|
53
|
+
// with the provider already linked in must work with no test-context
|
|
54
|
+
// wiring — and merge, erroring on any caller/runner collision.
|
|
55
|
+
const analysis = requireImportsResolved(artifacts.plan, provided);
|
|
56
|
+
const mergedImports = analysis.requiresTestContext
|
|
57
|
+
? { ...provided, ...testContextImportRecord() }
|
|
58
|
+
: provided;
|
|
59
|
+
const freshCases = opts.freshCases ?? true;
|
|
60
|
+
// Validate `shard` loudly (issue #110): integers, count >= 1, index in
|
|
61
|
+
// [0, count). Fail fast, before any instantiate, same posture as the
|
|
62
|
+
// imports/tags validation above.
|
|
63
|
+
if (opts.shard !== undefined) {
|
|
64
|
+
const { index, count } = opts.shard;
|
|
65
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
66
|
+
throw new Error(`shard.count must be an integer >= 1, got ${count}`);
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isInteger(index) || index < 0 || index >= count) {
|
|
69
|
+
throw new Error(`shard.index must be an integer in [0, ${count}), got ${index}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const newTests = async () => {
|
|
73
|
+
const inst = await instantiate(artifacts, mergedImports, {
|
|
74
|
+
jspi: opts.jspi,
|
|
75
|
+
});
|
|
76
|
+
return resolveTestsExport(inst.exports);
|
|
77
|
+
};
|
|
78
|
+
const censusTests = await newTests();
|
|
79
|
+
const census = await censusTests.all();
|
|
80
|
+
// Feature-tag scheduling (issue #25): gate on the suite's own
|
|
81
|
+
// `component-test:tags@0.1` inventory when it has one — the SDK embeds it
|
|
82
|
+
// in the guest core module and it survives wac composition, so this
|
|
83
|
+
// introspecting runner CAN see it (revising the earlier "cannot see the
|
|
84
|
+
// tags section" stance recorded below). Suites without an inventory run
|
|
85
|
+
// feature-blind exactly as before.
|
|
86
|
+
const inventory = loadTagsInventory(artifacts.componentBytes);
|
|
87
|
+
const missing = opts.missing ?? [];
|
|
88
|
+
if (inventory === null && opts.missing !== undefined) {
|
|
89
|
+
throw new Error("missing-features given, but the suite carries no " +
|
|
90
|
+
"component-test:tags@0.1 inventory (not built with their SDK, or " +
|
|
91
|
+
"sections stripped) — tag gating is impossible, refusing to " +
|
|
92
|
+
"silently run feature-blind");
|
|
93
|
+
}
|
|
94
|
+
const suiteName = opts.suiteName.replaceAll("-", "_");
|
|
95
|
+
const artifactSha256 = await sha256Hex(artifacts.componentBytes);
|
|
96
|
+
opts.emit(JSON.stringify({
|
|
97
|
+
"component-test-results": "0.1",
|
|
98
|
+
target: opts.target,
|
|
99
|
+
suite: { name: suiteName, "artifact-sha256": artifactSha256 },
|
|
100
|
+
// "tags" when this run schedules against the suite's tag inventory,
|
|
101
|
+
// "none" for inventory-less suites (component-test-results/src/lib.rs
|
|
102
|
+
// `RunInfo`: "none" is for producers that cannot see the tags section
|
|
103
|
+
// — with the inventory in hand, this runner no longer is one).
|
|
104
|
+
run: { segment: 0, scheduling: inventory !== null ? "tags" : "none" },
|
|
105
|
+
}));
|
|
106
|
+
if (census.length === 0) {
|
|
107
|
+
// Both authorities refuse this: component-test-results/src/lib.rs
|
|
108
|
+
// `fold_jsonl` ("empty selection is a run error") and harness.mjs
|
|
109
|
+
// `runSuiteJsonl` ("suite enumerated zero cases").
|
|
110
|
+
throw new Error("suite enumerated zero cases (empty selection is a run error)");
|
|
111
|
+
}
|
|
112
|
+
const counts = { passed: 0, failed: 0, skipped: 0, na: 0, total: 0 };
|
|
113
|
+
for (const [i, testCase] of census.entries()) {
|
|
114
|
+
// Stripe membership (issue #110) is decided on the census index `i`,
|
|
115
|
+
// BEFORE `only`/tag filtering — a case outside this shard's stripe is
|
|
116
|
+
// skipped with no emit and no count contribution, as if this shard's
|
|
117
|
+
// census never enumerated it at all.
|
|
118
|
+
if (opts.shard && i % opts.shard.count !== opts.shard.index)
|
|
119
|
+
continue;
|
|
120
|
+
const name = String(await testCase.name());
|
|
121
|
+
counts.total++;
|
|
122
|
+
// js/viewer/harness.mjs `runCases`: "if (only && !name.includes(only))
|
|
123
|
+
// continue" — a filtered-out case is skipped entirely, no emit.
|
|
124
|
+
if (opts.only && !name.includes(opts.only))
|
|
125
|
+
continue;
|
|
126
|
+
// harness.mjs `runCases` mark scheduling, in its exact order: `only`
|
|
127
|
+
// first (above), then drift, then applicability. The N/A row's shape is
|
|
128
|
+
// the embed runner's (expected/verify-pipeline-fixture.jsonl):
|
|
129
|
+
// status, first excluding mark as detail, diagnostics-complete true.
|
|
130
|
+
if (inventory !== null) {
|
|
131
|
+
const tags = tagsOf(inventory, name);
|
|
132
|
+
if (tags === undefined) {
|
|
133
|
+
throw new Error(`inventory drift: no tags record covers ${name}`);
|
|
134
|
+
}
|
|
135
|
+
if (!applies(tags, missing)) {
|
|
136
|
+
counts.na++;
|
|
137
|
+
opts.emit(JSON.stringify({
|
|
138
|
+
case: name,
|
|
139
|
+
status: "not-applicable",
|
|
140
|
+
detail: firstExcluding(tags, missing),
|
|
141
|
+
"diagnostics-complete": true,
|
|
142
|
+
}), i);
|
|
143
|
+
opts.log?.(`${name} … not-applicable`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// js/viewer/harness.mjs `runCases`' `freshCases` branch: re-enumerate
|
|
148
|
+
// from a fresh instance and run the matching case; a vanished case is
|
|
149
|
+
// inventory drift, not a failing case, and throws.
|
|
150
|
+
let executed = testCase;
|
|
151
|
+
if (freshCases) {
|
|
152
|
+
const freshTests = await newTests();
|
|
153
|
+
const freshList = await freshTests.all();
|
|
154
|
+
const match = await findByName(freshList, name, i);
|
|
155
|
+
if (match === undefined) {
|
|
156
|
+
throw new Error(`case '${name}' vanished on re-enumeration`);
|
|
157
|
+
}
|
|
158
|
+
executed = match;
|
|
159
|
+
}
|
|
160
|
+
const diags = [];
|
|
161
|
+
// The host-side `test-context` sideband: `diagnostic` calls are consumed
|
|
162
|
+
// concurrently with `run` per wit/tests.wit's doc comment — here that is
|
|
163
|
+
// automatic (same event loop turn, synchronous push into `diags`).
|
|
164
|
+
const ctx = new Context((msg) => diags.push(msg));
|
|
165
|
+
const start = performance.now();
|
|
166
|
+
// deno-lint-ignore no-explicit-any
|
|
167
|
+
let event;
|
|
168
|
+
try {
|
|
169
|
+
const attempt = executed.run(ctx);
|
|
170
|
+
let timedOut = false;
|
|
171
|
+
if (opts.caseTimeoutMs) {
|
|
172
|
+
let timer;
|
|
173
|
+
timedOut = await Promise.race([
|
|
174
|
+
attempt.then(() => false),
|
|
175
|
+
new Promise((resolve) => {
|
|
176
|
+
timer = setTimeout(() => resolve(true), opts.caseTimeoutMs);
|
|
177
|
+
}),
|
|
178
|
+
]).finally(() => clearTimeout(timer));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
await attempt;
|
|
182
|
+
}
|
|
183
|
+
const durationMs = Math.round(performance.now() - start);
|
|
184
|
+
if (timedOut) {
|
|
185
|
+
counts.failed++;
|
|
186
|
+
event = {
|
|
187
|
+
case: name,
|
|
188
|
+
status: "fail",
|
|
189
|
+
provenance: { "limit-exceeded": "case-timeout" },
|
|
190
|
+
detail: `case timeout exceeded (${(opts.caseTimeoutMs / 1000)}s)`,
|
|
191
|
+
"duration-ms": durationMs,
|
|
192
|
+
"diagnostics-complete": false,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
counts.passed++;
|
|
197
|
+
event = {
|
|
198
|
+
case: name,
|
|
199
|
+
status: "pass",
|
|
200
|
+
provenance: "returned",
|
|
201
|
+
"duration-ms": durationMs,
|
|
202
|
+
// The case returned normally, so its diagnostics sideband is
|
|
203
|
+
// complete (upstream emits this on every returned row; polyengine's
|
|
204
|
+
// trap/timeout rows already carry `false`).
|
|
205
|
+
"diagnostics-complete": true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
const durationMs = Math.round(performance.now() - start);
|
|
211
|
+
if (e instanceof ComponentException) {
|
|
212
|
+
const payload = e.payload;
|
|
213
|
+
if (payload?.kind === "failed") {
|
|
214
|
+
counts.failed++;
|
|
215
|
+
event = {
|
|
216
|
+
case: name,
|
|
217
|
+
status: "fail",
|
|
218
|
+
provenance: "returned",
|
|
219
|
+
detail: payload.value,
|
|
220
|
+
"duration-ms": durationMs,
|
|
221
|
+
"diagnostics-complete": true,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
else if (payload?.kind === "skipped") {
|
|
225
|
+
counts.skipped++;
|
|
226
|
+
event = {
|
|
227
|
+
case: name,
|
|
228
|
+
status: "skipped",
|
|
229
|
+
provenance: "returned",
|
|
230
|
+
detail: payload.value,
|
|
231
|
+
"duration-ms": durationMs,
|
|
232
|
+
"diagnostics-complete": true,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// Contract violation: `outcome` has exactly two cases. Treat as
|
|
237
|
+
// this case's failure, same as a trap (the run() promise made a
|
|
238
|
+
// verdict-shaped claim the runner cannot parse).
|
|
239
|
+
counts.failed++;
|
|
240
|
+
event = {
|
|
241
|
+
case: name,
|
|
242
|
+
status: "fail",
|
|
243
|
+
provenance: "trap",
|
|
244
|
+
detail: `run() rejected with an unrecognized outcome payload: ` +
|
|
245
|
+
`${JSON.stringify(payload)}`,
|
|
246
|
+
"duration-ms": durationMs,
|
|
247
|
+
"diagnostics-complete": false,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
// Trap (real wasm trap, or any unbranded throw): "a runner treats a
|
|
253
|
+
// trap as this case's failure and the suite instance as poisoned"
|
|
254
|
+
// (wit/tests.wit `test-case.run` doc comment) — poisoning is moot
|
|
255
|
+
// under `freshCases` (the default), since the NEXT case gets a fresh
|
|
256
|
+
// instance regardless.
|
|
257
|
+
counts.failed++;
|
|
258
|
+
const isTrap = e instanceof Trap;
|
|
259
|
+
event = {
|
|
260
|
+
case: name,
|
|
261
|
+
status: "fail",
|
|
262
|
+
provenance: "trap",
|
|
263
|
+
detail: `trap: ${isTrap ? e.message : describeThrow(e)}`,
|
|
264
|
+
"duration-ms": durationMs,
|
|
265
|
+
"diagnostics-complete": false,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (diags.length > 0)
|
|
270
|
+
event.diagnostics = diags;
|
|
271
|
+
opts.emit(JSON.stringify(event), i);
|
|
272
|
+
opts.log?.(`${name} … ${event.status}`);
|
|
273
|
+
}
|
|
274
|
+
opts.emit('{"segment-end":true}');
|
|
275
|
+
return counts;
|
|
276
|
+
}
|
|
277
|
+
// deno-lint-ignore no-explicit-any
|
|
278
|
+
async function findByName(list, name, hint) {
|
|
279
|
+
// Same-index fast path. Enumeration order is a hint, not a contract: real
|
|
280
|
+
// suites enumerate deterministically, so the re-enumerated case is
|
|
281
|
+
// virtually always at its census index — one name() round-trip instead of
|
|
282
|
+
// a front-to-back scan. The scan (harness.mjs's freshCases branch, which
|
|
283
|
+
// the fallback below mirrors verbatim) is quadratic in suite size, and
|
|
284
|
+
// each name() here is an interpreted CABI call (~8 us): a 19k-case suite
|
|
285
|
+
// pays ~182M of them, dominating the run's wall clock. An order-unstable
|
|
286
|
+
// suite just misses the hint and falls back; drift detection is unchanged
|
|
287
|
+
// (`undefined` still means the case vanished).
|
|
288
|
+
if (hint !== undefined && hint < list.length) {
|
|
289
|
+
const candidate = list[hint];
|
|
290
|
+
if (String(await candidate.name()) === name)
|
|
291
|
+
return candidate;
|
|
292
|
+
}
|
|
293
|
+
for (const c of list) {
|
|
294
|
+
if (String(await c.name()) === name)
|
|
295
|
+
return c;
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
export { analyzeImports, MissingImportsError, requireImportsResolved } from "./import-analysis.js";
|
package/esm/tags.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Feature-tag scheduling (issue #25): the L0 tags inventory and the
|
|
2
|
+
// applicability rule, ported from polymorph-test's authorities —
|
|
3
|
+
//
|
|
4
|
+
// - Section format: crates/component-test-formats/src/inventory.rs
|
|
5
|
+
// (`collect_tags_sections` / `parse_tags_records`): newline-delimited
|
|
6
|
+
// `name tag...` text records in `component-test:tags@0.1` custom
|
|
7
|
+
// sections, collected from the component AND nested modules/components
|
|
8
|
+
// (their reader uses wasmparser's `parse_all`, which descends; the
|
|
9
|
+
// SDK's `#[link_section]` puts the records in the guest CORE module,
|
|
10
|
+
// and those survive wac composition — verified empirically on the
|
|
11
|
+
// polymorph-tls composed suites). Records are newline-delimited within
|
|
12
|
+
// a section; a producer may omit the final newline, so a newline is
|
|
13
|
+
// repaired per section before concatenation, exactly as upstream does.
|
|
14
|
+
// - Record forms: `name tag...` (exact) and `prefix/* tag...` (generated
|
|
15
|
+
// rows: leaves are enumerated at run time below the prefix).
|
|
16
|
+
// - Applicability: crates/component-test-core/src/tags.rs — `feature`
|
|
17
|
+
// requires the target to HAVE the feature, `!feature` requires it to
|
|
18
|
+
// LACK it; a case applies iff every mark is satisfied against the
|
|
19
|
+
// runner's missing-features list. js/viewer/harness.mjs `applies()` is
|
|
20
|
+
// the JS-leg reference this mirrors.
|
|
21
|
+
// - Drift policy: harness.mjs `runCases` — an enumerated case that no
|
|
22
|
+
// record covers throws (the run is unsound, not failing).
|
|
23
|
+
/** The custom-section name (component-test-core `name::TAGS_SECTION`). */
|
|
24
|
+
export const TAGS_SECTION = "component-test:tags@0.1";
|
|
25
|
+
const MAGIC = [0x00, 0x61, 0x73, 0x6d]; // "\0asm"
|
|
26
|
+
function hasWasmMagic(bytes, at = 0) {
|
|
27
|
+
return bytes.length >= at + 8 && MAGIC.every((b, i) => bytes[at + i] === b);
|
|
28
|
+
}
|
|
29
|
+
/** u32 LEB128 at `pos`; returns [value, nextPos]. Traps on overlong/EOF. */
|
|
30
|
+
function lebU32(bytes, pos) {
|
|
31
|
+
let result = 0;
|
|
32
|
+
let shift = 0;
|
|
33
|
+
for (;;) {
|
|
34
|
+
if (pos >= bytes.length)
|
|
35
|
+
throw new Error("tags scan: truncated LEB128");
|
|
36
|
+
const b = bytes[pos++];
|
|
37
|
+
result |= (b & 0x7f) << shift;
|
|
38
|
+
if ((b & 0x80) === 0)
|
|
39
|
+
break;
|
|
40
|
+
shift += 7;
|
|
41
|
+
if (shift >= 35)
|
|
42
|
+
throw new Error("tags scan: LEB128 too long for u32");
|
|
43
|
+
}
|
|
44
|
+
return [result >>> 0, pos];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Collect the concatenated bytes of every `component-test:tags@0.1` custom
|
|
48
|
+
* section in `bytes` — the component's own sections plus those of nested
|
|
49
|
+
* core modules (section id 1) and nested components (section id 4), which
|
|
50
|
+
* both embed complete wasm binaries. Returns null when no section exists
|
|
51
|
+
* anywhere (a suite not built with their SDK).
|
|
52
|
+
*/
|
|
53
|
+
export function collectTagsSections(bytes) {
|
|
54
|
+
if (!hasWasmMagic(bytes))
|
|
55
|
+
throw new Error("tags scan: not a wasm binary");
|
|
56
|
+
const chunks = [];
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
const NL = new Uint8Array([0x0a]);
|
|
59
|
+
const scan = (buf, core) => {
|
|
60
|
+
let pos = 8; // magic + version/layer
|
|
61
|
+
while (pos < buf.length) {
|
|
62
|
+
const id = buf[pos++];
|
|
63
|
+
const [size, afterSize] = lebU32(buf, pos);
|
|
64
|
+
pos = afterSize;
|
|
65
|
+
const end = pos + size;
|
|
66
|
+
if (end > buf.length)
|
|
67
|
+
throw new Error("tags scan: truncated section");
|
|
68
|
+
if (id === 0) {
|
|
69
|
+
const [nameLen, afterName] = lebU32(buf, pos);
|
|
70
|
+
const nameEnd = afterName + nameLen;
|
|
71
|
+
if (nameEnd > end)
|
|
72
|
+
throw new Error("tags scan: truncated custom name");
|
|
73
|
+
if (decoder.decode(buf.subarray(afterName, nameEnd)) === TAGS_SECTION) {
|
|
74
|
+
const data = buf.subarray(nameEnd, end);
|
|
75
|
+
chunks.push(data);
|
|
76
|
+
// Newline repair per section (inventory.rs: nothing guarantees a
|
|
77
|
+
// producer terminates its last record).
|
|
78
|
+
if (data.length === 0 || data[data.length - 1] !== 0x0a) {
|
|
79
|
+
chunks.push(NL);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (!core && (id === 1 || id === 4)) {
|
|
84
|
+
// 1 = core module, 4 = nested component: payload is a full binary.
|
|
85
|
+
const payload = buf.subarray(pos, end);
|
|
86
|
+
if (hasWasmMagic(payload))
|
|
87
|
+
scan(payload, id === 1);
|
|
88
|
+
}
|
|
89
|
+
pos = end;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
scan(bytes, false);
|
|
93
|
+
if (chunks.length === 0)
|
|
94
|
+
return null;
|
|
95
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
96
|
+
const out = new Uint8Array(total);
|
|
97
|
+
let off = 0;
|
|
98
|
+
for (const c of chunks) {
|
|
99
|
+
out.set(c, off);
|
|
100
|
+
off += c.length;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse concatenated records (inventory.rs `parse_tags_records`): one
|
|
106
|
+
* record per line, `name tag...`, blank lines skipped, duplicate names and
|
|
107
|
+
* empty tags rejected. Grammar validation beyond that (WIT-label checks)
|
|
108
|
+
* is the producer's job — their SDK validates at macro-expansion time.
|
|
109
|
+
*/
|
|
110
|
+
export function parseTagsRecords(bytes) {
|
|
111
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
112
|
+
const inv = { exact: new Map(), prefixes: [] };
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
for (const line of text.split("\n")) {
|
|
115
|
+
if (line.trim() === "")
|
|
116
|
+
continue;
|
|
117
|
+
const parts = line.split(" ").filter((p) => p !== "");
|
|
118
|
+
const name = parts[0];
|
|
119
|
+
const tags = parts.slice(1);
|
|
120
|
+
for (const t of tags) {
|
|
121
|
+
if (t === "" || t === "!") {
|
|
122
|
+
throw new Error(`tags section: empty mark on \`${name}\``);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (seen.has(name)) {
|
|
126
|
+
throw new Error(`tags section: duplicate record \`${name}\``);
|
|
127
|
+
}
|
|
128
|
+
seen.add(name);
|
|
129
|
+
const prefix = name.endsWith("/*") ? name.slice(0, -2) : null;
|
|
130
|
+
if (prefix !== null) {
|
|
131
|
+
inv.prefixes.push({ prefix, tags });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
inv.exact.set(name, tags);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return inv;
|
|
138
|
+
}
|
|
139
|
+
/** Convenience: scan + parse; null when the suite carries no inventory. */
|
|
140
|
+
export function loadTagsInventory(bytes) {
|
|
141
|
+
const sections = collectTagsSections(bytes);
|
|
142
|
+
return sections === null ? null : parseTagsRecords(sections);
|
|
143
|
+
}
|
|
144
|
+
/** The tags covering `name`: exact record, else a generated-row prefix
|
|
145
|
+
* record (leaves live below `prefix/`), else undefined (inventory drift). */
|
|
146
|
+
export function tagsOf(inv, name) {
|
|
147
|
+
const exact = inv.exact.get(name);
|
|
148
|
+
if (exact !== undefined)
|
|
149
|
+
return exact;
|
|
150
|
+
for (const { prefix, tags } of inv.prefixes) {
|
|
151
|
+
if (name.startsWith(prefix + "/"))
|
|
152
|
+
return tags;
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
/** harness.mjs `applies()`: `!f` needs f missing; `f` needs f present. */
|
|
157
|
+
export function applies(tags, missing) {
|
|
158
|
+
return tags.every((t) => t.startsWith("!") ? missing.includes(t.slice(1)) : !missing.includes(t));
|
|
159
|
+
}
|
|
160
|
+
/** The N/A row's `detail`: the first unsatisfied mark (harness.mjs's
|
|
161
|
+
* `excluding`), empty string if somehow none (mirrors `excluding ?? ""`). */
|
|
162
|
+
export function firstExcluding(tags, missing) {
|
|
163
|
+
return tags.find((t) => t.startsWith("!") ? !missing.includes(t.slice(1)) : missing.includes(t)) ?? "";
|
|
164
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@polyengine/ct-runner",
|
|
3
|
+
"version": "0.1.0-pre.g633468a",
|
|
4
|
+
"description": "The polyengine execution runner for component-test-results (L1) conformance suites.",
|
|
5
|
+
"homepage": "https://github.com/polymorph-components/polyengine#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/polymorph-components/polyengine.git"
|
|
9
|
+
},
|
|
10
|
+
"license": "Apache-2.0",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/polymorph-components/polyengine/issues"
|
|
13
|
+
},
|
|
14
|
+
"module": "./esm/mod.js",
|
|
15
|
+
"types": "./types/mod.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": {
|
|
19
|
+
"types": "./types/mod.d.ts",
|
|
20
|
+
"default": "./esm/mod.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"./context": {
|
|
24
|
+
"import": {
|
|
25
|
+
"types": "./types/context.d.ts",
|
|
26
|
+
"default": "./esm/context.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"./imports": {
|
|
30
|
+
"import": {
|
|
31
|
+
"types": "./types/import-analysis.d.ts",
|
|
32
|
+
"default": "./esm/import-analysis.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"./run": {
|
|
36
|
+
"import": {
|
|
37
|
+
"types": "./types/run-suite.d.ts",
|
|
38
|
+
"default": "./esm/run-suite.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {},
|
|
44
|
+
"type": "module",
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.14.0"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@polyengine/runtime": "0.1.0-pre.g633468a"
|
|
53
|
+
},
|
|
54
|
+
"_generatedBy": "dnt@0.43.2"
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The frozen L1 interface id `test-context` is provided under. */
|
|
2
|
+
export declare const TEST_CONTEXT_INTERFACE = "polymorph:test/test-context@0.1.0";
|
|
3
|
+
/**
|
|
4
|
+
* One case's diagnostic sink. Host-side: no reps, no side tables — see
|
|
5
|
+
* contracts/embedder-api.md's host-implemented-resource column.
|
|
6
|
+
*
|
|
7
|
+
* `diagnostic` "may block cooperatively" per the WIT doc comment; this
|
|
8
|
+
* in-process host implementation never blocks (no backpressure to model),
|
|
9
|
+
* so it resolves immediately — still a valid `async func` implementation
|
|
10
|
+
* (contracts/embedder-api.md: "sync implementations remain legal").
|
|
11
|
+
*/
|
|
12
|
+
export declare class Context {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(onDiagnostic: (msg: string) => void);
|
|
15
|
+
diagnostic(msg: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the `test-context` import-record entry
|
|
19
|
+
* (`{ [TEST_CONTEXT_INTERFACE]: { Context } }`), per contracts/embedder-api.md
|
|
20
|
+
* §"Module wiring and instantiation" (resource classes sit at the resource's
|
|
21
|
+
* position in the record, PascalCase).
|
|
22
|
+
*
|
|
23
|
+
* The class is registered once per `instantiate` call regardless of how many
|
|
24
|
+
* cases run against that instance; the runner never asks the guest to
|
|
25
|
+
* construct a `context` (the WIT resource has no constructor — the host
|
|
26
|
+
* always initiates the borrow itself when calling `run`).
|
|
27
|
+
*/
|
|
28
|
+
export declare function testContextImportRecord(): Record<string, unknown>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ImportLeaf, type PlanLike } from "@polyengine/runtime/embedder";
|
|
2
|
+
/** One or more of the suite's import leaves cannot be resolved (yet). */
|
|
3
|
+
export declare class MissingImportsError extends Error {
|
|
4
|
+
/** The unresolved leaves, in `requiredImports()` order. */
|
|
5
|
+
readonly leaves: ImportLeaf[];
|
|
6
|
+
/** The distinct top-level record keys (interface ids / bare names) missing. */
|
|
7
|
+
readonly missing: string[];
|
|
8
|
+
constructor(missing: string[], leaves: ImportLeaf[]);
|
|
9
|
+
}
|
|
10
|
+
export interface ImportAnalysis {
|
|
11
|
+
/** Every linkable import leaf (`requiredImports()`, unfiltered). */
|
|
12
|
+
leaves: ImportLeaf[];
|
|
13
|
+
/** Whether this suite imports `test-context` at all (world-shaped suites do;
|
|
14
|
+
* a pre-composed bundle with the provider already linked in does not). */
|
|
15
|
+
requiresTestContext: boolean;
|
|
16
|
+
/** Top-level keys the suite needs that `provided` (plus test-context, if
|
|
17
|
+
* this runner will supply it) does not resolve. Empty when runnable. */
|
|
18
|
+
missing: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Analyze a suite's import surface against a caller-provided imports record,
|
|
22
|
+
* WITHOUT instantiating anything (translate-only; `requiredImports` reads
|
|
23
|
+
* only the plan). Detects:
|
|
24
|
+
* - whether `test-context` is imported (only then does the runner provide
|
|
25
|
+
* it — pre-composed bundles with the provider already linked must work
|
|
26
|
+
* too, per the dispatch);
|
|
27
|
+
* - a caller/runner collision on `test-context` (the runner "merges,
|
|
28
|
+
* erroring on collisions" per the dispatch's import-wiring spec);
|
|
29
|
+
* - every other top-level import key the suite needs but `provided` lacks,
|
|
30
|
+
* via the same version-canonical resolution `instantiate` itself uses
|
|
31
|
+
* (`ImportResolver`, contracts/embedder-api.md §"Version canonicalization").
|
|
32
|
+
*/
|
|
33
|
+
export declare function analyzeImports(plan: PlanLike, provided?: Record<string, unknown>): ImportAnalysis;
|
|
34
|
+
/** `analyzeImports`, throwing `MissingImportsError` if anything is missing. */
|
|
35
|
+
export declare function requireImportsResolved(plan: PlanLike, provided?: Record<string, unknown>): ImportAnalysis;
|
package/types/mod.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { type RunCounts, runSuite, type RunSuiteOptions, TESTS_INTERFACE, } from "./run-suite.js";
|
|
2
|
+
export { analyzeImports, type ImportAnalysis, MissingImportsError, requireImportsResolved, } from "./import-analysis.js";
|
|
3
|
+
export { Context, TEST_CONTEXT_INTERFACE, testContextImportRecord } from "./context.js";
|
|
4
|
+
export { applies, collectTagsSections, firstExcluding, loadTagsInventory, parseTagsRecords, TAGS_SECTION, type TagsInventory, tagsOf, } from "./tags.js";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ComponentArtifacts } from "@polyengine/runtime/embedder";
|
|
2
|
+
/** The suite's `tests` interface id (wit/tests.wit `interface tests`, v0.1.0). */
|
|
3
|
+
export declare const TESTS_INTERFACE = "polymorph:test/tests@0.1.0";
|
|
4
|
+
export interface RunSuiteOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Host imports for everything the suite needs OTHER than test-context
|
|
7
|
+
* (WASI, SUT interfaces, …) — the same shape `instantiate` itself takes
|
|
8
|
+
* (contracts/embedder-api.md §"Module wiring and instantiation"). The
|
|
9
|
+
* runner adds `test-context` itself and errors on a collision (dispatch's
|
|
10
|
+
* import-wiring spec); omit it here.
|
|
11
|
+
*/
|
|
12
|
+
imports?: Record<string, unknown>;
|
|
13
|
+
/** Envelope `target` (opaque implementation x environment key). */
|
|
14
|
+
target: string;
|
|
15
|
+
/**
|
|
16
|
+
* Envelope `suite.name`. Per js/viewer/harness.mjs's `envelope()`: "the
|
|
17
|
+
* suite name is normalized to the lockfile identity — the wasm file stem,
|
|
18
|
+
* underscores"; callers may pass the kebab-case name as-is, this function
|
|
19
|
+
* does the same normalization (`replaceAll("-", "_")`).
|
|
20
|
+
*/
|
|
21
|
+
suiteName: string;
|
|
22
|
+
/** Substring filter: non-matching cases are skipped entirely (no emit),
|
|
23
|
+
* per js/viewer/harness.mjs `runCases`'s `only` handling. */
|
|
24
|
+
only?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Feature-tag scheduling (issue #25): the features this target LACKS —
|
|
27
|
+
* js/viewer/harness.mjs's `missing`. Tag-gating activates whenever the
|
|
28
|
+
* suite carries a `component-test:tags@0.1` inventory (src/tags.ts):
|
|
29
|
+
* non-applicable cases emit `not-applicable` rows instead of executing,
|
|
30
|
+
* and an enumerated case no record covers throws (inventory drift — the
|
|
31
|
+
* run is unsound, not failing). Passing `missing` for a suite WITHOUT an
|
|
32
|
+
* inventory is an error (gating requested but impossible — upstream's
|
|
33
|
+
* runner refuses the same way rather than silently degrading).
|
|
34
|
+
*/
|
|
35
|
+
missing?: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Per-case wall-clock budget in ms (the `--case-timeout` runner option
|
|
38
|
+
* documented in harness.mjs's `runSuiteJsonl` doc comment). On expiry the
|
|
39
|
+
* case fails with `{"limit-exceeded":"case-timeout"}` provenance and the
|
|
40
|
+
* loop moves on; JSPI attempts cannot be cancelled, so this is only safe
|
|
41
|
+
* paired with `freshCases` (the default) — see below.
|
|
42
|
+
*/
|
|
43
|
+
caseTimeoutMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Fresh suite instance per case (default true). harness.mjs's doc comment
|
|
46
|
+
* on `runSuiteJsonl`'s `freshCases` parameter: "a fresh instance per case
|
|
47
|
+
* ... contains trap poisoning" and is required to pair with
|
|
48
|
+
* `caseTimeoutMs` (an abandoned JSPI attempt keeps running until its
|
|
49
|
+
* instance is dropped). Setting this false reuses one instance for the
|
|
50
|
+
* whole run — legal, but a trapped case can poison every later one, exactly
|
|
51
|
+
* as harness.mjs warns.
|
|
52
|
+
*/
|
|
53
|
+
freshCases?: boolean;
|
|
54
|
+
/** Opt in to JSPI-backed suspension; passed through to `instantiate`. */
|
|
55
|
+
jspi?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Stripe this run to one shard of the suite (issue #110): case `i`
|
|
58
|
+
* (i = the census index — `census.entries()`'s index over the FULL
|
|
59
|
+
* enumerated case list, before `only`/tag filtering) belongs to shard
|
|
60
|
+
* `i % count`; this shard executes and emits only its own cases, mirroring
|
|
61
|
+
* `runCases`' established `i % count === index` striping semantics
|
|
62
|
+
* (striping, not contiguous ranges, balances load since expensive cases
|
|
63
|
+
* cluster by group). `only`/tag-gating are applied AFTER stripe
|
|
64
|
+
* membership is decided (a case not in this stripe is neither executed
|
|
65
|
+
* nor emitted, exactly as if it never existed for this shard) — this is
|
|
66
|
+
* the interpretation that keeps the invariant "the union of every shard's
|
|
67
|
+
* rows, in suite order, equals the unsharded run's rows" (pinned by
|
|
68
|
+
* shard_test.ts's partition-identity test).
|
|
69
|
+
*
|
|
70
|
+
* Sharded envelope/terminator contract: a sharded call still emits its
|
|
71
|
+
* own envelope line and its own `{"segment-end":true}` terminator —
|
|
72
|
+
* `runSuite` does not know about sibling shards and cannot merge. The
|
|
73
|
+
* documented consumer topology (issue #110's stated shape) is: a
|
|
74
|
+
* caller-side worker pool runs one `runSuite` call per shard, and the
|
|
75
|
+
* PARENT — not this function — discards all but one envelope, merges the
|
|
76
|
+
* per-case rows back into suite order using the `index` argument now
|
|
77
|
+
* passed to `emit`, and writes the single terminator. The returned
|
|
78
|
+
* `RunCounts` are likewise per-shard (they count only this stripe's
|
|
79
|
+
* cases); the parent sums them. `shard` absent
|
|
80
|
+
* (the default) is byte-identical to today: no `index` shard-partitioning
|
|
81
|
+
* occurs and single-argument `emit` callers are unaffected.
|
|
82
|
+
*/
|
|
83
|
+
shard?: {
|
|
84
|
+
index: number;
|
|
85
|
+
count: number;
|
|
86
|
+
};
|
|
87
|
+
/** Receives each output line (envelope, one per case, terminator),
|
|
88
|
+
* WITHOUT a trailing newline — callers decide the line separator.
|
|
89
|
+
* `caseIndex` (issue #110) is the case's suite-order index (the same `i`
|
|
90
|
+
* used for stripe membership) for per-case rows; `undefined` for the
|
|
91
|
+
* envelope and terminator lines. A sharded consumer uses it to restore
|
|
92
|
+
* suite order when merging stripes back together. Optional second
|
|
93
|
+
* argument: existing single-argument `emit` callers are unaffected. */
|
|
94
|
+
emit: (line: string, caseIndex?: number) => void;
|
|
95
|
+
/** Optional progress log, one call per case (mirrors harness.mjs's
|
|
96
|
+
* `log?.(...)` callback). */
|
|
97
|
+
log?: (msg: string) => void;
|
|
98
|
+
}
|
|
99
|
+
export interface RunCounts {
|
|
100
|
+
passed: number;
|
|
101
|
+
failed: number;
|
|
102
|
+
skipped: number;
|
|
103
|
+
/** Cases scheduled out as `not-applicable` (tag gating; harness.mjs `na`). */
|
|
104
|
+
na: number;
|
|
105
|
+
total: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Run one suite end to end: instantiate, enumerate, execute every case,
|
|
109
|
+
* emit the complete results-JSONL stream (envelope, one line per case,
|
|
110
|
+
* terminator) through `opts.emit`. Throws `MissingImportsError` up front
|
|
111
|
+
* (contracts/embedder-api.md's `requiredImports`) if the caller's imports
|
|
112
|
+
* cannot satisfy the suite, and a plain `Error` if the census is empty (an
|
|
113
|
+
* empty selection is a run error, per component-test-results/src/lib.rs's
|
|
114
|
+
* `fold_jsonl` and harness.mjs's `runSuiteJsonl` — both refuse it).
|
|
115
|
+
*/
|
|
116
|
+
export declare function runSuite(artifacts: ComponentArtifacts, opts: RunSuiteOptions): Promise<RunCounts>;
|
|
117
|
+
export { analyzeImports, MissingImportsError, requireImportsResolved } from "./import-analysis.js";
|
package/types/tags.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** The custom-section name (component-test-core `name::TAGS_SECTION`). */
|
|
2
|
+
export declare const TAGS_SECTION = "component-test:tags@0.1";
|
|
3
|
+
/** Parsed static inventory: exact case records + generated-row prefixes. */
|
|
4
|
+
export interface TagsInventory {
|
|
5
|
+
exact: Map<string, string[]>;
|
|
6
|
+
prefixes: Array<{
|
|
7
|
+
prefix: string;
|
|
8
|
+
tags: string[];
|
|
9
|
+
}>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Collect the concatenated bytes of every `component-test:tags@0.1` custom
|
|
13
|
+
* section in `bytes` — the component's own sections plus those of nested
|
|
14
|
+
* core modules (section id 1) and nested components (section id 4), which
|
|
15
|
+
* both embed complete wasm binaries. Returns null when no section exists
|
|
16
|
+
* anywhere (a suite not built with their SDK).
|
|
17
|
+
*/
|
|
18
|
+
export declare function collectTagsSections(bytes: Uint8Array): Uint8Array | null;
|
|
19
|
+
/**
|
|
20
|
+
* Parse concatenated records (inventory.rs `parse_tags_records`): one
|
|
21
|
+
* record per line, `name tag...`, blank lines skipped, duplicate names and
|
|
22
|
+
* empty tags rejected. Grammar validation beyond that (WIT-label checks)
|
|
23
|
+
* is the producer's job — their SDK validates at macro-expansion time.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseTagsRecords(bytes: Uint8Array): TagsInventory;
|
|
26
|
+
/** Convenience: scan + parse; null when the suite carries no inventory. */
|
|
27
|
+
export declare function loadTagsInventory(bytes: Uint8Array): TagsInventory | null;
|
|
28
|
+
/** The tags covering `name`: exact record, else a generated-row prefix
|
|
29
|
+
* record (leaves live below `prefix/`), else undefined (inventory drift). */
|
|
30
|
+
export declare function tagsOf(inv: TagsInventory, name: string): string[] | undefined;
|
|
31
|
+
/** harness.mjs `applies()`: `!f` needs f missing; `f` needs f present. */
|
|
32
|
+
export declare function applies(tags: string[], missing: string[]): boolean;
|
|
33
|
+
/** The N/A row's `detail`: the first unsatisfied mark (harness.mjs's
|
|
34
|
+
* `excluding`), empty string if somehow none (mirrors `excluding ?? ""`). */
|
|
35
|
+
export declare function firstExcluding(tags: string[], missing: string[]): string;
|