proteum 2.5.25 → 2.6.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/README.md +1 -0
- package/agents/project/AGENTS.md +2 -0
- package/agents/project/client/pages/AGENTS.md +22 -1
- package/client/services/router/components/Link.tsx +25 -0
- package/client/services/router/components/Page.tsx +19 -3
- package/client/services/router/components/router.tsx +165 -64
- package/client/services/router/index.tsx +122 -31
- package/client/services/router/navigation.ts +235 -0
- package/client/services/router/request/api.ts +123 -75
- package/client/services/router/response/index.tsx +5 -0
- package/client/services/router/response/page.ts +20 -4
- package/common/router/contracts.ts +2 -2
- package/common/router/definitions.ts +28 -10
- package/common/router/index.ts +2 -0
- package/common/router/pageData.ts +20 -2
- package/common/router/response/page.ts +73 -7
- package/docs/client-navigation.md +128 -0
- package/package.json +1 -1
- package/server/services/router/response/page/index.tsx +2 -2
- package/tests/client-api-fetch-sync.test.cjs +180 -0
- package/tests/client-router-hooks.test.cjs +73 -0
- package/tests/client-router-navigation.test.cjs +857 -0
- package/tests/client-router-prefetch.test.cjs +197 -0
- package/tests/clientRouterHarness.cjs +70 -0
- package/tests/definition-contracts.test.cjs +45 -1
- package/tests/fakeDom.cjs +90 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
|
|
3
|
+
const { ReactClientContext, createRouter, restore } = require('./clientRouterHarness.cjs');
|
|
4
|
+
const { h, options } = require('preact');
|
|
5
|
+
const { renderToString } = require('preact-render-to-string');
|
|
6
|
+
const { Link } = require('../client/services/router/components/Link.tsx');
|
|
7
|
+
|
|
8
|
+
/*----------------------------------
|
|
9
|
+
- HARNESS
|
|
10
|
+
----------------------------------*/
|
|
11
|
+
|
|
12
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
13
|
+
|
|
14
|
+
// An unresolved route as registerRoutes() builds it from the SSR route list
|
|
15
|
+
const createUnresolvedRoute = (index, routePath, load) => ({
|
|
16
|
+
index,
|
|
17
|
+
chunk: `chunk${routePath.replace(/\W/g, '_')}`,
|
|
18
|
+
regex: new RegExp(`^${routePath}$`),
|
|
19
|
+
keys: [],
|
|
20
|
+
load,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const createModule = (routePath) => ({
|
|
24
|
+
__register: () => ({ method: 'GET', path: routePath, options: {}, controller: () => null }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const createRouterWithRoutes = (paths, { config, failing = [] } = {}) => {
|
|
28
|
+
const loads = [];
|
|
29
|
+
const router = createRouter(config);
|
|
30
|
+
router.routes = paths.map((routePath, index) =>
|
|
31
|
+
createUnresolvedRoute(index, routePath, () => {
|
|
32
|
+
loads.push(routePath);
|
|
33
|
+
return failing.includes(routePath)
|
|
34
|
+
? Promise.reject(new Error('ChunkLoadError'))
|
|
35
|
+
: Promise.resolve(createModule(routePath));
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return { router, loads };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/*----------------------------------
|
|
43
|
+
- TESTS
|
|
44
|
+
----------------------------------*/
|
|
45
|
+
|
|
46
|
+
test('prefetch(path) loads the matching route chunk once and keeps it resolved', async () => {
|
|
47
|
+
const { router, loads } = createRouterWithRoutes(['/radars', '/sales']);
|
|
48
|
+
|
|
49
|
+
await Promise.all([router.prefetch('/sales?tab=near#top'), router.prefetch('/sales')]);
|
|
50
|
+
await router.prefetch('/sales');
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(loads, ['/sales']);
|
|
53
|
+
assert.equal('load' in router.routes[1], false);
|
|
54
|
+
assert.equal(router.routes[1].path, '/sales');
|
|
55
|
+
assert.deepEqual(router.routes[1].regex, /^\/sales$/);
|
|
56
|
+
assert.equal('load' in router.routes[0], true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a navigation joins a prefetch already in flight', async () => {
|
|
60
|
+
const { router, loads } = createRouterWithRoutes(['/radars']);
|
|
61
|
+
const route = router.routes[0];
|
|
62
|
+
|
|
63
|
+
const prefetch = router.prefetch('/radars');
|
|
64
|
+
const navigationRoute = await router['load'](route);
|
|
65
|
+
await prefetch;
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(loads, ['/radars']);
|
|
68
|
+
assert.equal(navigationRoute.path, '/radars');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('prefetch ignores unknown paths and leaves failures to the navigation', async () => {
|
|
72
|
+
let updates = 0;
|
|
73
|
+
const { router, loads } = createRouterWithRoutes(['/radars'], { failing: ['/radars'] });
|
|
74
|
+
router.app.handleUpdate = () => updates++;
|
|
75
|
+
|
|
76
|
+
await router.prefetch('https://example.com/elsewhere');
|
|
77
|
+
await router.prefetch('/unknown');
|
|
78
|
+
await router.prefetch('/radars');
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(loads, ['/radars']);
|
|
81
|
+
assert.equal(updates, 0);
|
|
82
|
+
// Still unresolved: the next navigation loads it again and reports a failure itself
|
|
83
|
+
assert.equal('load' in router.routes[0], true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the router config prefetch list loads at idle, one chunk at a time', async () => {
|
|
87
|
+
const idleCallbacks = [];
|
|
88
|
+
window.requestIdleCallback = (callback) => idleCallbacks.push(callback);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const { router, loads } = createRouterWithRoutes(['/radars', '/sales', '/tlds'], {
|
|
92
|
+
config: { prefetch: ['/sales', '/radars'] },
|
|
93
|
+
});
|
|
94
|
+
let releaseSales;
|
|
95
|
+
const salesLoad = router.routes[1].load;
|
|
96
|
+
router.routes[1].load = () => new Promise((resolve) => (releaseSales = () => resolve(salesLoad())));
|
|
97
|
+
|
|
98
|
+
router['prefetchAtIdle']();
|
|
99
|
+
assert.equal(idleCallbacks.length, 1);
|
|
100
|
+
await flush();
|
|
101
|
+
assert.deepEqual(loads, []);
|
|
102
|
+
|
|
103
|
+
idleCallbacks[0]();
|
|
104
|
+
await flush();
|
|
105
|
+
// The second chunk waits for the first
|
|
106
|
+
assert.deepEqual(loads, []);
|
|
107
|
+
releaseSales();
|
|
108
|
+
await flush();
|
|
109
|
+
assert.deepEqual(loads, ['/sales', '/radars']);
|
|
110
|
+
|
|
111
|
+
// Nothing to schedule without a list
|
|
112
|
+
createRouterWithRoutes(['/radars']).router['prefetchAtIdle']();
|
|
113
|
+
assert.equal(idleCallbacks.length, 1);
|
|
114
|
+
} finally {
|
|
115
|
+
delete window.requestIdleCallback;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('without requestIdleCallback the prefetch waits a real delay, not one millisecond', async () => {
|
|
120
|
+
const timers = [];
|
|
121
|
+
window.setTimeout = (callback, delay) => timers.push({ callback, delay });
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const { router, loads } = createRouterWithRoutes(['/radars'], { config: { prefetch: ['/radars'] } });
|
|
125
|
+
router['prefetchAtIdle']();
|
|
126
|
+
|
|
127
|
+
assert.equal(timers.length, 1);
|
|
128
|
+
// Safari and iOS have no idle callback: the page's own requests go first
|
|
129
|
+
assert.ok(timers[0].delay >= 1000, `fallback delay ${timers[0].delay}ms`);
|
|
130
|
+
assert.deepEqual(loads, []);
|
|
131
|
+
|
|
132
|
+
timers[0].callback();
|
|
133
|
+
await flush();
|
|
134
|
+
assert.deepEqual(loads, ['/radars']);
|
|
135
|
+
} finally {
|
|
136
|
+
// The harness window has no timers of its own
|
|
137
|
+
delete window.setTimeout;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('data saver turns the idle prefetch off', () => {
|
|
142
|
+
const idleCallbacks = [];
|
|
143
|
+
window.requestIdleCallback = (callback) => idleCallbacks.push(callback);
|
|
144
|
+
window.navigator = { connection: { saveData: true } };
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
createRouterWithRoutes(['/radars'], { config: { prefetch: ['/radars'] } }).router['prefetchAtIdle']();
|
|
148
|
+
assert.equal(idleCallbacks.length, 0);
|
|
149
|
+
|
|
150
|
+
// Saver off, or no Network Information API at all: the prefetch is scheduled
|
|
151
|
+
window.navigator = { connection: { saveData: false } };
|
|
152
|
+
createRouterWithRoutes(['/radars'], { config: { prefetch: ['/radars'] } }).router['prefetchAtIdle']();
|
|
153
|
+
window.navigator = {};
|
|
154
|
+
createRouterWithRoutes(['/radars'], { config: { prefetch: ['/radars'] } }).router['prefetchAtIdle']();
|
|
155
|
+
assert.equal(idleCallbacks.length, 2);
|
|
156
|
+
} finally {
|
|
157
|
+
delete window.requestIdleCallback;
|
|
158
|
+
window.navigator = {};
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('Link prefetch loads the route on hover and focus, and keeps the caller handlers', async () => {
|
|
163
|
+
const { router, loads } = createRouterWithRoutes(['/radars']);
|
|
164
|
+
const anchors = [];
|
|
165
|
+
const previousVnodeHook = options.vnode;
|
|
166
|
+
options.vnode = (vnode) => {
|
|
167
|
+
if (vnode.type === 'a') anchors.push(vnode.props);
|
|
168
|
+
previousVnodeHook?.(vnode);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let hovered = 0;
|
|
172
|
+
try {
|
|
173
|
+
renderToString(
|
|
174
|
+
h(
|
|
175
|
+
ReactClientContext.Provider,
|
|
176
|
+
{ value: { Router: router } },
|
|
177
|
+
h(Link, { to: '/radars', prefetch: true, onMouseEnter: () => hovered++ }, 'Radars'),
|
|
178
|
+
h(Link, { to: '/radars' }, 'Plain'),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
} finally {
|
|
182
|
+
options.vnode = previousVnodeHook;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const [prefetching, plain] = anchors;
|
|
186
|
+
assert.equal(plain.onMouseEnter, undefined);
|
|
187
|
+
assert.equal(plain.onFocus, undefined);
|
|
188
|
+
|
|
189
|
+
prefetching.onMouseEnter({});
|
|
190
|
+
prefetching.onFocus({});
|
|
191
|
+
await flush();
|
|
192
|
+
|
|
193
|
+
assert.equal(hovered, 1);
|
|
194
|
+
assert.deepEqual(loads, ['/radars']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterAll(restore);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const Module = require('node:module');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const coreRoot = path.join(__dirname, '..');
|
|
5
|
+
require('module-alias').addAliases({
|
|
6
|
+
'@client': path.join(coreRoot, 'client'),
|
|
7
|
+
'@common': path.join(coreRoot, 'common'),
|
|
8
|
+
'@server': path.join(coreRoot, 'server'),
|
|
9
|
+
});
|
|
10
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
11
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
12
|
+
require('ts-node/register/transpile-only');
|
|
13
|
+
|
|
14
|
+
const { createContext } = require('preact');
|
|
15
|
+
|
|
16
|
+
const ReactClientContext = createContext(undefined);
|
|
17
|
+
// What the default `useContext()` export returns: a test that mounts the router component sets it
|
|
18
|
+
const clientContext = { value: {} };
|
|
19
|
+
const stubs = {
|
|
20
|
+
'@/client/context': Object.assign(() => clientContext.value, { ReactClientContext }),
|
|
21
|
+
'@client/app/component': { default: () => null },
|
|
22
|
+
'@client/pages/_layout': { default: () => null },
|
|
23
|
+
'@generated/client/layouts': { default: {}, layoutOrder: [] },
|
|
24
|
+
'@generated/client/routes': { default: {} },
|
|
25
|
+
'@generated/common/controllers': { __esModule: true, default: () => ({}) },
|
|
26
|
+
'react-dom': { hydrate: () => {} },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const originalLoad = Module._load;
|
|
30
|
+
Module._load = function load(request, parent, isMain) {
|
|
31
|
+
if (request in stubs) return stubs[request];
|
|
32
|
+
if (request === 'react') return originalLoad.call(this, 'preact/compat', parent, isMain);
|
|
33
|
+
|
|
34
|
+
return originalLoad.call(this, request, parent, isMain);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const previous = { window: global.window, document: global.document, dev: global.__DEV__ };
|
|
38
|
+
global.__DEV__ = false;
|
|
39
|
+
global.window = {
|
|
40
|
+
addEventListener: () => {},
|
|
41
|
+
removeEventListener: () => {},
|
|
42
|
+
history: { state: {}, pushState: () => {}, replaceState: () => {} },
|
|
43
|
+
location: { hash: '', host: 'localhost', origin: 'http://localhost', pathname: '/', protocol: 'http:', search: '' },
|
|
44
|
+
navigator: {},
|
|
45
|
+
scrollTo: () => {},
|
|
46
|
+
};
|
|
47
|
+
global.document = { defaultView: global.window };
|
|
48
|
+
|
|
49
|
+
const ClientRouter = require('../client/services/router/index.tsx').default;
|
|
50
|
+
|
|
51
|
+
// A router instance without the app boot: tests set routes and config themselves
|
|
52
|
+
const createRouter = (config = {}) => {
|
|
53
|
+
const app = { registerService: () => {}, handleUpdate: () => {} };
|
|
54
|
+
return new ClientRouter(app, { preload: [], context: () => ({}), ...config });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const restore = () => {
|
|
58
|
+
Module._load = originalLoad;
|
|
59
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
60
|
+
const name = key === 'dev' ? '__DEV__' : key;
|
|
61
|
+
if (value === undefined) delete global[name];
|
|
62
|
+
else global[name] = value;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const setClientContext = (value) => {
|
|
67
|
+
clientContext.value = value;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
module.exports = { ClientRouter, ReactClientContext, createRouter, restore, setClientContext };
|
|
@@ -28,7 +28,8 @@ const {
|
|
|
28
28
|
schema,
|
|
29
29
|
} = require('../server/app/controller/index.ts');
|
|
30
30
|
const Service = require('../server/app/service/index.ts').default;
|
|
31
|
-
const { expressHandler, registerRouteDefinition } = require('../common/router/definitions.ts');
|
|
31
|
+
const { definePageRoute, expressHandler, registerRouteDefinition } = require('../common/router/definitions.ts');
|
|
32
|
+
const { validatePageDataResult } = require('../common/router/pageData.ts');
|
|
32
33
|
const { parseProteumEnvConfig } = require('../common/env/proteumEnv.ts');
|
|
33
34
|
|
|
34
35
|
const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-definition-contracts-'));
|
|
@@ -153,6 +154,49 @@ export default definePageRoute({
|
|
|
153
154
|
assert.deepEqual(definition.normalizedOptionKeys, ['auth', 'authTracking']);
|
|
154
155
|
});
|
|
155
156
|
|
|
157
|
+
test('route indexer accepts the navigation page option', () => {
|
|
158
|
+
const root = createTempDir();
|
|
159
|
+
const filepath = path.join(root, 'client/pages/radars.tsx');
|
|
160
|
+
|
|
161
|
+
writeFile(
|
|
162
|
+
filepath,
|
|
163
|
+
`import { definePageRoute } from '@common/router/definitions';
|
|
164
|
+
|
|
165
|
+
export default definePageRoute({
|
|
166
|
+
path: '/radars',
|
|
167
|
+
options: { auth: true, navigation: 'deferred' },
|
|
168
|
+
data: ({ Radars }) => ({ radars: Radars.list() }),
|
|
169
|
+
render: ({ radars, navigation }) => null,
|
|
170
|
+
});
|
|
171
|
+
`,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const [definition] = indexRouteDefinitions({ side: 'client', sourceFilepath: filepath });
|
|
175
|
+
|
|
176
|
+
assert.deepEqual(definition.normalizedOptionKeys, ['auth', 'navigation']);
|
|
177
|
+
assert.deepEqual(definition.invalidOptionKeys, []);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('definePageRoute keeps the navigation option and page data keeps "navigation" for blocking pages only', () => {
|
|
181
|
+
const render = () => null;
|
|
182
|
+
const definition = definePageRoute({ path: '/radars', options: { navigation: 'deferred' }, data: () => ({}), render });
|
|
183
|
+
|
|
184
|
+
assert.equal(definition.kind, 'page');
|
|
185
|
+
assert.equal(definition.options.navigation, 'deferred');
|
|
186
|
+
assert.equal(definition.render, render);
|
|
187
|
+
|
|
188
|
+
const route = (options) => ({ path: '/radars', options: { filepath: 'client/pages/radars.tsx', ...options } });
|
|
189
|
+
const data = { navigation: { menu: [] }, rows: [] };
|
|
190
|
+
|
|
191
|
+
// Blocking pages already return a "navigation" data key
|
|
192
|
+
assert.equal(validatePageDataResult(route({}), data), data);
|
|
193
|
+
assert.equal(validatePageDataResult(route({ navigation: 'blocking' }), data), data);
|
|
194
|
+
assert.throws(() => validatePageDataResult(route({ navigation: 'deferred' }), data), /cannot return key "navigation"/);
|
|
195
|
+
assert.equal(validatePageDataResult(route({ navigation: 'deferred' }), { rows: [] }).rows.length, 0);
|
|
196
|
+
// Other route option keys stay reserved
|
|
197
|
+
assert.throws(() => validatePageDataResult(route({}), { auth: true }), /reserved key "auth"/);
|
|
198
|
+
});
|
|
199
|
+
|
|
156
200
|
test('router port override updates absolute runtime URLs', () => {
|
|
157
201
|
const root = createTempDir();
|
|
158
202
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The smallest document Preact's `render` needs to mount and update a tree in Node: element and text nodes,
|
|
3
|
+
* child lists, attributes. No layout, no events: a test that clicks drives the component's handlers itself.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class FakeNode {
|
|
7
|
+
constructor(nodeType) {
|
|
8
|
+
this.nodeType = nodeType;
|
|
9
|
+
this.parentNode = null;
|
|
10
|
+
this.childNodes = [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get firstChild() {
|
|
14
|
+
return this.childNodes[0] || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get nextSibling() {
|
|
18
|
+
if (!this.parentNode) return null;
|
|
19
|
+
const siblings = this.parentNode.childNodes;
|
|
20
|
+
return siblings[siblings.indexOf(this) + 1] || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
insertBefore(node, reference) {
|
|
24
|
+
if (node.parentNode) node.parentNode.removeChild(node);
|
|
25
|
+
const index = reference ? this.childNodes.indexOf(reference) : -1;
|
|
26
|
+
if (index < 0) this.childNodes.push(node);
|
|
27
|
+
else this.childNodes.splice(index, 0, node);
|
|
28
|
+
node.parentNode = this;
|
|
29
|
+
return node;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
appendChild(node) {
|
|
33
|
+
return this.insertBefore(node, null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
removeChild(node) {
|
|
37
|
+
this.childNodes.splice(this.childNodes.indexOf(node), 1);
|
|
38
|
+
node.parentNode = null;
|
|
39
|
+
return node;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get textContent() {
|
|
43
|
+
return this.childNodes.map((child) => child.textContent).join('');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class FakeText extends FakeNode {
|
|
48
|
+
constructor(data) {
|
|
49
|
+
super(3);
|
|
50
|
+
this.data = String(data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get textContent() {
|
|
54
|
+
return this.data;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class FakeElement extends FakeNode {
|
|
59
|
+
constructor(localName, namespaceURI) {
|
|
60
|
+
super(1);
|
|
61
|
+
this.localName = localName;
|
|
62
|
+
this.namespaceURI = namespaceURI;
|
|
63
|
+
this.attributes = {};
|
|
64
|
+
this.style = {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setAttribute(name, value) {
|
|
68
|
+
this.attributes[name] = String(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
removeAttribute(name) {
|
|
72
|
+
delete this.attributes[name];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getAttribute(name) {
|
|
76
|
+
return name in this.attributes ? this.attributes[name] : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
addEventListener() {}
|
|
80
|
+
|
|
81
|
+
removeEventListener() {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const createFakeDocument = () => ({
|
|
85
|
+
createElement: (name) => new FakeElement(name, 'http://www.w3.org/1999/xhtml'),
|
|
86
|
+
createElementNS: (namespace, name) => new FakeElement(name, namespace),
|
|
87
|
+
createTextNode: (data) => new FakeText(data),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
module.exports = { createFakeDocument };
|