@nuggetslife/vc 0.0.30 → 0.2.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/Cargo.toml +11 -0
- package/W3C_CONFORMANCE.md +6 -5
- package/bench/frame_compare.mjs +203 -0
- package/bench/v2_internals.mjs +115 -0
- package/bench/vc_ops.mjs +308 -0
- package/interop-allowlist.json +3 -0
- package/interop-smoke-allowlist.json +3 -0
- package/package.json +8 -7
- package/scripts/fetch-w3c-tests.sh +8 -0
- package/src/jsonld.rs +211 -140
- package/src/ld_signatures.rs +11 -2
- package/test-fixtures/interop/README.md +46 -0
- package/test-fixtures/interop/_contexts/README.md +51 -0
- package/test-fixtures/interop/_contexts/bbs-bound-v1.jsonld +92 -0
- package/test-fixtures/interop/_contexts/citizenship-v1.jsonld +58 -0
- package/test-fixtures/interop/_contexts/credentials-v1.jsonld +316 -0
- package/test-fixtures/interop/_contexts/elm-edc-ap.jsonld +809 -0
- package/test-fixtures/interop/_contexts/essif-schemas-vc-2020-v1.jsonld +47 -0
- package/test-fixtures/interop/_contexts/identity-v2.jsonld +195 -0
- package/test-fixtures/interop/_contexts/nuggets-identity-v1.jsonld +175 -0
- package/test-fixtures/interop/_contexts/nuggets-kyb-v1.jsonld +333 -0
- package/test-fixtures/interop/_contexts/openbadges-v3.jsonld +445 -0
- package/test-fixtures/interop/_contexts/security-bbs-v1.jsonld +93 -0
- package/test-fixtures/interop/ebsi/diploma-elm/README.md +29 -0
- package/test-fixtures/interop/ebsi/diploma-elm/expected.nq +70 -0
- package/test-fixtures/interop/ebsi/diploma-elm/frame.jsonld +24 -0
- package/test-fixtures/interop/ebsi/diploma-elm/input.jsonld +444 -0
- package/test-fixtures/interop/ebsi/diploma-simple/README.md +19 -0
- package/test-fixtures/interop/ebsi/diploma-simple/expected.nq +9 -0
- package/test-fixtures/interop/ebsi/diploma-simple/frame.jsonld +13 -0
- package/test-fixtures/interop/ebsi/diploma-simple/input.jsonld +24 -0
- package/test-fixtures/interop/idv2/full-disclosure/README.md +9 -0
- package/test-fixtures/interop/idv2/full-disclosure/expected.nq +59 -0
- package/test-fixtures/interop/idv2/full-disclosure/frame.jsonld +9 -0
- package/test-fixtures/interop/idv2/full-disclosure/input.jsonld +82 -0
- package/test-fixtures/interop/nuggets/identity-v1/README.md +17 -0
- package/test-fixtures/interop/nuggets/identity-v1/expected.nq +21 -0
- package/test-fixtures/interop/nuggets/identity-v1/frame.jsonld +17 -0
- package/test-fixtures/interop/nuggets/identity-v1/input.jsonld +31 -0
- package/test-fixtures/interop/nuggets/kyb-v1/README.md +15 -0
- package/test-fixtures/interop/nuggets/kyb-v1/expected.nq +18 -0
- package/test-fixtures/interop/nuggets/kyb-v1/frame.jsonld +24 -0
- package/test-fixtures/interop/nuggets/kyb-v1/input.jsonld +60 -0
- package/test-fixtures/interop/openbadges-v3/basic-achievement/README.md +17 -0
- package/test-fixtures/interop/openbadges-v3/basic-achievement/expected.nq +12 -0
- package/test-fixtures/interop/openbadges-v3/basic-achievement/frame.jsonld +17 -0
- package/test-fixtures/interop/openbadges-v3/basic-achievement/input.jsonld +25 -0
- package/test-fixtures/interop/openbadges-v3/with-allowed-values/README.md +11 -0
- package/test-fixtures/interop/openbadges-v3/with-allowed-values/expected.nq +25 -0
- package/test-fixtures/interop/openbadges-v3/with-allowed-values/frame.jsonld +22 -0
- package/test-fixtures/interop/openbadges-v3/with-allowed-values/input.jsonld +40 -0
- package/test-fixtures/interop/vp/single-vc-wrap/README.md +6 -0
- package/test-fixtures/interop/vp/single-vc-wrap/expected.nq +7 -0
- package/test-fixtures/interop/vp/single-vc-wrap/frame.jsonld +17 -0
- package/test-fixtures/interop/vp/single-vc-wrap/input.jsonld +27 -0
- package/test-fixtures/interop/w3c-vc-v1/permanent-resident-card/README.md +5 -0
- package/test-fixtures/interop/w3c-vc-v1/permanent-resident-card/expected.nq +13 -0
- package/test-fixtures/interop/w3c-vc-v1/permanent-resident-card/frame.jsonld +14 -0
- package/test-fixtures/interop/w3c-vc-v1/permanent-resident-card/input.jsonld +29 -0
- package/test_interop.mjs +184 -0
- package/test_interop_smoke.mjs +388 -0
- package/test_jsonld_crossverify.mjs +8 -2
- package/test_w3c_conformance.mjs +80 -7
- package/tools/regen_expected.mjs +108 -0
- package/w3c-baseline.json +881 -263
- package/w3c-denylist.json +6 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cross-stack BBS+ sign-verify smoke harness (Sprint 4 Phase D).
|
|
3
|
+
//
|
|
4
|
+
// Layered on top of the parity harness (test_interop.mjs): the parity gate
|
|
5
|
+
// ensures frame + canonize byte-equality between V2 native and jsonld.js;
|
|
6
|
+
// THIS gate ensures a full cryptographic round-trip across the two stacks.
|
|
7
|
+
//
|
|
8
|
+
// For each fixture × direction:
|
|
9
|
+
// 1. V2 native sign → @mattrglobal/jsonld-signatures-bbs verify
|
|
10
|
+
// 2. @mattrglobal/jsonld-signatures-bbs sign → V2 native verify
|
|
11
|
+
// Both directions must succeed (verified: true) for the fixture to "pass smoke".
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// node test_interop_smoke.mjs --check (CI default; exits 1 on any
|
|
15
|
+
// non-allowlisted failure)
|
|
16
|
+
// node test_interop_smoke.mjs --report (local triage; prints diagnostic
|
|
17
|
+
// per failure, always exits 0)
|
|
18
|
+
//
|
|
19
|
+
// Fixture subset rationale:
|
|
20
|
+
// The corpus is 9 fixtures across 6 families. After clientffi#69 (Phase E
|
|
21
|
+
// canonize_dispatch fix — mirror of the Phase A frame_native hotfix), the
|
|
22
|
+
// smoke harness covers all 9: caller-registered contexts now thread
|
|
23
|
+
// through the V2 BBS+ sign / verify chain, so OB v3 / EBSI / VP fixtures
|
|
24
|
+
// that reference spec-sourced (non-bundled) contexts can now round-trip.
|
|
25
|
+
//
|
|
26
|
+
// Included (9):
|
|
27
|
+
// - w3c-vc-v1/permanent-resident-card — canonical W3C VC v1 + BBS+
|
|
28
|
+
// - idv2/full-disclosure — Nuggets identity-v2 (post-#56)
|
|
29
|
+
// - nuggets/identity-v1 — Nuggets identity-v1 (post-#63)
|
|
30
|
+
// - nuggets/kyb-v1 — Nuggets kyb-v1 (post-#65)
|
|
31
|
+
// - vp/single-vc-wrap — VP wrapping a VC (post-#66)
|
|
32
|
+
// - openbadges-v3/basic-achievement — OB v3 spec context
|
|
33
|
+
// - openbadges-v3/with-allowed-values — OB v3 spec context
|
|
34
|
+
// - ebsi/diploma-simple — EBSI essif spec context
|
|
35
|
+
// - ebsi/diploma-elm — EBSI ELM (V2-pinned, see #61)
|
|
36
|
+
//
|
|
37
|
+
// Hardcoded BBS+ keypair: `did:example:489398593#test`, the same keypair
|
|
38
|
+
// used by the existing test infrastructure (vc/js/test-data/keyPair.json,
|
|
39
|
+
// matching vc/rs/src/jsonld/signatures/bbs/data/sample_data.rs::KEY_PAIR).
|
|
40
|
+
|
|
41
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
42
|
+
import { resolve, dirname, join } from 'node:path';
|
|
43
|
+
import { fileURLToPath } from 'node:url';
|
|
44
|
+
import { createRequire } from 'node:module';
|
|
45
|
+
import { ldSign, ldVerify } from './index.js';
|
|
46
|
+
|
|
47
|
+
const require = createRequire(import.meta.url);
|
|
48
|
+
const { Bls12381G2KeyPair } = require('@mattrglobal/bls12381-key-pair');
|
|
49
|
+
const { BbsBlsSignature2020 } = require('@mattrglobal/jsonld-signatures-bbs');
|
|
50
|
+
const jsigs = require('jsonld-signatures');
|
|
51
|
+
const { extendContextLoader, purposes } = jsigs;
|
|
52
|
+
|
|
53
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
const fixturesRoot = resolve(__dirname, 'test-fixtures', 'interop');
|
|
55
|
+
|
|
56
|
+
const args = process.argv.slice(2);
|
|
57
|
+
const checkMode = args.includes('--check');
|
|
58
|
+
const reportMode = args.includes('--report');
|
|
59
|
+
if (!checkMode && !reportMode) {
|
|
60
|
+
console.error('Usage: test_interop_smoke.mjs [--check | --report]');
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const allowlistPath = resolve(__dirname, 'interop-smoke-allowlist.json');
|
|
65
|
+
const allowlist = existsSync(allowlistPath)
|
|
66
|
+
? JSON.parse(readFileSync(allowlistPath, 'utf8'))
|
|
67
|
+
: { expect_fail: [] };
|
|
68
|
+
const expectFail = new Set((allowlist.expect_fail || []).map((e) => e.fixture));
|
|
69
|
+
|
|
70
|
+
// All corpus fixtures included in the smoke gate — see header rationale.
|
|
71
|
+
const SMOKE_FIXTURES = [
|
|
72
|
+
'w3c-vc-v1/permanent-resident-card',
|
|
73
|
+
'idv2/full-disclosure',
|
|
74
|
+
'nuggets/identity-v1',
|
|
75
|
+
'nuggets/kyb-v1',
|
|
76
|
+
'vp/single-vc-wrap',
|
|
77
|
+
'openbadges-v3/basic-achievement',
|
|
78
|
+
'openbadges-v3/with-allowed-values',
|
|
79
|
+
'ebsi/diploma-simple',
|
|
80
|
+
'ebsi/diploma-elm',
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// BBS+ keypair — `did:example:489398593#test`. Single source of truth is
|
|
84
|
+
// vc/js/test-data/keyPair.json, which mirrors
|
|
85
|
+
// vc/rs/src/jsonld/signatures/bbs/data/sample_data.rs::KEY_PAIR. The on-disk
|
|
86
|
+
// file omits `type` (consumed by other tests that don't need it); we patch
|
|
87
|
+
// it in here so the BBS+ suites resolve the verification method correctly.
|
|
88
|
+
const KEYPAIR_PATH = resolve(__dirname, 'test-data', 'keyPair.json');
|
|
89
|
+
const KEYPAIR_JSON = {
|
|
90
|
+
...JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8')),
|
|
91
|
+
type: 'Bls12381G2Key2020',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const CONTROLLER_DOC = {
|
|
95
|
+
'@context': 'https://w3id.org/security/v2',
|
|
96
|
+
id: KEYPAIR_JSON.controller,
|
|
97
|
+
assertionMethod: [KEYPAIR_JSON.id],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Load offline interop contexts the same way test_interop.mjs does.
|
|
101
|
+
function loadOfflineContexts() {
|
|
102
|
+
const dir = join(fixturesRoot, '_contexts');
|
|
103
|
+
const out = {};
|
|
104
|
+
if (!existsSync(dir)) return out;
|
|
105
|
+
for (const f of readdirSync(dir)) {
|
|
106
|
+
if (!f.endsWith('.jsonld') && !f.endsWith('.json')) continue;
|
|
107
|
+
const doc = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
108
|
+
if (!doc.__url__) continue;
|
|
109
|
+
const url = doc.__url__;
|
|
110
|
+
const copy = JSON.parse(JSON.stringify(doc));
|
|
111
|
+
delete copy.__url__;
|
|
112
|
+
out[url] = copy;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Augment with the contexts the BBS+ suites resolve at sign/verify time:
|
|
118
|
+
// the keypair URL itself, the controller doc, and the JWS suite context the
|
|
119
|
+
// `@mattrglobal/jsonld-signatures-bbs` `getVerificationMethod` framing pulls
|
|
120
|
+
// in. `jsonld-signatures` already bundles `security/v1` + `security/v2`
|
|
121
|
+
// (via its `extendContextLoader`), so we don't need to re-add those.
|
|
122
|
+
function buildContexts(offline) {
|
|
123
|
+
const suiteContextPath = resolve(__dirname, 'test-data', 'suiteContext.json');
|
|
124
|
+
const suiteContext = JSON.parse(readFileSync(suiteContextPath, 'utf8'));
|
|
125
|
+
return {
|
|
126
|
+
...offline,
|
|
127
|
+
[KEYPAIR_JSON.id]: KEYPAIR_JSON,
|
|
128
|
+
[KEYPAIR_JSON.controller]: CONTROLLER_DOC,
|
|
129
|
+
'https://w3id.org/security/suites/jws-2020/v1': suiteContext,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildJsigsLoader(contextMap) {
|
|
134
|
+
const loader = (url) => {
|
|
135
|
+
const doc = contextMap[url];
|
|
136
|
+
if (doc) return { contextUrl: null, document: doc, documentUrl: url };
|
|
137
|
+
throw new Error(`Smoke harness: unbundled context requested: ${url}`);
|
|
138
|
+
};
|
|
139
|
+
return extendContextLoader(loader);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Strip an existing proof if present (some fixtures may carry one). For the
|
|
143
|
+
// smoke harness we always sign fresh.
|
|
144
|
+
function stripProof(doc) {
|
|
145
|
+
const copy = JSON.parse(JSON.stringify(doc));
|
|
146
|
+
delete copy.proof;
|
|
147
|
+
return copy;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// All current smoke fixtures are VCs — no wrapper unwrapping needed; we
|
|
151
|
+
// just strip any incidental embedded proof before signing.
|
|
152
|
+
function unwrapForSigning(_fixtureId, doc) {
|
|
153
|
+
return stripProof(doc);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Some inputs don't carry the BBS+ context — V2 ldSign auto-adds it; we
|
|
157
|
+
// ensure jsonld-signatures-bbs sees it the same way by injecting it before
|
|
158
|
+
// calling sign. (The suite would otherwise call its own `ensureSuiteContext`
|
|
159
|
+
// which mutates the input but produces an identical effect.)
|
|
160
|
+
function ensureBbsContext(doc) {
|
|
161
|
+
const BBS_URL = 'https://w3id.org/security/bbs/v1';
|
|
162
|
+
const ctx = doc['@context'];
|
|
163
|
+
if (!ctx) {
|
|
164
|
+
doc['@context'] = [BBS_URL];
|
|
165
|
+
return doc;
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(ctx)) {
|
|
168
|
+
if (!ctx.includes(BBS_URL)) doc['@context'] = [...ctx, BBS_URL];
|
|
169
|
+
return doc;
|
|
170
|
+
}
|
|
171
|
+
// string or object
|
|
172
|
+
doc['@context'] = [ctx, BBS_URL];
|
|
173
|
+
return doc;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function v2Sign(input, contexts) {
|
|
177
|
+
return await ldSign({
|
|
178
|
+
document: input,
|
|
179
|
+
keyPair: KEYPAIR_JSON,
|
|
180
|
+
contexts,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function v2Verify(signedDoc, contexts) {
|
|
185
|
+
return await ldVerify({
|
|
186
|
+
document: signedDoc,
|
|
187
|
+
contexts,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function mattrSign(input, contexts) {
|
|
192
|
+
const keyPair = await Bls12381G2KeyPair.from({
|
|
193
|
+
id: KEYPAIR_JSON.id,
|
|
194
|
+
controller: KEYPAIR_JSON.controller,
|
|
195
|
+
publicKeyBase58: KEYPAIR_JSON.publicKeyBase58,
|
|
196
|
+
privateKeyBase58: KEYPAIR_JSON.privateKeyBase58,
|
|
197
|
+
});
|
|
198
|
+
const suite = new BbsBlsSignature2020({ key: keyPair });
|
|
199
|
+
const documentLoader = buildJsigsLoader(contexts);
|
|
200
|
+
// Mutate-safe: ensureBbsContext is applied before suite.sign so that
|
|
201
|
+
// expansion has the same vocabulary V2 ldSign would have used.
|
|
202
|
+
const docToSign = ensureBbsContext(JSON.parse(JSON.stringify(input)));
|
|
203
|
+
return await jsigs.sign(docToSign, {
|
|
204
|
+
suite,
|
|
205
|
+
purpose: new purposes.AssertionProofPurpose(),
|
|
206
|
+
documentLoader,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function mattrVerify(signedDoc, contexts) {
|
|
211
|
+
const documentLoader = buildJsigsLoader(contexts);
|
|
212
|
+
const result = await jsigs.verify(signedDoc, {
|
|
213
|
+
suite: new BbsBlsSignature2020(),
|
|
214
|
+
purpose: new purposes.AssertionProofPurpose(),
|
|
215
|
+
documentLoader,
|
|
216
|
+
});
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function summariseError(err) {
|
|
221
|
+
if (!err) return 'unknown';
|
|
222
|
+
if (typeof err === 'string') return err;
|
|
223
|
+
if (err.message) return err.message;
|
|
224
|
+
if (err.errors && err.errors.length) {
|
|
225
|
+
return err.errors.map((e) => e.message || String(e)).join('; ');
|
|
226
|
+
}
|
|
227
|
+
return String(err);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runDirection(directionId, signFn, verifyFn, input, contexts) {
|
|
231
|
+
let signed;
|
|
232
|
+
try {
|
|
233
|
+
signed = await signFn(input, contexts);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return { direction: directionId, status: 'error', stage: 'sign', error: summariseError(err) };
|
|
236
|
+
}
|
|
237
|
+
let verifyResult;
|
|
238
|
+
try {
|
|
239
|
+
verifyResult = await verifyFn(signed, contexts);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return { direction: directionId, status: 'error', stage: 'verify', error: summariseError(err) };
|
|
242
|
+
}
|
|
243
|
+
if (verifyResult && verifyResult.verified === true) {
|
|
244
|
+
return { direction: directionId, status: 'pass' };
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
direction: directionId,
|
|
248
|
+
status: 'fail',
|
|
249
|
+
stage: 'verify',
|
|
250
|
+
error: summariseError(verifyResult && verifyResult.error),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function runFixture(fixture, contexts) {
|
|
255
|
+
const inputPath = join(fixture.dir, 'input.jsonld');
|
|
256
|
+
if (!existsSync(inputPath)) {
|
|
257
|
+
return { id: fixture.id, status: 'skip', reason: 'missing input.jsonld' };
|
|
258
|
+
}
|
|
259
|
+
const raw = JSON.parse(readFileSync(inputPath, 'utf8'));
|
|
260
|
+
const input = unwrapForSigning(fixture.id, raw);
|
|
261
|
+
|
|
262
|
+
const v2ToMattr = await runDirection(
|
|
263
|
+
'v2->jsonld',
|
|
264
|
+
v2Sign,
|
|
265
|
+
mattrVerify,
|
|
266
|
+
input,
|
|
267
|
+
contexts,
|
|
268
|
+
);
|
|
269
|
+
const mattrToV2 = await runDirection(
|
|
270
|
+
'jsonld->v2',
|
|
271
|
+
mattrSign,
|
|
272
|
+
v2Verify,
|
|
273
|
+
input,
|
|
274
|
+
contexts,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const overall =
|
|
278
|
+
v2ToMattr.status === 'pass' && mattrToV2.status === 'pass'
|
|
279
|
+
? 'pass'
|
|
280
|
+
: 'fail';
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
id: fixture.id,
|
|
284
|
+
status: overall,
|
|
285
|
+
directions: { v2ToMattr, mattrToV2 },
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function main() {
|
|
290
|
+
const offline = loadOfflineContexts();
|
|
291
|
+
const contexts = buildContexts(offline);
|
|
292
|
+
|
|
293
|
+
const fixtures = SMOKE_FIXTURES.map((id) => ({
|
|
294
|
+
id,
|
|
295
|
+
dir: join(fixturesRoot, ...id.split('/')),
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
const results = [];
|
|
299
|
+
for (const fx of fixtures) {
|
|
300
|
+
process.stderr.write(` smoke ${fx.id}…\n`);
|
|
301
|
+
results.push(await runFixture(fx, contexts));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const pass = results.filter((r) => r.status === 'pass').length;
|
|
305
|
+
const fail = results.filter((r) => r.status === 'fail').length;
|
|
306
|
+
const skip = results.filter((r) => r.status === 'skip').length;
|
|
307
|
+
|
|
308
|
+
console.log(`\n========== INTEROP SMOKE ==========`);
|
|
309
|
+
console.log(`pass: ${pass}`);
|
|
310
|
+
console.log(`fail: ${fail}`);
|
|
311
|
+
console.log(`skip: ${skip}`);
|
|
312
|
+
|
|
313
|
+
// Per-fixture results table
|
|
314
|
+
console.log(`\nfixture | v2->jsonld | jsonld->v2 | overall`);
|
|
315
|
+
console.log(`-------------------------------------------+------------+------------+--------`);
|
|
316
|
+
for (const r of results) {
|
|
317
|
+
const v2j = r.directions ? r.directions.v2ToMattr.status : '-';
|
|
318
|
+
const jv2 = r.directions ? r.directions.mattrToV2.status : '-';
|
|
319
|
+
const id = r.id.padEnd(42);
|
|
320
|
+
console.log(`${id} | ${v2j.padEnd(10)} | ${jv2.padEnd(10)} | ${r.status}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const regressions = [];
|
|
324
|
+
const improvements = [];
|
|
325
|
+
const expectedFails = [];
|
|
326
|
+
const skips = [];
|
|
327
|
+
for (const r of results) {
|
|
328
|
+
if (r.status === 'pass') {
|
|
329
|
+
if (expectFail.has(r.id)) improvements.push(r);
|
|
330
|
+
} else if (r.status === 'fail') {
|
|
331
|
+
if (expectFail.has(r.id)) expectedFails.push(r);
|
|
332
|
+
else regressions.push(r);
|
|
333
|
+
} else if (r.status === 'skip') {
|
|
334
|
+
skips.push(r);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (reportMode) {
|
|
339
|
+
for (const r of [...regressions, ...expectedFails]) {
|
|
340
|
+
console.log(`\n--- ${r.id} (${r.status}) ---`);
|
|
341
|
+
if (r.directions) {
|
|
342
|
+
for (const d of [r.directions.v2ToMattr, r.directions.mattrToV2]) {
|
|
343
|
+
if (d.status === 'pass') continue;
|
|
344
|
+
console.log(` [${d.direction}] ${d.status} @ ${d.stage}: ${d.error}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
console.log(
|
|
349
|
+
`\n[--report] ${regressions.length} divergence(s); ${expectedFails.length} expected-fail; ${improvements.length} improvement(s); ${skips.length} skip(s).`,
|
|
350
|
+
);
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --check mode
|
|
355
|
+
if (improvements.length) {
|
|
356
|
+
console.log(`\n${improvements.length} fixture(s) newly passing — please ratchet interop-smoke-allowlist.json:`);
|
|
357
|
+
for (const r of improvements) console.log(` + ${r.id}`);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
if (regressions.length) {
|
|
361
|
+
console.log(`\n${regressions.length} REGRESSION(S):`);
|
|
362
|
+
for (const r of regressions) {
|
|
363
|
+
console.log(` - ${r.id}`);
|
|
364
|
+
if (r.directions) {
|
|
365
|
+
for (const d of [r.directions.v2ToMattr, r.directions.mattrToV2]) {
|
|
366
|
+
if (d.status === 'pass') continue;
|
|
367
|
+
console.log(` [${d.direction}] ${d.status} @ ${d.stage}: ${d.error}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
if (skips.length) {
|
|
374
|
+
console.log(`\n${skips.length} skipped fixture(s):`);
|
|
375
|
+
for (const r of skips) console.log(` ! ${r.id}: ${r.reason}`);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
console.log(
|
|
379
|
+
`\nAll smoke fixtures round-trip cleanly.${
|
|
380
|
+
expectedFails.length ? ' (' + expectedFails.length + ' tracked expect-fail.)' : ''
|
|
381
|
+
}`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
main().catch((e) => {
|
|
386
|
+
console.error(e);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
});
|
|
@@ -135,7 +135,10 @@ test('cross-verify: flatten(inputDocument, null) matches JS reference', async ()
|
|
|
135
135
|
const napiResult = await proc.flatten(inputDocument, null);
|
|
136
136
|
const jsResult = await jsonld.flatten(inputDocument, null, { documentLoader });
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// Flatten without compaction returns an unordered set of node objects
|
|
139
|
+
// (each keyed by @id). Sort by @id before comparing.
|
|
140
|
+
const sortById = arr => [...arr].sort((a, b) => String(a['@id'] || '').localeCompare(String(b['@id'] || '')));
|
|
141
|
+
assert.deepStrictEqual(sortById(napiResult), sortById(jsResult));
|
|
139
142
|
});
|
|
140
143
|
|
|
141
144
|
|
|
@@ -171,7 +174,10 @@ test('cross-verify: toRDF(inputDocument, n-quads) matches JS reference', async (
|
|
|
171
174
|
const napiResult = await proc.toRDF(inputDocument, { format: 'application/n-quads' });
|
|
172
175
|
const jsResult = await jsonld.toRDF(inputDocument, { documentLoader, format: 'application/n-quads' });
|
|
173
176
|
|
|
174
|
-
|
|
177
|
+
// toRDF triple order is implementation-defined (RDFC-1.0 normalises it later).
|
|
178
|
+
// Compare as a set: split into lines, drop empties, sort.
|
|
179
|
+
const norm = s => s.split('\n').filter(l => l.length).sort().join('\n');
|
|
180
|
+
assert.strictEqual(norm(napiResult), norm(jsResult));
|
|
175
181
|
});
|
|
176
182
|
|
|
177
183
|
|
package/test_w3c_conformance.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const updateBaseline = flag('--update');
|
|
|
32
32
|
|
|
33
33
|
const TESTS_ROOT = resolve(__dirname, 'tmp-w3c-tests');
|
|
34
34
|
const JLD_TESTS = join(TESTS_ROOT, 'json-ld-api', 'tests');
|
|
35
|
+
const JLD_FRAMING_TESTS = join(TESTS_ROOT, 'json-ld-framing', 'tests');
|
|
35
36
|
const RDFC_TESTS = join(TESTS_ROOT, 'rdf-canon', 'tests');
|
|
36
37
|
const JLD_BASE = 'https://w3c.github.io/json-ld-api/tests/';
|
|
37
38
|
const BASELINE_PATH = join(__dirname, 'w3c-baseline.json');
|
|
@@ -70,6 +71,7 @@ function preloadJldContexts() {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
walk(JLD_TESTS, '');
|
|
74
|
+
if (existsSync(JLD_FRAMING_TESTS)) walk(JLD_FRAMING_TESTS, '');
|
|
73
75
|
return ctxs;
|
|
74
76
|
}
|
|
75
77
|
|
|
@@ -84,6 +86,25 @@ const normNQuads = (s) => typeof s === 'string'
|
|
|
84
86
|
? s.split('\n').map(l => l.trim()).filter(Boolean).sort().join('\n')
|
|
85
87
|
: s;
|
|
86
88
|
|
|
89
|
+
// W3C toRdf expected fixtures contain post-URDNA2015 blank-node labels
|
|
90
|
+
// (e.g. `_:c14n0`), but the spec itself defines toRdf as producing
|
|
91
|
+
// pre-canonical labels (e.g. `_:b0`). jsonld.js exhibits the same
|
|
92
|
+
// pre-canonical output. To compare semantically we run URDNA2015 on both
|
|
93
|
+
// sides before string-matching. Falls back to normNQuads if either side
|
|
94
|
+
// can't be canonicalized (so a bug in the processor still surfaces).
|
|
95
|
+
const canonProc = new JsonLd();
|
|
96
|
+
async function nquadsEqual(got, expected) {
|
|
97
|
+
if (typeof got !== 'string' || typeof expected !== 'string') return false;
|
|
98
|
+
try {
|
|
99
|
+
const opts = { algorithm: 'URDNA2015', inputFormat: 'application/n-quads', format: 'application/n-quads' };
|
|
100
|
+
const a = await canonProc.canonize(got, opts);
|
|
101
|
+
const b = await canonProc.canonize(expected, opts);
|
|
102
|
+
return a === b;
|
|
103
|
+
} catch {
|
|
104
|
+
return normNQuads(got) === normNQuads(expected);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
const DENYLIST_PATH = join(__dirname, 'w3c-denylist.json');
|
|
88
109
|
let DENYLIST = {};
|
|
89
110
|
if (existsSync(DENYLIST_PATH)) {
|
|
@@ -144,10 +165,18 @@ async function runJld(algo, methodName) {
|
|
|
144
165
|
const opt = entry.option || {};
|
|
145
166
|
const callOpts = { base: opt.base || inputUrl };
|
|
146
167
|
if (opt.processingMode) callOpts.processingMode = opt.processingMode;
|
|
168
|
+
else if (opt.specVersion) callOpts.processingMode = opt.specVersion;
|
|
147
169
|
if (opt.expandContext) {
|
|
148
170
|
const ctxUrl = JLD_BASE + opt.expandContext;
|
|
149
171
|
callOpts.expandContext = contexts[ctxUrl] || ctxUrl;
|
|
150
172
|
}
|
|
173
|
+
// toRdf-specific options from W3C test entries
|
|
174
|
+
if (opt.rdfDirection) callOpts.rdfDirection = opt.rdfDirection;
|
|
175
|
+
if (opt.produceGeneralizedRdf != null) callOpts.produceGeneralizedRdf = opt.produceGeneralizedRdf;
|
|
176
|
+
if (opt.compactArrays != null) callOpts.compactArrays = opt.compactArrays;
|
|
177
|
+
// fromRDF-specific options
|
|
178
|
+
if (opt.useNativeTypes != null) callOpts.useNativeTypes = opt.useNativeTypes;
|
|
179
|
+
if (opt.useRdfType != null) callOpts.useRdfType = opt.useRdfType;
|
|
151
180
|
|
|
152
181
|
try {
|
|
153
182
|
const input = methodName === 'fromRDF' ? loadText(inputPath) : loadJson(inputPath);
|
|
@@ -172,7 +201,7 @@ async function runJld(algo, methodName) {
|
|
|
172
201
|
const expected = methodName === 'toRDF' ? loadText(expectPath) : loadJson(expectPath);
|
|
173
202
|
|
|
174
203
|
const ok = methodName === 'toRDF'
|
|
175
|
-
?
|
|
204
|
+
? await nquadsEqual(napiResult, expected)
|
|
176
205
|
: jsonEqual(napiResult, expected);
|
|
177
206
|
record(algo, id, ok ? 'pass' : 'fail');
|
|
178
207
|
} catch (e) {
|
|
@@ -229,20 +258,64 @@ async function runOnce(algo) {
|
|
|
229
258
|
else if (algo === 'flatten') await runJld('flatten', 'flatten');
|
|
230
259
|
else if (algo === 'toRdf') await runJld('toRdf', 'toRDF');
|
|
231
260
|
else if (algo === 'fromRdf') await runJld('fromRdf', 'fromRDF');
|
|
261
|
+
else if (algo === 'frame') await runFraming();
|
|
232
262
|
}
|
|
233
263
|
|
|
264
|
+
async function runFraming() {
|
|
265
|
+
const manifestPath = join(JLD_FRAMING_TESTS, 'frame-manifest.jsonld');
|
|
266
|
+
if (!existsSync(manifestPath)) return;
|
|
267
|
+
const manifest = loadJson(manifestPath);
|
|
268
|
+
const contexts = preloadJldContexts();
|
|
269
|
+
const proc = new JsonLd({ contexts });
|
|
270
|
+
let seen = 0;
|
|
271
|
+
for (const entry of manifest.sequence) {
|
|
272
|
+
const types = Array.isArray(entry['@type']) ? entry['@type'] : [entry['@type']];
|
|
273
|
+
if (!types.includes('jld:PositiveEvaluationTest') && !types.includes('jld:NegativeEvaluationTest')) continue;
|
|
274
|
+
const id = entry['@id'];
|
|
275
|
+
if (isDenied('frame', id)) continue;
|
|
276
|
+
if (!isInCheckScope('frame', id)) continue;
|
|
277
|
+
seen += 1;
|
|
278
|
+
if (seen % PROGRESS_EVERY === 0) process.stderr.write(` frame: ${seen} tests processed\n`);
|
|
279
|
+
if (!entry.input || !entry.frame) { record('frame', id, 'err'); continue; }
|
|
280
|
+
const inputPath = join(JLD_FRAMING_TESTS, entry.input);
|
|
281
|
+
const framePath = join(JLD_FRAMING_TESTS, entry.frame);
|
|
282
|
+
const expectPath = entry.expect ? join(JLD_FRAMING_TESTS, entry.expect) : null;
|
|
283
|
+
if (!existsSync(inputPath) || !existsSync(framePath)) { record('frame', id, 'err'); continue; }
|
|
284
|
+
const opt = entry.option || {};
|
|
285
|
+
const inputUrl = 'https://w3c.github.io/json-ld-framing/tests/' + entry.input;
|
|
286
|
+
const callOpts = { base: opt.base || inputUrl };
|
|
287
|
+
if (opt.processingMode) callOpts.processingMode = opt.processingMode;
|
|
288
|
+
else if (opt.specVersion) callOpts.processingMode = opt.specVersion;
|
|
289
|
+
if (opt.omitGraph != null) callOpts.omitGraph = opt.omitGraph;
|
|
290
|
+
if (opt.requireAll != null) callOpts.requireAll = opt.requireAll;
|
|
291
|
+
try {
|
|
292
|
+
const input = loadJson(inputPath);
|
|
293
|
+
const frame = loadJson(framePath);
|
|
294
|
+
const napiResult = await withTimeout(proc.frame(input, frame, callOpts), id);
|
|
295
|
+
if (!expectPath || !existsSync(expectPath)) { record('frame', id, 'err'); continue; }
|
|
296
|
+
const expected = loadJson(expectPath);
|
|
297
|
+
record('frame', id, jsonEqual(napiResult, expected) ? 'pass' : 'fail');
|
|
298
|
+
} catch (e) {
|
|
299
|
+
const isTimeout = e && /^timeout:/.test(e.message || '');
|
|
300
|
+
record('frame', id, isTimeout ? 'err' : 'err');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
234
306
|
async function main() {
|
|
235
307
|
if (!existsSync(JLD_TESTS) || !existsSync(RDFC_TESTS)) {
|
|
236
308
|
console.error('W3C test suites not found at ' + TESTS_ROOT);
|
|
237
309
|
console.error('Run: vc/js/scripts/fetch-w3c-tests.sh');
|
|
238
310
|
process.exit(2);
|
|
239
311
|
}
|
|
240
|
-
// flatten and toRdf are
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
const
|
|
312
|
+
// flatten and toRdf are gated by V2: the v1 path has Rust-level deadlocks
|
|
313
|
+
// (sync block_on bridging into tokio::sync locks in the document loader and
|
|
314
|
+
// node_map). The v2 path (jsonld_v2::flatten_v2 / to_nquads_v2) uses a
|
|
315
|
+
// pre-resolved HashMap loader with no tokio sync primitives and runs the
|
|
316
|
+
// suite without hangs, so it is included in the default list.
|
|
317
|
+
const algos = ['canonize', 'fromRdf', 'expand', 'compact', 'flatten', 'toRdf', 'frame'];
|
|
318
|
+
const allAlgos = ['expand', 'compact', 'flatten', 'toRdf', 'fromRdf', 'canonize', 'frame'];
|
|
246
319
|
if (onlyArg && !allAlgos.includes(onlyArg)) {
|
|
247
320
|
console.error(`Invalid --only value: ${onlyArg}. Expected one of: ${allAlgos.join(', ')}`);
|
|
248
321
|
process.exit(2);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regenerate `expected.nq` for one or more interop fixtures using jsonld.js
|
|
3
|
+
// (the third-party reference). The harness in `test_interop.mjs` then asserts
|
|
4
|
+
// V2 native produces byte-equal output. Never hand-edit `expected.nq`.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// node tools/regen_expected.mjs <fixture-dir> [<fixture-dir>...]
|
|
8
|
+
// node tools/regen_expected.mjs --all
|
|
9
|
+
//
|
|
10
|
+
// Examples:
|
|
11
|
+
// node tools/regen_expected.mjs test-fixtures/interop/w3c-vc-v1/permanent-resident-card
|
|
12
|
+
// node tools/regen_expected.mjs --all
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { resolve, dirname, join, relative } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import jsonld from 'jsonld';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const repoRoot = resolve(__dirname, '..');
|
|
21
|
+
const fixturesRoot = resolve(repoRoot, 'test-fixtures', 'interop');
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
if (args.length === 0) {
|
|
25
|
+
console.error('Usage: regen_expected.mjs <fixture-dir>... | --all');
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build the offline document loader from the fixtures' contexts. We don't
|
|
30
|
+
// fetch anything from the network — every context referenced by a fixture
|
|
31
|
+
// must resolve from the bundled `vc/js/test-fixtures/interop/_contexts/` dir
|
|
32
|
+
// or from inline contexts (i.e. embedded `@context` objects).
|
|
33
|
+
const contextDir = resolve(fixturesRoot, '_contexts');
|
|
34
|
+
const offlineContexts = {};
|
|
35
|
+
if (existsSync(contextDir)) {
|
|
36
|
+
for (const f of readdirSync(contextDir)) {
|
|
37
|
+
if (!f.endsWith('.jsonld') && !f.endsWith('.json')) continue;
|
|
38
|
+
const doc = JSON.parse(readFileSync(join(contextDir, f), 'utf8'));
|
|
39
|
+
const url = doc.__url__;
|
|
40
|
+
if (!url) {
|
|
41
|
+
console.error(`context ${f} missing __url__ marker`);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
delete doc.__url__;
|
|
45
|
+
offlineContexts[url] = doc;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const documentLoader = async (url) => {
|
|
50
|
+
if (!offlineContexts[url]) {
|
|
51
|
+
throw new Error(`offline loader: unknown context ${url}`);
|
|
52
|
+
}
|
|
53
|
+
return { contextUrl: null, document: offlineContexts[url], documentUrl: url };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
async function regen(fixtureDir) {
|
|
57
|
+
const inputPath = join(fixtureDir, 'input.jsonld');
|
|
58
|
+
const framePath = join(fixtureDir, 'frame.jsonld');
|
|
59
|
+
const expectedPath = join(fixtureDir, 'expected.nq');
|
|
60
|
+
if (!existsSync(inputPath) || !existsSync(framePath)) {
|
|
61
|
+
console.error(`skipping ${fixtureDir}: missing input.jsonld or frame.jsonld`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const input = JSON.parse(readFileSync(inputPath, 'utf8'));
|
|
65
|
+
const frame = JSON.parse(readFileSync(framePath, 'utf8'));
|
|
66
|
+
const framed = await jsonld.frame(input, frame, { documentLoader });
|
|
67
|
+
const nquads = await jsonld.canonize(framed, {
|
|
68
|
+
algorithm: 'URDNA2015',
|
|
69
|
+
format: 'application/n-quads',
|
|
70
|
+
documentLoader,
|
|
71
|
+
});
|
|
72
|
+
// Normalize: LF, trailing newline, no BOM
|
|
73
|
+
const normalized = nquads.endsWith('\n') ? nquads : nquads + '\n';
|
|
74
|
+
writeFileSync(expectedPath, normalized, { encoding: 'utf8' });
|
|
75
|
+
console.log(`wrote ${relative(repoRoot, expectedPath)} (${normalized.length} bytes)`);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function discoverFixtureDirs(root) {
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const family of readdirSync(root)) {
|
|
82
|
+
if (family.startsWith('_')) continue; // skip _contexts/
|
|
83
|
+
const familyDir = join(root, family);
|
|
84
|
+
if (!statSync(familyDir).isDirectory()) continue;
|
|
85
|
+
for (const name of readdirSync(familyDir)) {
|
|
86
|
+
const fixtureDir = join(familyDir, name);
|
|
87
|
+
if (!statSync(fixtureDir).isDirectory()) continue;
|
|
88
|
+
out.push(fixtureDir);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const targets = args[0] === '--all'
|
|
95
|
+
? discoverFixtureDirs(fixturesRoot)
|
|
96
|
+
: args.map((a) => resolve(a));
|
|
97
|
+
|
|
98
|
+
let ok = 0, skipped = 0, failed = 0;
|
|
99
|
+
for (const dir of targets) {
|
|
100
|
+
try {
|
|
101
|
+
if (await regen(dir)) ok += 1; else skipped += 1;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
failed += 1;
|
|
104
|
+
console.error(`failed ${dir}: ${err && err.message ? err.message : err}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
console.log(`\nregenerated: ${ok}; skipped: ${skipped}; failed: ${failed}`);
|
|
108
|
+
process.exit(failed > 0 ? 1 : 0);
|