javascript-solid-server 0.0.162 → 0.0.163
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/package.json +1 -1
- package/src/mashlib/index.js +183 -3
- package/test/round-trip-optimization.test.js +638 -0
package/package.json
CHANGED
package/src/mashlib/index.js
CHANGED
|
@@ -74,6 +74,177 @@ function dataIsland(resourceUrl, jsonLdString) {
|
|
|
74
74
|
return `<script type="application/ld+json" id="dataisland" data-uri="${safeUri}">${safeBody}</script>`;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Inline round-trip optimization reader (#346).
|
|
79
|
+
*
|
|
80
|
+
* Exposes a generic `window.__dataIsland.get(uri)` accessor any client
|
|
81
|
+
* can use, plus a compatibility patch for rdflib-based clients
|
|
82
|
+
* (mashlib and friends) that intercepts `fetcher.load(uri)` and
|
|
83
|
+
* resolves from the inline JSON-LD data island instead of issuing a
|
|
84
|
+
* second HTTP request. Falls through cleanly to the original network
|
|
85
|
+
* fetch on any miss, parse error, or absent rdflib.
|
|
86
|
+
*
|
|
87
|
+
* Three detection paths to cover the timing space:
|
|
88
|
+
* 1. Synchronous check — if `$rdf` is already on the page when this
|
|
89
|
+
* script runs (e.g. reader injected after mashlib in some custom
|
|
90
|
+
* flow), patch immediately.
|
|
91
|
+
* 2. Setter on `window.$rdf` — catches the assignment as soon as
|
|
92
|
+
* mashlib publishes its rdflib instance. Closes the race where
|
|
93
|
+
* mashlib's bundle initializes and calls `panes.runDataBrowser()`
|
|
94
|
+
* synchronously inside an `onload` handler before any setTimeout
|
|
95
|
+
* poll could fire.
|
|
96
|
+
* 3. Polling fallback — bounded retry for environments where the
|
|
97
|
+
* property setter is rejected (e.g. `$rdf` already defined as a
|
|
98
|
+
* non-configurable own property).
|
|
99
|
+
*
|
|
100
|
+
* Net effect: when JSS serves an HTML wrapper with an embedded data
|
|
101
|
+
* island, the page renders with one HTTP round-trip instead of two.
|
|
102
|
+
*/
|
|
103
|
+
export function roundTripOptimizationScript() {
|
|
104
|
+
return `<script>
|
|
105
|
+
(function () {
|
|
106
|
+
if (typeof window === 'undefined') return;
|
|
107
|
+
|
|
108
|
+
// Initialize defensively: another script may have set a truthy
|
|
109
|
+
// window.__dataIsland that lacks a .get function — or, worse,
|
|
110
|
+
// assigned a primitive (string, number, etc.) where attaching
|
|
111
|
+
// .get would silently fail in non-strict mode and throw in strict
|
|
112
|
+
// mode. Normalize to a plain object first if the existing value
|
|
113
|
+
// is not an object or function (this also handles a null value,
|
|
114
|
+
// since typeof null === 'object'). Preserve well-formed existing
|
|
115
|
+
// implementations so consumers can register custom .get hooks.
|
|
116
|
+
var di = window.__dataIsland;
|
|
117
|
+
if (di === null || di === undefined
|
|
118
|
+
|| (typeof di !== 'object' && typeof di !== 'function')) {
|
|
119
|
+
di = window.__dataIsland = {};
|
|
120
|
+
}
|
|
121
|
+
if (typeof di.get !== 'function') {
|
|
122
|
+
di.get = function (uri) {
|
|
123
|
+
if (!uri) return null;
|
|
124
|
+
try {
|
|
125
|
+
// Fetch by id and compare data-uri as a string. Avoids
|
|
126
|
+
// selector construction entirely so there is no CSS.escape
|
|
127
|
+
// pitfall, no attribute-string-context injection surface,
|
|
128
|
+
// and no false misses on URIs containing characters older
|
|
129
|
+
// browsers' selector parsers handle inconsistently.
|
|
130
|
+
var el = document.getElementById('dataisland');
|
|
131
|
+
if (el && el.type === 'application/ld+json'
|
|
132
|
+
&& el.getAttribute('data-uri') === String(uri)) {
|
|
133
|
+
return {
|
|
134
|
+
contentType: 'application/ld+json',
|
|
135
|
+
content: el.textContent
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
} catch (e) { /* fall through to null */ }
|
|
139
|
+
return null;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function applyPatch(rdf) {
|
|
144
|
+
if (!rdf || !rdf.fetcher || !rdf.fetcher.load) return;
|
|
145
|
+
if (rdf.fetcher.__dataIslandPatched) return;
|
|
146
|
+
rdf.fetcher.__dataIslandPatched = true;
|
|
147
|
+
var f = rdf.fetcher;
|
|
148
|
+
var orig = f.load.bind(f);
|
|
149
|
+
f.load = function (uri, options) {
|
|
150
|
+
var s = (uri && uri.uri) || (uri && uri.value) || String(uri);
|
|
151
|
+
var d = window.__dataIsland.get(s);
|
|
152
|
+
if (d) {
|
|
153
|
+
return new Promise(function (resolve, reject) {
|
|
154
|
+
rdf.parse(d.content, f.store, s, d.contentType, function (err) {
|
|
155
|
+
if (err) {
|
|
156
|
+
reject(err);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Wrap the success path so unexpected throws (e.g.
|
|
160
|
+
// f.requested missing/non-writable on some rdflib builds)
|
|
161
|
+
// surface as Promise rejections rather than hanging the
|
|
162
|
+
// resolution.
|
|
163
|
+
try {
|
|
164
|
+
if (f.requested && typeof f.requested === 'object') {
|
|
165
|
+
f.requested[s] = 'done';
|
|
166
|
+
}
|
|
167
|
+
// Return a real Response when available so consumers
|
|
168
|
+
// using "instanceof Response", ".text()", ".json()",
|
|
169
|
+
// etc. work the same as on the network path. Fall
|
|
170
|
+
// back to a Response-shaped plain object in environments
|
|
171
|
+
// where the Response constructor isn't available.
|
|
172
|
+
var resp;
|
|
173
|
+
if (typeof Response === 'function') {
|
|
174
|
+
resp = new Response(d.content, {
|
|
175
|
+
status: 200,
|
|
176
|
+
statusText: 'OK',
|
|
177
|
+
headers: { 'content-type': d.contentType }
|
|
178
|
+
});
|
|
179
|
+
// Response.url is read-only and empty when
|
|
180
|
+
// constructed; consumers reading it expect the
|
|
181
|
+
// resource URL. defineProperty is supported on
|
|
182
|
+
// Response in all browsers we target.
|
|
183
|
+
try {
|
|
184
|
+
Object.defineProperty(resp, 'url',
|
|
185
|
+
{ value: s, configurable: true });
|
|
186
|
+
} catch (urlErr) { /* leave url empty */ }
|
|
187
|
+
} else {
|
|
188
|
+
resp = {
|
|
189
|
+
ok: true,
|
|
190
|
+
status: 200,
|
|
191
|
+
statusText: 'OK',
|
|
192
|
+
url: s,
|
|
193
|
+
headers: {
|
|
194
|
+
// Match real Response.headers.get() behavior on
|
|
195
|
+
// the inline-data path: case-insensitive lookup,
|
|
196
|
+
// returns the data island's content-type for
|
|
197
|
+
// 'content-type', null for unknown headers.
|
|
198
|
+
get: function (name) {
|
|
199
|
+
if (typeof name !== 'string') return null;
|
|
200
|
+
if (name.toLowerCase() === 'content-type') {
|
|
201
|
+
return d.contentType;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
resolve(resp);
|
|
209
|
+
} catch (callbackErr) {
|
|
210
|
+
reject(callbackErr);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}).catch(function () { return orig(uri, options); });
|
|
214
|
+
}
|
|
215
|
+
return orig(uri, options);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Path 1: synchronous check
|
|
220
|
+
if (typeof $rdf !== 'undefined') applyPatch($rdf);
|
|
221
|
+
|
|
222
|
+
// Path 2: setter for $rdf — catches synchronous mashlib initialization
|
|
223
|
+
try {
|
|
224
|
+
var captured = (typeof $rdf !== 'undefined') ? $rdf : undefined;
|
|
225
|
+
Object.defineProperty(window, '$rdf', {
|
|
226
|
+
configurable: true,
|
|
227
|
+
get: function () { return captured; },
|
|
228
|
+
set: function (v) { captured = v; applyPatch(v); }
|
|
229
|
+
});
|
|
230
|
+
} catch (e) {
|
|
231
|
+
/* property non-configurable or otherwise un-redefinable;
|
|
232
|
+
the polling fallback below covers this case */
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Path 3: polling fallback
|
|
236
|
+
var n = 0;
|
|
237
|
+
(function poll() {
|
|
238
|
+
if (++n > 100) return;
|
|
239
|
+
if (typeof $rdf !== 'undefined' && $rdf && $rdf.fetcher
|
|
240
|
+
&& $rdf.fetcher.__dataIslandPatched) return;
|
|
241
|
+
if (typeof $rdf !== 'undefined') applyPatch($rdf);
|
|
242
|
+
setTimeout(poll, 100);
|
|
243
|
+
})();
|
|
244
|
+
})();
|
|
245
|
+
</script>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
77
248
|
/**
|
|
78
249
|
* Generate Mashlib databrowser HTML
|
|
79
250
|
*
|
|
@@ -84,17 +255,23 @@ function dataIsland(resourceUrl, jsonLdString) {
|
|
|
84
255
|
* as a `<script type="application/ld+json">` data island. Accepts a
|
|
85
256
|
* UTF-8 string or a Buffer (coerced via `String()`). Honors a 256 KB
|
|
86
257
|
* size cap; oversize payloads are silently dropped. Phase 1 of #7.
|
|
258
|
+
* @param {boolean} [opts.roundTripOptimization=true] - Inline a small
|
|
259
|
+
* reader script that lets rdflib-based clients (mashlib and friends)
|
|
260
|
+
* resolve `fetcher.load(uri)` from the data island instead of issuing
|
|
261
|
+
* a second HTTP request. Falls through to network fetch on any miss
|
|
262
|
+
* or parse error. #346.
|
|
87
263
|
* @returns {string} HTML content
|
|
88
264
|
*/
|
|
89
265
|
export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = {}) {
|
|
90
266
|
const island = dataIsland(resourceUrl, opts.embedJsonLd);
|
|
267
|
+
const reader = opts.roundTripOptimization === false ? '' : roundTripOptimizationScript();
|
|
91
268
|
if (cdnVersion) {
|
|
92
269
|
// CDN mode - use script.onload to ensure mashlib is fully loaded before init
|
|
93
270
|
// This avoids race conditions with defer + DOMContentLoaded
|
|
94
271
|
const cdnBase = `https://unpkg.com/mashlib@${cdnVersion}/dist`;
|
|
95
272
|
return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title>
|
|
96
273
|
<link href="${cdnBase}/mash.css" rel="stylesheet"></head>
|
|
97
|
-
<body id="PageBody">${island}<header id="PageHeader"></header>
|
|
274
|
+
<body id="PageBody">${island}${reader}<header id="PageHeader"></header>
|
|
98
275
|
<div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div>
|
|
99
276
|
<footer id="PageFooter"></footer>
|
|
100
277
|
<script>
|
|
@@ -111,7 +288,7 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = {
|
|
|
111
288
|
// Local mode - use defer (reliable when served locally)
|
|
112
289
|
return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title><script>document.addEventListener('DOMContentLoaded', function() {
|
|
113
290
|
panes.runDataBrowser()
|
|
114
|
-
})</script><script defer="defer" src="/mashlib.min.js"></script><link href="/mash.css" rel="stylesheet"></head><body id="PageBody">${island}<header id="PageHeader"></header><div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div><footer id="PageFooter"></footer></body></html>`;
|
|
291
|
+
})</script><script defer="defer" src="/mashlib.min.js"></script><link href="/mash.css" rel="stylesheet"></head><body id="PageBody">${island}${reader}<header id="PageHeader"></header><div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div><footer id="PageFooter"></footer></body></html>`;
|
|
115
292
|
}
|
|
116
293
|
|
|
117
294
|
/**
|
|
@@ -122,16 +299,19 @@ export function generateDatabrowserHtml(resourceUrl, cdnVersion = null, opts = {
|
|
|
122
299
|
* @param {object} [opts]
|
|
123
300
|
* @param {string|Buffer} [opts.embedJsonLd] - JSON-LD body for the
|
|
124
301
|
* data island, same contract as `generateDatabrowserHtml`. Phase 1 of #7.
|
|
302
|
+
* @param {boolean} [opts.roundTripOptimization=true] - Inline reader
|
|
303
|
+
* script (#346); see `generateDatabrowserHtml` for details.
|
|
125
304
|
* @returns {string} HTML content
|
|
126
305
|
*/
|
|
127
306
|
export function generateModuleDatabrowserHtml(moduleUrl, resourceUrl = '', opts = {}) {
|
|
128
307
|
const cssUrl = moduleUrl.replace(/\.js$/, '.css');
|
|
129
308
|
const island = dataIsland(resourceUrl, opts.embedJsonLd);
|
|
309
|
+
const reader = opts.roundTripOptimization === false ? '' : roundTripOptimizationScript();
|
|
130
310
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"/>
|
|
131
311
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
132
312
|
<title>Solid Data Browser</title>
|
|
133
313
|
<link rel="stylesheet" href="${cssUrl}"></head>
|
|
134
|
-
<body>${island}<div id="mashlib"></div>
|
|
314
|
+
<body>${island}${reader}<div id="mashlib"></div>
|
|
135
315
|
<script type="module" src="${moduleUrl}"></script>
|
|
136
316
|
</body></html>`;
|
|
137
317
|
}
|
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the round-trip optimization reader (#346).
|
|
3
|
+
*
|
|
4
|
+
* The reader is a small inline `<script>` that exposes
|
|
5
|
+
* `window.__dataIsland.get(uri)` and (when rdflib loads) patches
|
|
6
|
+
* `$rdf.fetcher.load()` to resolve from the inline JSON-LD data island
|
|
7
|
+
* instead of issuing a second HTTP request. These tests pin:
|
|
8
|
+
* - presence in CDN, local, and module HTML wrappers when enabled by default
|
|
9
|
+
* - opt-out via `roundTripOptimization: false`
|
|
10
|
+
* - reader exposes the documented accessor
|
|
11
|
+
* - reader contains the bounded-retry guard (no infinite polling)
|
|
12
|
+
* - reader body is well-formed JS (no premature `</script>` close)
|
|
13
|
+
* - runtime behavior of the accessor and the rdflib patch (via Node `vm`)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it } from 'node:test';
|
|
17
|
+
import assert from 'node:assert';
|
|
18
|
+
import vm from 'node:vm';
|
|
19
|
+
import {
|
|
20
|
+
generateDatabrowserHtml,
|
|
21
|
+
generateModuleDatabrowserHtml,
|
|
22
|
+
roundTripOptimizationScript
|
|
23
|
+
} from '../src/mashlib/index.js';
|
|
24
|
+
|
|
25
|
+
describe('round-trip optimization reader — emission (#346)', () => {
|
|
26
|
+
it('emits the reader script in CDN mode by default', () => {
|
|
27
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
|
|
28
|
+
assert.match(html, /window\.__dataIsland/);
|
|
29
|
+
assert.match(html, /\$rdf\.fetcher/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('emits the reader script in local mode by default', () => {
|
|
33
|
+
const html = generateDatabrowserHtml('https://x.test/foo');
|
|
34
|
+
assert.match(html, /window\.__dataIsland/);
|
|
35
|
+
assert.match(html, /\$rdf\.fetcher/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('emits the reader script in module mode by default', () => {
|
|
39
|
+
const html = generateModuleDatabrowserHtml(
|
|
40
|
+
'/dist/databrowser.js',
|
|
41
|
+
'https://x.test/foo'
|
|
42
|
+
);
|
|
43
|
+
assert.match(html, /window\.__dataIsland/);
|
|
44
|
+
assert.match(html, /\$rdf\.fetcher/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('omits the reader when roundTripOptimization is explicitly false (CDN)', () => {
|
|
48
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0', {
|
|
49
|
+
roundTripOptimization: false
|
|
50
|
+
});
|
|
51
|
+
assert.doesNotMatch(html, /window\.__dataIsland/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('omits the reader when roundTripOptimization is explicitly false (local)', () => {
|
|
55
|
+
const html = generateDatabrowserHtml('https://x.test/foo', null, {
|
|
56
|
+
roundTripOptimization: false
|
|
57
|
+
});
|
|
58
|
+
assert.doesNotMatch(html, /window\.__dataIsland/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('omits the reader when roundTripOptimization is explicitly false (module)', () => {
|
|
62
|
+
const html = generateModuleDatabrowserHtml(
|
|
63
|
+
'/dist/databrowser.js',
|
|
64
|
+
'https://x.test/foo',
|
|
65
|
+
{ roundTripOptimization: false }
|
|
66
|
+
);
|
|
67
|
+
assert.doesNotMatch(html, /window\.__dataIsland/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('exposes the documented public surface (window.__dataIsland with .get)', () => {
|
|
71
|
+
// Public-surface assertion only — minified formatting is not pinned.
|
|
72
|
+
// Runtime behavior is exercised in the vm-based suite below.
|
|
73
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
|
|
74
|
+
assert.match(html, /window\.__dataIsland/);
|
|
75
|
+
assert.match(html, /\.get\s*[:=(]/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('looks up the data island by id and compares data-uri', () => {
|
|
79
|
+
// Avoiding selector construction sidesteps CSS.escape pitfalls and
|
|
80
|
+
// selector-injection surface in older browsers.
|
|
81
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
|
|
82
|
+
assert.match(html, /document\.getElementById\(\s*['"]dataisland['"]\s*\)/);
|
|
83
|
+
assert.match(html, /getAttribute\(\s*['"]data-uri['"]\s*\)/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('marks the fetcher as patched to prevent double-patching', () => {
|
|
87
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
|
|
88
|
+
assert.match(html, /__dataIslandPatched/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('bounds the polling retry to prevent infinite loop on non-rdflib clients', () => {
|
|
92
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
|
|
93
|
+
// Polling guard caps total retries (whitespace-tolerant).
|
|
94
|
+
// Actual runtime behavior is exercised in the vm-based suite below.
|
|
95
|
+
assert.match(html, /\+\+\s*n\s*>\s*\d+/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('captures original fetcher.load before patching', () => {
|
|
99
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
|
|
100
|
+
// Public-surface assertion (whitespace-tolerant).
|
|
101
|
+
// Runtime fall-through is exercised in the vm-based suite below.
|
|
102
|
+
assert.match(html, /orig\s*=\s*f\.load\.bind\(f\)/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('reader contains exactly one </script> close tag (no premature close)', () => {
|
|
106
|
+
// Test the source string directly (not a slice of the emitted HTML)
|
|
107
|
+
// so a premature `</script>` cannot be silently treated as the
|
|
108
|
+
// terminator. A correct reader has exactly one `</script>`: its own
|
|
109
|
+
// closing tag.
|
|
110
|
+
const wrapped = roundTripOptimizationScript();
|
|
111
|
+
const closes = (wrapped.match(/<\/script\s*>/gi) || []).length;
|
|
112
|
+
assert.strictEqual(closes, 1,
|
|
113
|
+
'reader must have exactly one </script> close tag, got ' + closes);
|
|
114
|
+
// Sanity: the close is at the very end (modulo trailing whitespace).
|
|
115
|
+
assert.match(wrapped, /<\/script>\s*$/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('round-trip optimization reader — interaction with data island (#346)', () => {
|
|
120
|
+
it('reader and data island both present when JSON-LD payload supplied', () => {
|
|
121
|
+
const html = generateDatabrowserHtml(
|
|
122
|
+
'https://test.solid.social/profile/card.jsonld',
|
|
123
|
+
'2.0.0',
|
|
124
|
+
{ embedJsonLd: '{"@id":"#me","foaf:name":"Alice"}' }
|
|
125
|
+
);
|
|
126
|
+
assert.match(html, /id="dataisland"/);
|
|
127
|
+
assert.match(html, /window\.__dataIsland/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reader still present when data island is absent (no payload)', () => {
|
|
131
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
|
|
132
|
+
assert.doesNotMatch(html, /id="dataisland"/);
|
|
133
|
+
// Reader is still emitted; it just no-ops on missing islands
|
|
134
|
+
assert.match(html, /window\.__dataIsland/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('data island appears before reader script in document order', () => {
|
|
138
|
+
const html = generateDatabrowserHtml(
|
|
139
|
+
'https://x.test/foo',
|
|
140
|
+
'2.0.0',
|
|
141
|
+
{ embedJsonLd: '{"@id":"#me"}' }
|
|
142
|
+
);
|
|
143
|
+
const islandPos = html.indexOf('id="dataisland"');
|
|
144
|
+
const readerPos = html.indexOf('window.__dataIsland');
|
|
145
|
+
assert.ok(islandPos > 0, 'data island missing');
|
|
146
|
+
assert.ok(readerPos > 0, 'reader missing');
|
|
147
|
+
assert.ok(islandPos < readerPos,
|
|
148
|
+
'data island must appear before reader so the DOM element exists when reader queries it');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Helpers for the runtime suite: extract the reader IIFE from generated
|
|
154
|
+
* HTML and evaluate it in a Node `vm` context with stubbed window /
|
|
155
|
+
* document / $rdf so we can pin actual behavior, not just emitted
|
|
156
|
+
* tokens.
|
|
157
|
+
*/
|
|
158
|
+
function extractReaderSource(html) {
|
|
159
|
+
// The reader IIFE begins with `(function () {` followed by the
|
|
160
|
+
// `if (typeof window` guard. Locate that signature, then walk back
|
|
161
|
+
// to the enclosing <script> open and forward to the closing </script>.
|
|
162
|
+
const sigMatch = html.match(/\(function \(\)\s*\{\s*if \(typeof window/);
|
|
163
|
+
if (!sigMatch) throw new Error('reader IIFE not found in HTML');
|
|
164
|
+
const start = sigMatch.index;
|
|
165
|
+
const scriptOpen = html.lastIndexOf('<script>', start);
|
|
166
|
+
const scriptClose = html.indexOf('</script>', start);
|
|
167
|
+
return html.slice(scriptOpen + '<script>'.length, scriptClose);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function makeContext({ islands = {}, $rdf = undefined } = {}) {
|
|
171
|
+
// Single data island per page (matches the real DOM contract). The
|
|
172
|
+
// accessor uses getElementById('dataisland'), so we expose at most
|
|
173
|
+
// one element regardless of how many entries the test passes in.
|
|
174
|
+
const islandEntries = Object.entries(islands);
|
|
175
|
+
const islandEl = islandEntries.length > 0
|
|
176
|
+
? {
|
|
177
|
+
type: 'application/ld+json',
|
|
178
|
+
textContent: islandEntries[0][1],
|
|
179
|
+
getAttribute(name) {
|
|
180
|
+
return name === 'data-uri' ? islandEntries[0][0] : null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
: null;
|
|
184
|
+
const document = {
|
|
185
|
+
getElementById(id) {
|
|
186
|
+
return id === 'dataisland' ? islandEl : null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const window = {};
|
|
190
|
+
// Stub setTimeout so the reader's polling fallback (up to ~10s of
|
|
191
|
+
// 100ms ticks when $rdf is absent) does not register real Node
|
|
192
|
+
// timers that keep the test process alive past the assertions.
|
|
193
|
+
// Tests that need to exercise polling can build a custom context.
|
|
194
|
+
return vm.createContext({
|
|
195
|
+
window, document, $rdf,
|
|
196
|
+
setTimeout: () => 0,
|
|
197
|
+
clearTimeout: () => {},
|
|
198
|
+
Promise, String, console, Response,
|
|
199
|
+
Object: globalThis.Object
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
describe('round-trip optimization reader — runtime behavior (#346)', () => {
|
|
204
|
+
const html = generateDatabrowserHtml('https://x.test/foo', '2.0.0');
|
|
205
|
+
|
|
206
|
+
it('window.__dataIsland.get returns {contentType, content} for matching island', () => {
|
|
207
|
+
const ctx = makeContext({
|
|
208
|
+
islands: { 'https://x.test/foo': '{"@id":"#me"}' }
|
|
209
|
+
});
|
|
210
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
211
|
+
const result = ctx.window.__dataIsland.get('https://x.test/foo');
|
|
212
|
+
// Compare by value: result is constructed in the vm realm so its
|
|
213
|
+
// prototype is not reference-equal to the host realm's Object.
|
|
214
|
+
assert.strictEqual(result.contentType, 'application/ld+json');
|
|
215
|
+
assert.strictEqual(result.content, '{"@id":"#me"}');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('window.__dataIsland.get returns null when no matching island exists', () => {
|
|
219
|
+
const ctx = makeContext({ islands: {} });
|
|
220
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
221
|
+
assert.strictEqual(
|
|
222
|
+
ctx.window.__dataIsland.get('https://x.test/missing'),
|
|
223
|
+
null
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('window.__dataIsland.get returns null for falsy uri input', () => {
|
|
228
|
+
const ctx = makeContext({ islands: {} });
|
|
229
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
230
|
+
assert.strictEqual(ctx.window.__dataIsland.get(null), null);
|
|
231
|
+
assert.strictEqual(ctx.window.__dataIsland.get(undefined), null);
|
|
232
|
+
assert.strictEqual(ctx.window.__dataIsland.get(''), null);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('patches $rdf.fetcher.load synchronously when rdflib is already present', () => {
|
|
236
|
+
const fakeFetcher = {
|
|
237
|
+
requested: {},
|
|
238
|
+
store: {},
|
|
239
|
+
load: async () => 'original'
|
|
240
|
+
};
|
|
241
|
+
const $rdf = { fetcher: fakeFetcher, parse: () => {}, sym: (u) => u };
|
|
242
|
+
const ctx = makeContext({ islands: {}, $rdf });
|
|
243
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
244
|
+
assert.strictEqual(fakeFetcher.__dataIslandPatched, true,
|
|
245
|
+
'fetcher should be marked patched');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('patched fetcher.load resolves from data island instead of network', async () => {
|
|
249
|
+
let networkCalls = 0;
|
|
250
|
+
const parseCalls = [];
|
|
251
|
+
const fakeFetcher = {
|
|
252
|
+
requested: {},
|
|
253
|
+
store: { kb: 'fake' },
|
|
254
|
+
load: async () => { networkCalls++; return 'network'; }
|
|
255
|
+
};
|
|
256
|
+
const $rdf = {
|
|
257
|
+
fetcher: fakeFetcher,
|
|
258
|
+
parse(content, store, uri, contentType, callback) {
|
|
259
|
+
parseCalls.push({ content, uri, contentType });
|
|
260
|
+
callback(null);
|
|
261
|
+
},
|
|
262
|
+
sym: (u) => ({ uri: u })
|
|
263
|
+
};
|
|
264
|
+
const ctx = makeContext({
|
|
265
|
+
islands: { 'https://x.test/foo': '{"@id":"#me"}' },
|
|
266
|
+
$rdf
|
|
267
|
+
});
|
|
268
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
269
|
+
|
|
270
|
+
const result = await fakeFetcher.load('https://x.test/foo', {});
|
|
271
|
+
|
|
272
|
+
assert.strictEqual(networkCalls, 0, 'should not have hit network');
|
|
273
|
+
assert.strictEqual(parseCalls.length, 1, '$rdf.parse called once');
|
|
274
|
+
assert.strictEqual(parseCalls[0].content, '{"@id":"#me"}');
|
|
275
|
+
assert.strictEqual(parseCalls[0].uri, 'https://x.test/foo');
|
|
276
|
+
assert.strictEqual(parseCalls[0].contentType, 'application/ld+json');
|
|
277
|
+
assert.strictEqual(fakeFetcher.requested['https://x.test/foo'], 'done');
|
|
278
|
+
|
|
279
|
+
// Return value is a real Response (or Response-shaped fallback) so
|
|
280
|
+
// consumers using `instanceof Response` or `.text()` / `.json()`
|
|
281
|
+
// see the same kind of object as on the network path.
|
|
282
|
+
assert.strictEqual(result.ok, true);
|
|
283
|
+
assert.strictEqual(result.status, 200);
|
|
284
|
+
assert.strictEqual(result.url, 'https://x.test/foo');
|
|
285
|
+
assert.strictEqual(typeof result.headers.get, 'function');
|
|
286
|
+
// Content-type header round-trips through the Response init.
|
|
287
|
+
assert.strictEqual(result.headers.get('content-type'), 'application/ld+json');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('patched fetcher.load falls through to original on data island miss', async () => {
|
|
291
|
+
let networkCalls = [];
|
|
292
|
+
const fakeFetcher = {
|
|
293
|
+
requested: {},
|
|
294
|
+
store: {},
|
|
295
|
+
load: async function (uri, options) {
|
|
296
|
+
networkCalls.push({ uri, options });
|
|
297
|
+
return { source: 'network', uri };
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const $rdf = {
|
|
301
|
+
fetcher: fakeFetcher,
|
|
302
|
+
parse: () => {},
|
|
303
|
+
sym: (u) => u
|
|
304
|
+
};
|
|
305
|
+
const ctx = makeContext({ islands: {}, $rdf });
|
|
306
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
307
|
+
|
|
308
|
+
const result = await fakeFetcher.load('https://x.test/foo', { force: true });
|
|
309
|
+
|
|
310
|
+
assert.strictEqual(networkCalls.length, 1);
|
|
311
|
+
assert.strictEqual(networkCalls[0].uri, 'https://x.test/foo');
|
|
312
|
+
assert.deepStrictEqual(networkCalls[0].options, { force: true });
|
|
313
|
+
assert.deepStrictEqual(result, {
|
|
314
|
+
source: 'network',
|
|
315
|
+
uri: 'https://x.test/foo'
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('patched fetcher.load falls through to original on parse error', async () => {
|
|
320
|
+
let networkCalls = 0;
|
|
321
|
+
const fakeFetcher = {
|
|
322
|
+
requested: {},
|
|
323
|
+
store: {},
|
|
324
|
+
load: async () => { networkCalls++; return 'network'; }
|
|
325
|
+
};
|
|
326
|
+
const $rdf = {
|
|
327
|
+
fetcher: fakeFetcher,
|
|
328
|
+
parse(content, store, uri, contentType, callback) {
|
|
329
|
+
callback(new Error('parse failed'));
|
|
330
|
+
},
|
|
331
|
+
sym: (u) => u
|
|
332
|
+
};
|
|
333
|
+
const ctx = makeContext({
|
|
334
|
+
islands: { 'https://x.test/foo': 'not valid json-ld' },
|
|
335
|
+
$rdf
|
|
336
|
+
});
|
|
337
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
338
|
+
|
|
339
|
+
const result = await fakeFetcher.load('https://x.test/foo', {});
|
|
340
|
+
|
|
341
|
+
assert.strictEqual(networkCalls, 1, 'should have fallen through to network');
|
|
342
|
+
assert.strictEqual(result, 'network');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('rdflib patch is idempotent (running reader twice does not double-wrap)', () => {
|
|
346
|
+
const fakeFetcher = {
|
|
347
|
+
requested: {},
|
|
348
|
+
store: {},
|
|
349
|
+
load: async () => 'original'
|
|
350
|
+
};
|
|
351
|
+
const $rdf = { fetcher: fakeFetcher, parse: () => {}, sym: (u) => u };
|
|
352
|
+
const ctx = makeContext({ islands: {}, $rdf });
|
|
353
|
+
|
|
354
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
355
|
+
const firstPatchedLoad = fakeFetcher.load;
|
|
356
|
+
|
|
357
|
+
// Second run — must detect __dataIslandPatched and skip
|
|
358
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
359
|
+
|
|
360
|
+
assert.strictEqual(fakeFetcher.load, firstPatchedLoad,
|
|
361
|
+
'second run should not re-wrap an already-patched fetcher');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('does nothing when rdflib never loads (bounded retry exits silently)', () => {
|
|
365
|
+
const ctx = makeContext({ islands: {} /* no $rdf */ });
|
|
366
|
+
// Should not throw despite $rdf being undefined.
|
|
367
|
+
assert.doesNotThrow(() => {
|
|
368
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
369
|
+
});
|
|
370
|
+
// Generic accessor still set up.
|
|
371
|
+
assert.strictEqual(typeof ctx.window.__dataIsland.get, 'function');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('installs a setter on window.$rdf to catch synchronous mashlib init', () => {
|
|
375
|
+
// The setter closes the race where mashlib's bundle initializes and
|
|
376
|
+
// calls panes.runDataBrowser() (and hence fetcher.load) synchronously
|
|
377
|
+
// inside an onload handler — faster than any setTimeout poll could fire.
|
|
378
|
+
const ctx = makeContext({ islands: {} /* no $rdf yet */ });
|
|
379
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
380
|
+
const desc = Object.getOwnPropertyDescriptor(ctx.window, '$rdf');
|
|
381
|
+
assert.ok(desc, 'setter descriptor missing on window.$rdf');
|
|
382
|
+
assert.strictEqual(typeof desc.get, 'function', 'getter should be installed');
|
|
383
|
+
assert.strictEqual(typeof desc.set, 'function', 'setter should be installed');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('setter on window.$rdf patches fetcher immediately on assignment', () => {
|
|
387
|
+
const fakeFetcher = {
|
|
388
|
+
requested: {},
|
|
389
|
+
store: {},
|
|
390
|
+
load: async () => 'original'
|
|
391
|
+
};
|
|
392
|
+
const fakeRdf = { fetcher: fakeFetcher, parse: () => {}, sym: (u) => u };
|
|
393
|
+
const ctx = makeContext({ islands: {} /* $rdf not yet set */ });
|
|
394
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
395
|
+
// Simulate mashlib publishing its rdflib instance.
|
|
396
|
+
ctx.window.$rdf = fakeRdf;
|
|
397
|
+
assert.strictEqual(fakeFetcher.__dataIslandPatched, true,
|
|
398
|
+
'fetcher should be patched the moment $rdf is assigned');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('returns a real Response when the constructor is available', async () => {
|
|
402
|
+
const fakeFetcher = {
|
|
403
|
+
requested: {},
|
|
404
|
+
store: {},
|
|
405
|
+
load: async () => 'orig'
|
|
406
|
+
};
|
|
407
|
+
const $rdf = {
|
|
408
|
+
fetcher: fakeFetcher,
|
|
409
|
+
parse: (content, store, uri, ct, cb) => cb(null),
|
|
410
|
+
sym: (u) => u
|
|
411
|
+
};
|
|
412
|
+
const ctx = makeContext({
|
|
413
|
+
islands: { 'https://x.test/foo': '{"@id":"#me"}' },
|
|
414
|
+
$rdf
|
|
415
|
+
});
|
|
416
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
417
|
+
const result = await fakeFetcher.load('https://x.test/foo', {});
|
|
418
|
+
assert.ok(result instanceof Response,
|
|
419
|
+
'should resolve to a real Response when constructor is available');
|
|
420
|
+
assert.strictEqual(await result.text(), '{"@id":"#me"}');
|
|
421
|
+
assert.strictEqual(result.url, 'https://x.test/foo');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('falls back to Response-shaped object when Response is unavailable', async () => {
|
|
425
|
+
// Build a context without Response so the fallback path runs.
|
|
426
|
+
const islandEl = {
|
|
427
|
+
type: 'application/ld+json',
|
|
428
|
+
textContent: '{"@id":"#x"}',
|
|
429
|
+
getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
|
|
430
|
+
};
|
|
431
|
+
const document = {
|
|
432
|
+
getElementById: (id) => id === 'dataisland' ? islandEl : null
|
|
433
|
+
};
|
|
434
|
+
const window = {};
|
|
435
|
+
const fakeFetcher = {
|
|
436
|
+
requested: {},
|
|
437
|
+
store: {},
|
|
438
|
+
load: async () => 'orig'
|
|
439
|
+
};
|
|
440
|
+
const $rdf = {
|
|
441
|
+
fetcher: fakeFetcher,
|
|
442
|
+
parse: (c, s, u, ct, cb) => cb(null),
|
|
443
|
+
sym: (u) => u
|
|
444
|
+
};
|
|
445
|
+
const ctx = vm.createContext({
|
|
446
|
+
window, document, $rdf,
|
|
447
|
+
setTimeout: () => 0, clearTimeout: () => {},
|
|
448
|
+
Promise, String, console,
|
|
449
|
+
Object: globalThis.Object
|
|
450
|
+
// Note: no Response in this context.
|
|
451
|
+
});
|
|
452
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
453
|
+
const result = await fakeFetcher.load('https://x.test/foo', {});
|
|
454
|
+
assert.strictEqual(result.ok, true);
|
|
455
|
+
assert.strictEqual(result.status, 200);
|
|
456
|
+
assert.strictEqual(result.url, 'https://x.test/foo');
|
|
457
|
+
assert.strictEqual(typeof result.headers.get, 'function');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('fallback headers.get returns the data island content-type (case-insensitive)', async () => {
|
|
461
|
+
// Build a context without Response so the fallback headers path runs.
|
|
462
|
+
const islandEl = {
|
|
463
|
+
type: 'application/ld+json',
|
|
464
|
+
textContent: '{"@id":"#x"}',
|
|
465
|
+
getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
|
|
466
|
+
};
|
|
467
|
+
const document = {
|
|
468
|
+
getElementById: (id) => id === 'dataisland' ? islandEl : null
|
|
469
|
+
};
|
|
470
|
+
const window = {};
|
|
471
|
+
const fakeFetcher = {
|
|
472
|
+
requested: {},
|
|
473
|
+
store: {},
|
|
474
|
+
load: async () => 'orig'
|
|
475
|
+
};
|
|
476
|
+
const $rdf = {
|
|
477
|
+
fetcher: fakeFetcher,
|
|
478
|
+
parse: (c, s, u, ct, cb) => cb(null),
|
|
479
|
+
sym: (u) => u
|
|
480
|
+
};
|
|
481
|
+
const ctx = vm.createContext({
|
|
482
|
+
window, document, $rdf,
|
|
483
|
+
setTimeout: () => 0, clearTimeout: () => {},
|
|
484
|
+
Promise, String, console,
|
|
485
|
+
Object: globalThis.Object
|
|
486
|
+
// No Response — forces fallback path.
|
|
487
|
+
});
|
|
488
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
489
|
+
const result = await fakeFetcher.load('https://x.test/foo', {});
|
|
490
|
+
assert.strictEqual(result.headers.get('content-type'), 'application/ld+json');
|
|
491
|
+
assert.strictEqual(result.headers.get('Content-Type'), 'application/ld+json');
|
|
492
|
+
assert.strictEqual(result.headers.get('CONTENT-TYPE'), 'application/ld+json');
|
|
493
|
+
assert.strictEqual(result.headers.get('etag'), null);
|
|
494
|
+
assert.strictEqual(result.headers.get(123), null,
|
|
495
|
+
'non-string name should return null without throwing');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('preserves existing window.__dataIsland but ensures .get is callable', () => {
|
|
499
|
+
// Simulate a prior script setting a truthy __dataIsland without .get.
|
|
500
|
+
const islandEl = {
|
|
501
|
+
type: 'application/ld+json',
|
|
502
|
+
textContent: '{"@id":"#me"}',
|
|
503
|
+
getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
|
|
504
|
+
};
|
|
505
|
+
const document = {
|
|
506
|
+
getElementById: (id) => id === 'dataisland' ? islandEl : null
|
|
507
|
+
};
|
|
508
|
+
const preExisting = { someOtherProperty: 'original-value' };
|
|
509
|
+
const window = { __dataIsland: preExisting };
|
|
510
|
+
const ctx = vm.createContext({
|
|
511
|
+
window, document,
|
|
512
|
+
setTimeout: () => 0, clearTimeout: () => {},
|
|
513
|
+
Promise, String, console, Response,
|
|
514
|
+
Object: globalThis.Object
|
|
515
|
+
});
|
|
516
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
517
|
+
|
|
518
|
+
// Existing object preserved (not overwritten):
|
|
519
|
+
assert.strictEqual(ctx.window.__dataIsland, preExisting);
|
|
520
|
+
assert.strictEqual(ctx.window.__dataIsland.someOtherProperty, 'original-value');
|
|
521
|
+
// .get added since it was missing:
|
|
522
|
+
assert.strictEqual(typeof ctx.window.__dataIsland.get, 'function');
|
|
523
|
+
// And it works:
|
|
524
|
+
const result = ctx.window.__dataIsland.get('https://x.test/foo');
|
|
525
|
+
assert.strictEqual(result.contentType, 'application/ld+json');
|
|
526
|
+
assert.strictEqual(result.content, '{"@id":"#me"}');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('normalizes primitive window.__dataIsland to an object before attaching .get', () => {
|
|
530
|
+
// If a script accidentally assigned a primitive (string/number/etc),
|
|
531
|
+
// `|| {}` would preserve it and attaching .get would silently fail.
|
|
532
|
+
// The reader must reset to a plain object before adding .get.
|
|
533
|
+
const islandEl = {
|
|
534
|
+
type: 'application/ld+json',
|
|
535
|
+
textContent: '{"@id":"#me"}',
|
|
536
|
+
getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
|
|
537
|
+
};
|
|
538
|
+
const document = {
|
|
539
|
+
getElementById: (id) => id === 'dataisland' ? islandEl : null
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
for (const primitive of ['stringy', 42, true, Symbol('x')]) {
|
|
543
|
+
const window = { __dataIsland: primitive };
|
|
544
|
+
const ctx = vm.createContext({
|
|
545
|
+
window, document,
|
|
546
|
+
setTimeout: () => 0, clearTimeout: () => {},
|
|
547
|
+
Promise, String, console, Response,
|
|
548
|
+
Object: globalThis.Object
|
|
549
|
+
});
|
|
550
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
551
|
+
assert.strictEqual(typeof ctx.window.__dataIsland, 'object',
|
|
552
|
+
'primitive should be normalized to object');
|
|
553
|
+
assert.strictEqual(typeof ctx.window.__dataIsland.get, 'function',
|
|
554
|
+
'.get should be installed after normalization');
|
|
555
|
+
// And it works:
|
|
556
|
+
const result = ctx.window.__dataIsland.get('https://x.test/foo');
|
|
557
|
+
assert.strictEqual(result.contentType, 'application/ld+json');
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('normalizes a null window.__dataIsland to an object before attaching .get', () => {
|
|
562
|
+
// typeof null === 'object', so a naive object-check would let null
|
|
563
|
+
// through. Explicit null check is required.
|
|
564
|
+
const islandEl = {
|
|
565
|
+
type: 'application/ld+json',
|
|
566
|
+
textContent: '{"@id":"#me"}',
|
|
567
|
+
getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
|
|
568
|
+
};
|
|
569
|
+
const document = {
|
|
570
|
+
getElementById: (id) => id === 'dataisland' ? islandEl : null
|
|
571
|
+
};
|
|
572
|
+
const window = { __dataIsland: null };
|
|
573
|
+
const ctx = vm.createContext({
|
|
574
|
+
window, document,
|
|
575
|
+
setTimeout: () => 0, clearTimeout: () => {},
|
|
576
|
+
Promise, String, console, Response,
|
|
577
|
+
Object: globalThis.Object
|
|
578
|
+
});
|
|
579
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
580
|
+
assert.notStrictEqual(ctx.window.__dataIsland, null);
|
|
581
|
+
assert.strictEqual(typeof ctx.window.__dataIsland.get, 'function');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('does not overwrite a pre-existing window.__dataIsland.get', () => {
|
|
585
|
+
const customGet = function () {
|
|
586
|
+
return { contentType: 'custom', content: 'from-custom-get' };
|
|
587
|
+
};
|
|
588
|
+
const islandEl = {
|
|
589
|
+
type: 'application/ld+json',
|
|
590
|
+
textContent: '{"@id":"#me"}',
|
|
591
|
+
getAttribute: (n) => n === 'data-uri' ? 'https://x.test/foo' : null
|
|
592
|
+
};
|
|
593
|
+
const document = {
|
|
594
|
+
getElementById: (id) => id === 'dataisland' ? islandEl : null
|
|
595
|
+
};
|
|
596
|
+
const window = { __dataIsland: { get: customGet } };
|
|
597
|
+
const ctx = vm.createContext({
|
|
598
|
+
window, document,
|
|
599
|
+
setTimeout: () => 0, clearTimeout: () => {},
|
|
600
|
+
Promise, String, console, Response,
|
|
601
|
+
Object: globalThis.Object
|
|
602
|
+
});
|
|
603
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
604
|
+
|
|
605
|
+
assert.strictEqual(ctx.window.__dataIsland.get, customGet,
|
|
606
|
+
'pre-existing custom .get must not be replaced');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('tolerates missing fetcher.requested without hanging the Promise', async () => {
|
|
610
|
+
// Some rdflib/mashlib builds may not initialize `requested`.
|
|
611
|
+
// The patched load must still resolve cleanly.
|
|
612
|
+
const fakeFetcher = {
|
|
613
|
+
// No `requested` property.
|
|
614
|
+
store: {},
|
|
615
|
+
load: async () => 'orig'
|
|
616
|
+
};
|
|
617
|
+
const $rdf = {
|
|
618
|
+
fetcher: fakeFetcher,
|
|
619
|
+
parse: (c, s, u, ct, cb) => cb(null),
|
|
620
|
+
sym: (u) => u
|
|
621
|
+
};
|
|
622
|
+
const ctx = makeContext({
|
|
623
|
+
islands: { 'https://x.test/foo': '{"@id":"#z"}' },
|
|
624
|
+
$rdf
|
|
625
|
+
});
|
|
626
|
+
vm.runInContext(extractReaderSource(html), ctx);
|
|
627
|
+
|
|
628
|
+
// Bound the wait so a hanging Promise fails the test rather than
|
|
629
|
+
// hanging the whole suite.
|
|
630
|
+
const result = await Promise.race([
|
|
631
|
+
fakeFetcher.load('https://x.test/foo', {}),
|
|
632
|
+
new Promise((_, reject) =>
|
|
633
|
+
setTimeout(() => reject(new Error('load() hung')), 500))
|
|
634
|
+
]);
|
|
635
|
+
assert.strictEqual(result.ok, true);
|
|
636
|
+
assert.strictEqual(result.url, 'https://x.test/foo');
|
|
637
|
+
});
|
|
638
|
+
});
|