@cocreate/elements 1.46.0 → 2.0.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/.github/FUNDING.yml +1 -1
- package/.github/workflows/automated.yml +18 -7
- package/CHANGELOG.md +753 -700
- package/CONTRIBUTING.md +6 -6
- package/README.md +469 -46
- package/demo/index.html +1 -1
- package/docs/index.html +7 -7
- package/package.json +12 -11
- package/src/crudHandler.js +153 -47
- package/src/dataHandler.js +7 -0
- package/src/dataObject.js +54 -0
- package/src/formHandler.js +226 -170
- package/src/formSelectors.js +1 -0
- package/src/getDataHandler.js +97 -94
- package/src/hrefHandler.js +104 -0
- package/src/iframeHandler.js +90 -0
- package/src/index.js +446 -12
- package/src/initHandler.js +96 -46
- package/src/mapRegistry.js +10 -1
- package/src/setDataHandler.js +171 -87
- package/src/socketHandler.js +328 -0
- package/src/socketSelectors.js +3 -0
- package/src/srcHandler.js +69 -32
- package/src/subscribeHandler.js +64 -64
- package/src/valueQuery.js +3 -4
- package/src/valueQuerySelector.js +1 -0
- package/test/deferred-src.test.cjs +11 -0
- package/test/form-enter.test.cjs +48 -0
- package/test/get-data.test.cjs +100 -0
- package/test/href-handler.test.cjs +132 -0
- package/test/init-read.test.cjs +145 -0
- package/test/lazy-loading.test.cjs +168 -0
- package/test/load-more.test.cjs +19 -0
- package/test/save-array.test.cjs +31 -0
- package/test/save-completion.test.cjs +36 -0
- package/test/set-data-type.test.cjs +119 -0
- package/test/ssr-response.test.cjs +108 -0
- package/webpack.config.js +1 -0
- package/src/dndHandler.js +0 -155
package/src/valueQuery.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import Observer from "@cocreate/observer";
|
|
2
2
|
import "@cocreate/element-prototype";
|
|
3
3
|
import { queryElements } from "@cocreate/utils";
|
|
4
|
-
|
|
5
|
-
const selector = "[value-query]";
|
|
4
|
+
import { valueQuerySelector } from "./valueQuerySelector.js";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Initializes elements with value-* attributes to enable dynamic value retrieval and setting.
|
|
@@ -18,7 +17,7 @@ async function init(element) {
|
|
|
18
17
|
) {
|
|
19
18
|
element = [element];
|
|
20
19
|
} else if (!element) {
|
|
21
|
-
element = document.querySelectorAll(
|
|
20
|
+
element = document.querySelectorAll(valueQuerySelector);
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
for (let i = 0; i < element.length; i++) {
|
|
@@ -87,7 +86,7 @@ init();
|
|
|
87
86
|
Observer.init({
|
|
88
87
|
name: "CoCreateElementsValueAdded",
|
|
89
88
|
types: ["addedNodes"],
|
|
90
|
-
selector:
|
|
89
|
+
selector: valueQuerySelector,
|
|
91
90
|
callback: function (mutation) {
|
|
92
91
|
init(mutation.target);
|
|
93
92
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const valueQuerySelector = "[value-query]";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const {test}=require('node:test');
|
|
2
|
+
const assert=require('node:assert/strict');
|
|
3
|
+
const fs=require('node:fs');
|
|
4
|
+
const vm=require('node:vm');
|
|
5
|
+
const source=fs.readFileSync(require.resolve('../src/srcHandler.js'),'utf8');
|
|
6
|
+
test('deferred SSR source fetches, renders and clears its 304 marker',async()=>{
|
|
7
|
+
const attrs=new Map([['src','/component.html'],['error','304']]);let content,requests=0;
|
|
8
|
+
const el={tagName:'DIV',getAttribute:key=>attrs.get(key)??null,hasAttribute:key=>attrs.has(key),removeAttribute:key=>attrs.delete(key),setValue:value=>{content=value;}};
|
|
9
|
+
const init=vm.runInNewContext(source.slice(source.indexOf('async function initElements('),source.indexOf('async function initMediaSource('))+'\ninitElements',{initializing:new WeakMap(),isSameOrigin:()=>true,sessionStorage:{getItem:()=>null},fetch:async()=>{requests++;return {status:200,ok:true,text:async()=>'<p>Cached or fetched content</p>'};},console,ObjectId:()=>''});
|
|
10
|
+
await init([el]);assert.equal(requests,1);assert.equal(content,'<p>Cached or fetched content</p>');assert.equal(attrs.has('error'),false);
|
|
11
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { readFileSync } = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const vm = require('node:vm');
|
|
5
|
+
const test = require('node:test');
|
|
6
|
+
const source = readFileSync(path.join(__dirname, '../src/formHandler.js'), 'utf8');
|
|
7
|
+
const handler = source.slice(source.indexOf('function handleEnter('), source.indexOf('\n/**\n * @param form'));
|
|
8
|
+
function run(options = {}) {
|
|
9
|
+
let clicked = 0, prevented = 0, queried = 0;
|
|
10
|
+
const form = { contains: () => true };
|
|
11
|
+
const scope = { getAttribute: () => '#next' };
|
|
12
|
+
const target = {
|
|
13
|
+
form, hasAttribute: () => true,
|
|
14
|
+
matches: () => options.disabled || false,
|
|
15
|
+
closest: () => options.hidden ? {} : null,
|
|
16
|
+
getClientRects: () => options.invisible ? [] : [{}],
|
|
17
|
+
click: () => clicked++
|
|
18
|
+
};
|
|
19
|
+
const input = {
|
|
20
|
+
tagName: options.tagName || 'INPUT', type: options.type || 'text', form,
|
|
21
|
+
closest: selector => selector === '[submit-query]' ? (options.noQuery ? null : scope) : (options.widget ? {} : null)
|
|
22
|
+
};
|
|
23
|
+
const event = { key: 'Enter', target: input, currentTarget: form, preventDefault: () => prevented++, ...options.event };
|
|
24
|
+
vm.runInNewContext(handler + '\nhandleEnter(event);', {
|
|
25
|
+
event, getComputedStyle: () => ({ visibility: 'visible' }),
|
|
26
|
+
queryElements: ({ element }) => { assert.equal(element, scope); queried++; return options.multiple ? [target, target] : [target]; }
|
|
27
|
+
});
|
|
28
|
+
return { clicked, prevented, queried };
|
|
29
|
+
}
|
|
30
|
+
test('an explicit query clicks its target once', () => {
|
|
31
|
+
assert.deepEqual(run(), { clicked: 1, prevented: 1, queried: 1 });
|
|
32
|
+
});
|
|
33
|
+
test('without a query, native behavior is left alone', () => {
|
|
34
|
+
assert.deepEqual(run({noQuery:true}), {clicked:0,prevented:0,queried:0});
|
|
35
|
+
});
|
|
36
|
+
for (const option of ['disabled','hidden','invisible','multiple']) {
|
|
37
|
+
test(`${option} targets are not clicked and cannot fall through to submission`, () => {
|
|
38
|
+
const result=run({[option]:true}); assert.equal(result.clicked,0);assert.equal(result.prevented,1);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
for (const options of [{tagName:'TEXTAREA'},{tagName:'SELECT'},{type:'checkbox'},{type:'submit'},{widget:true},{event:{isComposing:true}},{event:{ctrlKey:true}},{event:{defaultPrevented:true}}]) {
|
|
42
|
+
test(`preserves control behavior: ${JSON.stringify(options)}`,()=>{
|
|
43
|
+
assert.deepEqual(run(options),{clicked:0,prevented:0,queried:0});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
test('held Enter does not repeat or fall through to native submission',()=>{
|
|
47
|
+
assert.deepEqual(run({event:{repeat:true}}),{clicked:0,prevented:1,queried:0});
|
|
48
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
|
|
7
|
+
async function setup() {
|
|
8
|
+
const context = vm.createContext({
|
|
9
|
+
localStorage: { getItem: () => null }
|
|
10
|
+
});
|
|
11
|
+
const registries = {
|
|
12
|
+
elements: new Map(),
|
|
13
|
+
forms: new Map(),
|
|
14
|
+
keys: new Map(),
|
|
15
|
+
elementSubscriptions: new Map(),
|
|
16
|
+
subscriptions: new Map()
|
|
17
|
+
};
|
|
18
|
+
const stubs = {
|
|
19
|
+
'@cocreate/utils': {
|
|
20
|
+
getAttributes: element => ({ ...element.data }),
|
|
21
|
+
checkValue: () => true
|
|
22
|
+
},
|
|
23
|
+
'mapRegistry.js': registries,
|
|
24
|
+
'subscribeHandler.js': { getSubscription: () => null },
|
|
25
|
+
'crudHandler.js': { read() {}, save() {} }
|
|
26
|
+
};
|
|
27
|
+
const modules = new Map();
|
|
28
|
+
|
|
29
|
+
function load(name) {
|
|
30
|
+
name = name.startsWith('./') ? name.slice(2) : name;
|
|
31
|
+
if (modules.has(name)) return modules.get(name);
|
|
32
|
+
|
|
33
|
+
const stub = stubs[name];
|
|
34
|
+
const module = stub
|
|
35
|
+
? new vm.SyntheticModule(Object.keys(stub), function () {
|
|
36
|
+
for (const [key, value] of Object.entries(stub)) {
|
|
37
|
+
this.setExport(key, value);
|
|
38
|
+
}
|
|
39
|
+
}, { context })
|
|
40
|
+
: new vm.SourceTextModule(
|
|
41
|
+
fs.readFileSync(path.join(__dirname, '../src', name), 'utf8'),
|
|
42
|
+
{ context, identifier: name }
|
|
43
|
+
);
|
|
44
|
+
modules.set(name, module);
|
|
45
|
+
return module;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const module = load('getDataHandler.js');
|
|
49
|
+
await module.link(load);
|
|
50
|
+
await module.evaluate();
|
|
51
|
+
return module.namespace;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function field(form, key, value) {
|
|
55
|
+
return {
|
|
56
|
+
data: { array: 'organizations', object: '', key },
|
|
57
|
+
form,
|
|
58
|
+
attributes: [],
|
|
59
|
+
closest: () => form,
|
|
60
|
+
getAttribute: name => name === 'key' ? key : null,
|
|
61
|
+
hasAttribute: name => name === 'key',
|
|
62
|
+
getValue: async () => value,
|
|
63
|
+
matches: selector => selector !== '[render-query]' && selector !== 'input[type="file"]'
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('getData preserves each field key when controls share an object scope', async () => {
|
|
68
|
+
const { getData, getDataObject, initDataKey } = await setup();
|
|
69
|
+
const form = { elements: [] };
|
|
70
|
+
const fields = [
|
|
71
|
+
field(form, 'name', 'Acme'),
|
|
72
|
+
field(form, '_id', 'org-1'),
|
|
73
|
+
field(form, 'key', 'secret')
|
|
74
|
+
];
|
|
75
|
+
form.elements = fields;
|
|
76
|
+
for (const element of fields) initDataKey(element, getDataObject(element));
|
|
77
|
+
|
|
78
|
+
const [payload] = await getData(form);
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
JSON.parse(JSON.stringify(payload.object)),
|
|
81
|
+
{ name: 'Acme', _id: 'org-1', key: 'secret' }
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('getData does not apply a whole-object key to neighboring fields', async () => {
|
|
86
|
+
const { getData, getDataObject, initDataKey } = await setup();
|
|
87
|
+
const form = { elements: [] };
|
|
88
|
+
const fields = [
|
|
89
|
+
field(form, '{}', { name: 'Acme' }),
|
|
90
|
+
field(form, '_id', 'org-1')
|
|
91
|
+
];
|
|
92
|
+
form.elements = fields;
|
|
93
|
+
for (const element of fields) initDataKey(element, getDataObject(element));
|
|
94
|
+
|
|
95
|
+
const [payload] = await getData(form);
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
JSON.parse(JSON.stringify(payload.object)),
|
|
98
|
+
{ name: 'Acme', _id: 'org-1' }
|
|
99
|
+
);
|
|
100
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
|
|
7
|
+
function documentStub() {
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
return {
|
|
10
|
+
nodeType: 9,
|
|
11
|
+
addEventListener: (type, callback) => listeners.add(callback),
|
|
12
|
+
removeEventListener: (type, callback) => listeners.delete(callback),
|
|
13
|
+
click(target) {
|
|
14
|
+
const event = { target, prevented: false, preventDefault() { this.prevented = true; } };
|
|
15
|
+
for (const listener of listeners) listener(event);
|
|
16
|
+
return event;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function setup() {
|
|
22
|
+
const document = documentStub(), opened = [], definitions = [];
|
|
23
|
+
const location = { href: 'https://example.test/' };
|
|
24
|
+
const actions = { init: entries => definitions.push(...entries), href() { throw new Error('Default href'); } };
|
|
25
|
+
let targets = [];
|
|
26
|
+
const stubs = {
|
|
27
|
+
'@cocreate/actions': { default: actions },
|
|
28
|
+
'@cocreate/utils': { queryElements: () => targets }
|
|
29
|
+
};
|
|
30
|
+
const context = vm.createContext({ document, location, URL, window: { open: (...args) => opened.push(args) } });
|
|
31
|
+
const module = new vm.SourceTextModule(fs.readFileSync(path.join(__dirname, '../src/hrefHandler.js'), 'utf8'), { context });
|
|
32
|
+
await module.link(name => {
|
|
33
|
+
const exports = stubs[name];
|
|
34
|
+
return new vm.SyntheticModule(Object.keys(exports), function () {
|
|
35
|
+
for (const [key, value] of Object.entries(exports)) this.setExport(key, value);
|
|
36
|
+
}, { context });
|
|
37
|
+
});
|
|
38
|
+
await module.evaluate();
|
|
39
|
+
function element(attributes = {}, parent = null) {
|
|
40
|
+
const node = {
|
|
41
|
+
ownerDocument: document, parent, children: [],
|
|
42
|
+
hasAttribute: name => Object.hasOwn(attributes, name),
|
|
43
|
+
getAttribute: name => attributes[name] ?? null,
|
|
44
|
+
matches: selector => selector.split(',').some(part => Object.hasOwn(attributes, part.trim().slice(1, -1))),
|
|
45
|
+
closest(selector) { return this.matches(selector) ? this : parent?.closest(selector) || null; },
|
|
46
|
+
querySelector(selector) { return this.children.find(child => child.matches(selector)) || null; }
|
|
47
|
+
};
|
|
48
|
+
parent?.children.push(node);
|
|
49
|
+
return node;
|
|
50
|
+
}
|
|
51
|
+
return { document, location, opened, actions, definitions, element, module: module.namespace,
|
|
52
|
+
select: value => { targets = value; } };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('automatic setup registers actions and exposes only navigation controls', async () => {
|
|
56
|
+
const f = await setup();
|
|
57
|
+
assert.deepEqual(Object.keys(f.module).sort(), ['default', 'disableHref', 'enableHref', 'open']);
|
|
58
|
+
assert.deepEqual(Object.keys(f.module.default).sort(), ['disableHref', 'enableHref', 'open']);
|
|
59
|
+
for (const name of ['open', 'disableHref', 'enableHref']) {
|
|
60
|
+
assert.equal(f.module.default[name], f.module[name]);
|
|
61
|
+
}
|
|
62
|
+
assert.equal(typeof f.actions.href, 'function');
|
|
63
|
+
assert.equal(f.document.link.islink, 'true');
|
|
64
|
+
assert.deepEqual(f.definitions.map(definition => definition.name), ['disableHref', 'enableHref']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('delegation handles newly inserted links and preserves window targeting', async () => {
|
|
68
|
+
const f = await setup();
|
|
69
|
+
const link = f.element({ href: '/next', target: '_window' });
|
|
70
|
+
const icon = f.element({}, link);
|
|
71
|
+
assert.equal(f.document.click(icon).prevented, true);
|
|
72
|
+
assert.deepEqual(f.opened, [['/next', '_blank', 'location=yes,height=570,width=520,scrollbars=yes,status=yes']]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('direct clicks defer action links to the chain href hook', async () => {
|
|
76
|
+
const f = await setup();
|
|
77
|
+
const link = f.element({ actions: 'save', href: '/next' });
|
|
78
|
+
const event = f.document.click(link);
|
|
79
|
+
assert.equal(event.prevented, false);
|
|
80
|
+
assert.equal(f.location.href, 'https://example.test/');
|
|
81
|
+
f.actions.href(link, { event });
|
|
82
|
+
assert.equal(event.prevented, true);
|
|
83
|
+
assert.equal(f.location.href, '/next');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the chain hook resolves child links and leaves downloads alone', async () => {
|
|
87
|
+
const f = await setup();
|
|
88
|
+
const button = f.element({ actions: 'save' });
|
|
89
|
+
f.element({ href: '/next', target: 'preview' }, button);
|
|
90
|
+
f.actions.href(button);
|
|
91
|
+
assert.deepEqual(f.opened, [['/next', 'preview']]);
|
|
92
|
+
const download = f.element({ href: '/file', download: '' });
|
|
93
|
+
assert.equal(f.document.click(download).prevented, false);
|
|
94
|
+
f.actions.href(download);
|
|
95
|
+
assert.equal(f.location.href, 'https://example.test/');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('enable and disable actions update document state and complete their chains', async () => {
|
|
99
|
+
const f = await setup();
|
|
100
|
+
const button = f.element(), link = f.element({ href: '/next' });
|
|
101
|
+
let completed = 0;
|
|
102
|
+
f.definitions[0].callback({ element: button, end() { completed++; } });
|
|
103
|
+
assert.equal(f.document.click(link).prevented, true);
|
|
104
|
+
f.actions.href(link);
|
|
105
|
+
assert.equal(f.location.href, 'https://example.test/');
|
|
106
|
+
f.definitions[1].callback({ element: button, end() { completed++; } });
|
|
107
|
+
assert.equal(completed, 2);
|
|
108
|
+
f.document.click(link);
|
|
109
|
+
assert.equal(f.location.href, '/next');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('link-query scopes enable and disable to the selected iframe document', async () => {
|
|
113
|
+
const f = await setup();
|
|
114
|
+
const frameDocument = documentStub();
|
|
115
|
+
f.select([{ contentDocument: frameDocument }]);
|
|
116
|
+
const button = f.element({ 'link-query': '#preview' });
|
|
117
|
+
f.module.disableHref(button);
|
|
118
|
+
assert.equal(frameDocument.link.islink, 'false');
|
|
119
|
+
assert.equal(f.document.link.islink, 'true');
|
|
120
|
+
f.module.enableHref(button);
|
|
121
|
+
assert.equal(frameDocument.link.islink, 'true');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('applications can replace the href hook after the feature loads', async () => {
|
|
125
|
+
const f = await setup();
|
|
126
|
+
const custom = () => {};
|
|
127
|
+
f.actions.href = custom;
|
|
128
|
+
const button = f.element();
|
|
129
|
+
f.module.disableHref(button);
|
|
130
|
+
f.module.enableHref(button);
|
|
131
|
+
assert.equal(f.actions.href, custom);
|
|
132
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const vm = require('node:vm');
|
|
5
|
+
const test = require('node:test');
|
|
6
|
+
|
|
7
|
+
async function setup() {
|
|
8
|
+
const requests = [], jobs = new Map(), errors = [], observers = [];
|
|
9
|
+
let timerId = 0;
|
|
10
|
+
const context = vm.createContext({
|
|
11
|
+
console: { error: (...args) => errors.push(args), log() {} },
|
|
12
|
+
document: { querySelectorAll: () => [] },
|
|
13
|
+
localStorage: { getItem: () => null },
|
|
14
|
+
HTMLCollection: class {}, NodeList: class {},
|
|
15
|
+
setTimeout: (callback) => { jobs.set(++timerId, callback); return timerId; },
|
|
16
|
+
clearTimeout: (id) => jobs.delete(id)
|
|
17
|
+
});
|
|
18
|
+
const stubs = {
|
|
19
|
+
'@cocreate/observer': { default: { init(options) { observers.push(options); } } },
|
|
20
|
+
'@cocreate/actions': { default: { init() {} } },
|
|
21
|
+
'@cocreate/socket-client': { default: { send() {} } },
|
|
22
|
+
'@cocreate/crud-client': { default: { listen() {}, send: async (data) => { requests.push(data); } } },
|
|
23
|
+
'@cocreate/filter': { default: { init: async (element) => { if (element.ready) await element.ready; } } },
|
|
24
|
+
'@cocreate/utils': {
|
|
25
|
+
getAttributes: (element) => ({ ...element.data }),
|
|
26
|
+
getAttributeNames: () => [], queryElements: () => [],
|
|
27
|
+
checkValue: (value) => !/{{.*}}/.test(value)
|
|
28
|
+
},
|
|
29
|
+
'setDataHandler.js': { setData() {}, setDataType() {} }
|
|
30
|
+
};
|
|
31
|
+
const modules = new Map();
|
|
32
|
+
function load(name) {
|
|
33
|
+
name = name.startsWith('./') ? name.slice(2) : name;
|
|
34
|
+
if (modules.has(name)) return modules.get(name);
|
|
35
|
+
const stub = stubs[name];
|
|
36
|
+
const module = stub
|
|
37
|
+
? new vm.SyntheticModule(Object.keys(stub), function () {
|
|
38
|
+
for (const [key, value] of Object.entries(stub)) this.setExport(key, value);
|
|
39
|
+
}, { context })
|
|
40
|
+
: new vm.SourceTextModule(fs.readFileSync(path.join(__dirname, '../src', name), 'utf8'), { context, identifier: name });
|
|
41
|
+
modules.set(name, module);
|
|
42
|
+
return module;
|
|
43
|
+
}
|
|
44
|
+
const module = load('initHandler.js');
|
|
45
|
+
await module.link(load);
|
|
46
|
+
await module.evaluate();
|
|
47
|
+
return {
|
|
48
|
+
init: module.namespace.init, requests, errors, observers,
|
|
49
|
+
flush() { const batch = [...jobs.values()]; jobs.clear(); for (const job of batch) job(); }
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function element(id, attributes = {}) {
|
|
53
|
+
return {
|
|
54
|
+
tagName: 'DIV', data: { array: 'apis', object: id, key: 'name' },
|
|
55
|
+
getAttribute: (key) => attributes[key] ?? null,
|
|
56
|
+
hasAttribute: (key) => Object.hasOwn(attributes, key),
|
|
57
|
+
addEventListener() {}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const tick = () => new Promise(resolve => setImmediate(resolve));
|
|
61
|
+
|
|
62
|
+
test('filtered counts for different clusters are read in separate batches', async () => {
|
|
63
|
+
const f = await setup();
|
|
64
|
+
const counts = ['cluster-a', 'cluster-b'].map(cluster => {
|
|
65
|
+
const el = element('');
|
|
66
|
+
el.data = { array: 'servers', object: '', key: '$length' };
|
|
67
|
+
el.getFilter = async () => ({ query: { cluster_id: cluster }, index: 0 });
|
|
68
|
+
return el;
|
|
69
|
+
});
|
|
70
|
+
await f.init(counts);
|
|
71
|
+
f.flush();
|
|
72
|
+
assert.equal(f.requests.length, 2);
|
|
73
|
+
assert.deepEqual(f.requests.map(data => data.filter.query.cluster_id), ['cluster-a', 'cluster-b']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a pending field does not block reads for ready elements in the same SSR page', async () => {
|
|
77
|
+
const f = await setup();
|
|
78
|
+
const slow = element('111111111111111111111111');
|
|
79
|
+
const ready = element('222222222222222222222222', { rendered: 'true' });
|
|
80
|
+
let release;
|
|
81
|
+
slow.ready = new Promise(resolve => { release = resolve; });
|
|
82
|
+
const pending = f.init([slow, ready]);
|
|
83
|
+
await tick();
|
|
84
|
+
f.flush();
|
|
85
|
+
try {
|
|
86
|
+
assert.equal(f.requests.length, 1);
|
|
87
|
+
assert.equal(f.requests[0].object._id, ready.data.object);
|
|
88
|
+
} finally {
|
|
89
|
+
release();
|
|
90
|
+
await pending;
|
|
91
|
+
}
|
|
92
|
+
f.flush();
|
|
93
|
+
assert.equal(f.requests.length, 2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('one failed initialization does not prevent other reads and can be retried', async () => {
|
|
97
|
+
const f = await setup();
|
|
98
|
+
const broken = element('111111111111111111111111');
|
|
99
|
+
const ready = element('222222222222222222222222');
|
|
100
|
+
broken.ready = Promise.reject(new Error('value lookup failed'));
|
|
101
|
+
await f.init([broken, ready]);
|
|
102
|
+
f.flush();
|
|
103
|
+
assert.equal(f.requests.length, 1);
|
|
104
|
+
assert.equal(f.errors.length, 1);
|
|
105
|
+
delete broken.ready;
|
|
106
|
+
await f.init(broken);
|
|
107
|
+
f.flush();
|
|
108
|
+
assert.equal(f.requests.length, 2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('ready fields for the same document still share one debounced read', async () => {
|
|
112
|
+
const f = await setup();
|
|
113
|
+
await f.init([element('111111111111111111111111'), element('111111111111111111111111')]);
|
|
114
|
+
f.flush();
|
|
115
|
+
assert.equal(f.requests.length, 1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('read=false and already read elements remain excluded', async () => {
|
|
119
|
+
const f = await setup();
|
|
120
|
+
const read = element('222222222222222222222222');
|
|
121
|
+
read.hasRead = true;
|
|
122
|
+
await f.init([element('111111111111111111111111', { read: 'false' }), read]);
|
|
123
|
+
f.flush();
|
|
124
|
+
assert.equal(f.requests.length, 0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('changing read from false to true reads the current composed filter', async () => {
|
|
128
|
+
const f = await setup();
|
|
129
|
+
const attributes = { read: 'false' };
|
|
130
|
+
const el = element('', attributes);
|
|
131
|
+
el.data = { array: 'files', object: '', key: '' };
|
|
132
|
+
el.getFilter = async () => ({ query: { path: '/' }, index: 0 });
|
|
133
|
+
|
|
134
|
+
await f.init(el);
|
|
135
|
+
f.flush();
|
|
136
|
+
assert.equal(f.requests.length, 0);
|
|
137
|
+
|
|
138
|
+
attributes.read = 'true';
|
|
139
|
+
const observer = f.observers.find(item => item.name === 'CoCreateElementsReadAttribute');
|
|
140
|
+
await observer.callback({ target: el, oldValue: 'false' });
|
|
141
|
+
f.flush();
|
|
142
|
+
|
|
143
|
+
assert.equal(f.requests.length, 1);
|
|
144
|
+
assert.equal(f.requests[0].filter.query.path, '/');
|
|
145
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
|
|
7
|
+
function element(tagName, attributes = {}) {
|
|
8
|
+
return {
|
|
9
|
+
tagName: tagName.toUpperCase(), attributes,
|
|
10
|
+
getAttribute: name => attributes[name] ?? null,
|
|
11
|
+
matches(selector) {
|
|
12
|
+
return selector.split(',').some(part => {
|
|
13
|
+
const value = part.trim();
|
|
14
|
+
return value.startsWith('[')
|
|
15
|
+
? Object.hasOwn(attributes, value.slice(1, -1))
|
|
16
|
+
: value.toUpperCase() === this.tagName;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function setup(nodes = [], failures = new Set()) {
|
|
23
|
+
const imports = [], observers = [], calls = [], errors = [];
|
|
24
|
+
const context = vm.createContext({
|
|
25
|
+
console: { error: (...args) => errors.push(args) },
|
|
26
|
+
localStorage: { getItem: () => 'production' },
|
|
27
|
+
document: {
|
|
28
|
+
addEventListener() {},
|
|
29
|
+
querySelector: selector => nodes.find(node => node.matches(selector)),
|
|
30
|
+
querySelectorAll: selector => nodes.filter(node => node.matches(selector))
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const stubs = {
|
|
34
|
+
'@cocreate/observer': { default: { init: configs => observers.push(...configs) } },
|
|
35
|
+
'@cocreate/utils': {
|
|
36
|
+
getAttributes: node => ({ ...node.attributes }),
|
|
37
|
+
checkValue: () => true
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const modules = new Map();
|
|
41
|
+
function synthetic(exports) {
|
|
42
|
+
return new vm.SyntheticModule(Object.keys(exports), function () {
|
|
43
|
+
for (const [key, value] of Object.entries(exports)) this.setExport(key, value);
|
|
44
|
+
}, { context });
|
|
45
|
+
}
|
|
46
|
+
async function dynamicImport(name) {
|
|
47
|
+
imports.push(name);
|
|
48
|
+
if (failures.delete(name)) throw new Error('Temporary chunk failure');
|
|
49
|
+
const methods = ['init', 'read', 'save', 'getData', 'setDataType', 'reset'];
|
|
50
|
+
const module = synthetic(Object.fromEntries(methods.map(method => [method, (...args) => {
|
|
51
|
+
calls.push({ name, method, args });
|
|
52
|
+
return args[0];
|
|
53
|
+
}])));
|
|
54
|
+
await module.link(() => {});
|
|
55
|
+
await module.evaluate();
|
|
56
|
+
return module;
|
|
57
|
+
}
|
|
58
|
+
function load(name) {
|
|
59
|
+
name = name.replace(/^\.\//, '');
|
|
60
|
+
if (modules.has(name)) return modules.get(name);
|
|
61
|
+
const module = stubs[name] ? synthetic(stubs[name]) : new vm.SourceTextModule(
|
|
62
|
+
fs.readFileSync(path.join(__dirname, '../src', name), 'utf8'),
|
|
63
|
+
{ context, identifier: name, importModuleDynamically: dynamicImport }
|
|
64
|
+
);
|
|
65
|
+
modules.set(name, module);
|
|
66
|
+
return module;
|
|
67
|
+
}
|
|
68
|
+
const entry = load('index.js');
|
|
69
|
+
await entry.link(load);
|
|
70
|
+
await entry.evaluate();
|
|
71
|
+
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
72
|
+
await flush();
|
|
73
|
+
return {
|
|
74
|
+
api: entry.namespace.default, imports, calls, errors, flush,
|
|
75
|
+
mutate(node, type, attributeName) {
|
|
76
|
+
for (const observer of observers) {
|
|
77
|
+
if (!observer.types.includes(type) || !node.matches(observer.selector)) continue;
|
|
78
|
+
if (type === 'attributes' && !observer.attributeFilter.includes(attributeName)) continue;
|
|
79
|
+
observer.callback({ target: node, attributeName });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('an empty document loads no feature runtime and retains synchronous metadata APIs', async () => {
|
|
86
|
+
const f = await setup();
|
|
87
|
+
assert.deepEqual(f.imports, []);
|
|
88
|
+
const data = f.api.getDataObject(element('input', { array: 'files', key: 'name' }));
|
|
89
|
+
assert.equal(data.type, 'object');
|
|
90
|
+
assert.equal(data.database, 'production');
|
|
91
|
+
assert.equal(data.then, undefined);
|
|
92
|
+
f.api.keys.set('example', 1);
|
|
93
|
+
assert.equal(f.api.keys.get('example'), 1);
|
|
94
|
+
assert.deepEqual(f.imports, []);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
for (const [tag, attrs, expected] of [
|
|
98
|
+
['div', { src: '/partial.html' }, './srcHandler.js'],
|
|
99
|
+
['div', { source: '/partial.html' }, './srcHandler.js'],
|
|
100
|
+
['div', { stream: '/stream' }, './srcHandler.js'],
|
|
101
|
+
['form', {}, './formHandler.js'],
|
|
102
|
+
['fieldset', {}, './formHandler.js'],
|
|
103
|
+
['a', { href: '/next' }, './hrefHandler.js'],
|
|
104
|
+
['a', { target: '_window' }, './hrefHandler.js'],
|
|
105
|
+
['div', { array: 'files' }, './dataHandler.js'],
|
|
106
|
+
['div', { 'render-json': 'true' }, './dataHandler.js'],
|
|
107
|
+
['iframe', {}, './iframeHandler.js'],
|
|
108
|
+
['input', { 'value-query': '.source' }, './valueQuery.js'],
|
|
109
|
+
['div', { 'socket-on': 'object.read' }, './socketHandler.js'],
|
|
110
|
+
['button', { actions: 'send' }, './socketHandler.js'],
|
|
111
|
+
['button', { actions: 'save' }, './dataHandler.js'],
|
|
112
|
+
['button', { actions: 'delete' }, './dataHandler.js'],
|
|
113
|
+
['button', { actions: 'reset' }, './formHandler.js'],
|
|
114
|
+
['button', { actions: 'disableHref' }, './hrefHandler.js'],
|
|
115
|
+
['button', { actions: 'enableHref' }, './hrefHandler.js']
|
|
116
|
+
]) {
|
|
117
|
+
test(`initial ${tag} ${JSON.stringify(attrs)} loads only ${expected}`, async () => {
|
|
118
|
+
const f = await setup([element(tag, attrs)]);
|
|
119
|
+
assert.deepEqual(f.imports, [expected]);
|
|
120
|
+
assert.deepEqual(f.errors, []);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test('unrelated actions load no Elements runtime', async () => {
|
|
125
|
+
const f = await setup([element('button', { actions: 'validate, upload' })]);
|
|
126
|
+
assert.deepEqual(f.imports, []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('added elements and attribute changes load each feature once', async () => {
|
|
130
|
+
const f = await setup();
|
|
131
|
+
const source = element('div', { src: '/partial.html' });
|
|
132
|
+
const data = element('div', { array: 'files' });
|
|
133
|
+
f.mutate(source, 'addedNodes');
|
|
134
|
+
f.mutate(source, 'attributes', 'src');
|
|
135
|
+
f.mutate(data, 'attributes', 'array');
|
|
136
|
+
f.mutate(data, 'addedNodes');
|
|
137
|
+
await f.flush();
|
|
138
|
+
assert.deepEqual(f.imports, ['./srcHandler.js', './dataHandler.js']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('programmatic APIs await and share the data runtime', async () => {
|
|
142
|
+
const f = await setup();
|
|
143
|
+
const node = element('input');
|
|
144
|
+
for (const name of ['init', 'read', 'save', 'getData', 'setDataType']) {
|
|
145
|
+
assert.equal(await f.api[name](node), node);
|
|
146
|
+
}
|
|
147
|
+
assert.deepEqual(f.imports, ['./dataHandler.js']);
|
|
148
|
+
assert.deepEqual(f.calls.map(call => call.method), ['init', 'read', 'save', 'getData', 'setDataType']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('adding href to an existing element loads its handler once', async () => {
|
|
152
|
+
const f = await setup();
|
|
153
|
+
const link = element('a', { href: '/next' });
|
|
154
|
+
f.mutate(link, 'attributes', 'href');
|
|
155
|
+
f.mutate(link, 'addedNodes');
|
|
156
|
+
await f.flush();
|
|
157
|
+
assert.deepEqual(f.imports, ['./hrefHandler.js']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('failed feature imports can be retried by a subsequent mutation', async () => {
|
|
161
|
+
const node = element('div', { src: '/partial.html' });
|
|
162
|
+
const f = await setup([node], new Set(['./srcHandler.js']));
|
|
163
|
+
assert.equal(f.errors.length, 1);
|
|
164
|
+
f.mutate(node, 'attributes', 'src');
|
|
165
|
+
await f.flush();
|
|
166
|
+
assert.deepEqual(f.imports, ['./srcHandler.js', './srcHandler.js']);
|
|
167
|
+
assert.equal(f.errors.length, 1);
|
|
168
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const {test}=require('node:test');
|
|
2
|
+
const assert=require('node:assert/strict');
|
|
3
|
+
const fs=require('node:fs');
|
|
4
|
+
const vm=require('node:vm');
|
|
5
|
+
const source=fs.readFileSync(require.resolve('../src/setDataHandler.js'),'utf8');
|
|
6
|
+
const loadMore=vm.runInNewContext('('+source.slice(source.indexOf('async function loadMore('),source.indexOf('\nasync function checkFilters'))+')');
|
|
7
|
+
test('append deduplicates without mutating shared cache/server responses',async()=>{
|
|
8
|
+
const response={object:[{_id:'a'},{_id:'b'},{_id:'b'}],filter:{index:3,startingIndex:1}};
|
|
9
|
+
const original=JSON.stringify(response);let rendered;
|
|
10
|
+
await loadMore({getData:async()=>({object:[{_id:'a'}]}),renderValue:async data=>{rendered=data;}},response,'object');
|
|
11
|
+
assert.equal(JSON.stringify(response),original);
|
|
12
|
+
assert.equal(rendered.object.length,1);assert.equal(rendered.object[0]._id,'b');
|
|
13
|
+
assert.equal(rendered.filter.loadMore,true);assert.equal(rendered.filter.overwrite,false);
|
|
14
|
+
});
|
|
15
|
+
test('duplicate-only response leaves existing render untouched',async()=>{
|
|
16
|
+
let called=false;
|
|
17
|
+
await loadMore({getData:async()=>({object:[{_id:'a'}]}),renderValue:async()=>{called=true;}},{object:[{_id:'a'}]},'object');
|
|
18
|
+
assert.equal(called,false);
|
|
19
|
+
});
|