clickgo 6.7.2 → 6.8.1

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.
Files changed (44) hide show
  1. package/AGENTS.md +14 -0
  2. package/dist/app/demo.cga +0 -0
  3. package/dist/app/task.cga +0 -0
  4. package/dist/clickgo.d.ts +21 -0
  5. package/dist/control/common.cgc +0 -0
  6. package/dist/control/desktop.cgc +0 -0
  7. package/dist/control/form.cgc +0 -0
  8. package/dist/control/iconview.cgc +0 -0
  9. package/dist/control/jodit.cgc +0 -0
  10. package/dist/control/property.cgc +0 -0
  11. package/dist/control/task.cgc +0 -0
  12. package/dist/control/tuieditor.cgc +0 -0
  13. package/dist/global.css +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/lib/control.d.ts +69 -0
  16. package/dist/lib/core.d.ts +37 -1
  17. package/dist/lib/dom.d.ts +15 -1
  18. package/dist/lib/form.d.ts +43 -1
  19. package/dist/lib/task.d.ts +59 -2
  20. package/dist/lib/tool.d.ts +14 -1
  21. package/dist/theme/modern-light.cgt +0 -0
  22. package/dist/theme/modern.cgt +0 -0
  23. package/dist/theme/software-dark.cgt +0 -0
  24. package/dist/theme/software-light.cgt +0 -0
  25. package/doc/clickgo-rag.md +2399 -934
  26. package/package.json +12 -3
  27. package/test/core-module-loading.mjs +169 -0
  28. package/test/desktop.mjs +715 -0
  29. package/test/dom-style.mjs +261 -0
  30. package/test/form-launcher.mjs +102 -0
  31. package/test/form-native-regions.mjs +115 -0
  32. package/test/form-notify.mjs +90 -0
  33. package/test/form-viewport.mjs +79 -2
  34. package/test/iconview.mjs +70 -0
  35. package/test/jodit-lifecycle.mjs +212 -0
  36. package/test/rtl-archive.mjs +1 -1
  37. package/test/scroll.mjs +145 -0
  38. package/test/task-package-loading.mjs +94 -0
  39. package/test/task-tray.mjs +331 -0
  40. package/test/tool-assets.mjs +64 -0
  41. package/test/tuieditor-locale.mjs +175 -0
  42. package/test/vflow.mjs +122 -0
  43. package/dist/theme/compact-dark.cgt +0 -0
  44. package/dist/theme/compact-light.cgt +0 -0
@@ -0,0 +1,331 @@
1
+ import assert from 'node:assert/strict';
2
+ import vm from 'node:vm';
3
+ import { readFile } from 'node:fs/promises';
4
+ import ts from 'typescript';
5
+
6
+ const events = [];
7
+ const sizeWatch = new Map();
8
+ const pendingIcons = new Map();
9
+ const core = {
10
+ config: { 'task.position': 'bottom', 'task.mode': 'bar', 'task.margin': 8 },
11
+ trigger: async (...args) => { events.push(args); },
12
+ };
13
+ const form = { simpleSystemTaskRoot: { forms: {} }, notify() {}, getList: () => ({}),
14
+ getMaxZIndexID: async () => null, changeFocus: async () => {},
15
+ elements: { list: { querySelectorAll: () => [] } } };
16
+ const tick = [];
17
+ const clickgo = {
18
+ modules: { vue: { reactive: value => value, watch() {}, nextTick: callback => {
19
+ const promise = Promise.resolve().then(callback); tick.push(promise); return promise;
20
+ } } },
21
+ isNative: () => false,
22
+ };
23
+ const modules = {
24
+ '../clickgo': clickgo, './core': core, './form': form,
25
+ './tool': { clone: structuredClone, blob2DataUrl: async () => 'data:image/png;base64,icon' },
26
+ './fs': { getContent: async (taskId, path) => {
27
+ if (path === '/slow') return new Promise(resolve => pendingIcons.set(taskId, resolve));
28
+ assert.ok(path.startsWith('/package/') || path.startsWith('/clickgo/'));
29
+ return new Blob(['icon']);
30
+ } },
31
+ './control': { clearComponents() {} },
32
+ './native': { clear() {} },
33
+ './dom': {
34
+ removeFromStyleList() {}, clearWatchSize() {}, clearWatch() {},
35
+ watchSizeMulti: (taskId, el, callback) => { sizeWatch.set(el, { taskId, callback }); },
36
+ unwatchSizeMulti: (taskId, el) => { sizeWatch.delete(el); },
37
+ },
38
+ };
39
+ const source = await readFile(new URL('../dist/lib/task.ts', import.meta.url), 'utf8');
40
+ const compiled = ts.transpileModule(source + '\nexport const testState = { list, runtime };', {
41
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
42
+ }).outputText;
43
+ const exports = {};
44
+ vm.runInNewContext(compiled, { exports, require: name => modules[name] ?? {}, Blob, setTimeout, clearTimeout,
45
+ window: { innerWidth: 800, innerHeight: 600 } });
46
+ const api = exports;
47
+ api.initSysId('sys');
48
+ api.init();
49
+ const calls = [];
50
+ const owner = {
51
+ id: 'owner', class: { onTrayClick: id => calls.push(['app', id]), onTrayMenuClick: (id, cmd) => calls.push(['app-menu', id, cmd]) },
52
+ forms: { main: { vroot: { onTrayClick: id => calls.push(['form', id]), onTrayMenuClick: (id, cmd) => calls.push(['form-menu', id, cmd]) } } },
53
+ };
54
+ api.testState.list.owner = owner;
55
+ api.testState.list.other = { id: 'other', forms: {} };
56
+ api.testState.runtime.other = { permissions: [] };
57
+ const input = { icon: '/package/res/icon.png', tip: 'Tray', menu: [
58
+ { id: 'show', label: 'Show' }, { id: 'disabled', label: 'Disabled', disabled: true },
59
+ { id: 'split', label: '', separator: true },
60
+ ] };
61
+ const id = await api.createTray('owner', input);
62
+ assert.equal(typeof id, 'string');
63
+ input.menu[0].label = 'Changed externally';
64
+ assert.equal(api.getTrayList('owner')[id].menu[0].label, 'Show');
65
+ assert.equal(Object.keys(api.getTrayList('other')).length, 0);
66
+ assert.equal(await api.updateTray('other', id, { tip: 'Hijacked' }), false);
67
+ assert.equal(api.removeTray('other', id), false);
68
+ assert.equal(await api.activateTray('other', id), false);
69
+ assert.equal(await api.activateTray('owner', id), false);
70
+
71
+ function bar(taskId, formId, width, height) {
72
+ const props = {};
73
+ const el = { offsetWidth: width, offsetHeight: height };
74
+ const vroot = { position: 'bottom', $el: el, $refs: { form: { setPropData: (key, value) => { props[key] = value; } } } };
75
+ api.testState.list[taskId] = { id: taskId, forms: { [formId]: { vroot } } };
76
+ return { props, el };
77
+ }
78
+ const first = bar('bar1', 'f1', 220, 44);
79
+ assert.equal(api.setSystem('bar1', 'f1'), true);
80
+ await Promise.all(tick);
81
+ assert.equal(first.props.width, 800);
82
+ assert.equal(first.props.top, 556);
83
+ assert.equal(api.systemTaskInfo.length, 44);
84
+ assert.equal(Object.keys(api.getTrayList('bar1')).length, 1);
85
+ const snapshot = api.getTrayList('bar1'); snapshot[id].menu[0].label = 'Hijacked';
86
+ assert.equal(api.getTrayList('owner')[id].menu[0].label, 'Show');
87
+ assert.equal(await api.activateTray('bar1', id, 'disabled'), false);
88
+ assert.equal(await api.activateTray('bar1', id, 'split'), false);
89
+ assert.equal(await api.activateTray('bar1', id, 'stale'), false);
90
+ assert.equal(await api.activateTray('bar1', id), true);
91
+ assert.equal(await api.activateTray('bar1', id, 'show'), true);
92
+ assert.equal(calls.length, 4);
93
+ core.config['task.mode'] = 'dock';
94
+ api.refreshSystemPosition();
95
+ await Promise.all(tick);
96
+ assert.deepEqual(first.props, { width: 0, height: 0, left: 290, top: 548 });
97
+ assert.equal(api.systemTaskInfo.length, 52);
98
+ first.el.offsetWidth = 300;
99
+ sizeWatch.get(first.el).callback();
100
+ await Promise.all(tick);
101
+ assert.equal(first.props.left, 250);
102
+ for (const [position, expected] of Object.entries({
103
+ top: [250, 8, 52], left: [8, 278, 308], right: [492, 278, 308], bottom: [250, 548, 52],
104
+ })) {
105
+ core.config['task.position'] = position;
106
+ api.refreshSystemPosition(); await Promise.all(tick);
107
+ assert.deepEqual([first.props.left, first.props.top, api.systemTaskInfo.length], expected);
108
+ }
109
+ // An old pending position update cannot move a replacement bar.
110
+ api.refreshSystemPosition();
111
+ const second = bar('bar2', 'f2', 100, 40);
112
+ api.setSystem('bar2', 'f2'); await Promise.all(tick);
113
+ assert.equal(sizeWatch.has(first.el), false);
114
+ assert.equal(sizeWatch.has(second.el), true);
115
+ assert.equal(second.props.left, 350);
116
+ assert.equal(await api.activateTray('bar1', id), false);
117
+ assert.equal(await api.activateTray('bar2', id), true);
118
+ const old = api.updateTray('owner', id, { icon: '/slow' });
119
+ await Promise.resolve();
120
+ await api.updateTray('owner', id, { icon: 'data:image/png;base64,new', tip: 'New' });
121
+ pendingIcons.get('owner')(new Blob(['old']));
122
+ assert.equal(await old, false);
123
+ assert.equal(api.getTrayList('owner')[id].icon, 'data:image/png;base64,new');
124
+ const deletedUpdate = api.updateTray('owner', id, { icon: '/slow' });
125
+ await Promise.resolve(); api.removeTray('owner', id);
126
+ pendingIcons.get('owner')(new Blob(['old']));
127
+ assert.equal(await deletedUpdate, false);
128
+ assert.equal(await api.activateTray('bar2', id), false);
129
+ const creation = api.createTray('owner', { icon: '/slow' });
130
+ await Promise.resolve(); delete api.testState.list.owner;
131
+ pendingIcons.get('owner')(new Blob(['old']));
132
+ assert.equal(await creation, false, 'late registration cannot resurrect an ended task');
133
+ assert.equal(Object.keys(api.getTrayList('sys')).length, 0);
134
+ await api.clearSystem('bar2');
135
+ assert.equal(sizeWatch.size, 0);
136
+ assert.equal(api.systemTaskInfo.length, 0);
137
+ assert.ok(events.some(event => event[0] === 'trayCreated'));
138
+ assert.ok(events.some(event => event[0] === 'trayChanged'));
139
+ assert.ok(events.some(event => event[0] === 'trayRemoved'));
140
+ console.log('Tray ownership, snapshot isolation, app/form delivery, disabled/stale commands, replacement, async races and four-edge Dock geometry passed.');
141
+
142
+ // Exercise real public event routing, including a taskbar without root permission.
143
+ const coreSource = await readFile(new URL('../dist/lib/core.ts', import.meta.url), 'utf8');
144
+ const coreExports = {};
145
+ vm.runInNewContext(ts.transpileModule(coreSource, {
146
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
147
+ }).outputText, { exports: coreExports, require: name => name === './task' ? api : (modules[name] ?? {}), window: { addEventListener() {} }, TextEncoder });
148
+ coreExports.initSysId('sys');
149
+ const delivered = [];
150
+ const observer = name => ({ onTrayCreated: (ownerId, trayId) => delivered.push([name, ownerId, trayId]) });
151
+ api.testState.list.owner = { class: observer('owner-app'), forms: { ownerForm: { vroot: observer('owner-form') } } };
152
+ api.testState.list.other = { class: observer('other'), forms: {} };
153
+ api.testState.list.root = { class: observer('root'), forms: {} };
154
+ api.testState.list.bar2.class = observer('system-app');
155
+ api.testState.list.bar2.forms.f2.vroot.onTrayCreated = observer('system-form').onTrayCreated;
156
+ api.testState.runtime.root = { permissions: ['root'] };
157
+ api.systemTaskInfo.taskId = 'bar2';
158
+ await coreExports.trigger('trayCreated', 'owner', 'tray100');
159
+ assert.deepEqual(delivered.map(item => item[0]).sort(), ['owner-app', 'owner-form', 'root', 'system-app', 'system-form'].sort());
160
+ assert.ok(delivered.every(item => item[1] === 'owner' && item[2] === 'tray100'));
161
+ api.systemTaskInfo.taskId = '';
162
+ delivered.length = 0;
163
+ await coreExports.trigger('trayCreated', 'owner', 'tray101');
164
+ assert.ok(!delivered.some(item => item[0].startsWith('system')));
165
+ console.log('Public tray events reach owner App/Form, root and the designated system taskbar; replacement removes event access.');
166
+
167
+ // Prompt/menus can change while an icon is loading without losing either update.
168
+ const mergedId = await api.createTray('owner', { icon: 'data:image/png;base64,initial' });
169
+ const pending = api.updateTray('owner', mergedId, { icon: '/slow' });
170
+ await Promise.resolve();
171
+ await api.updateTray('owner', mergedId, { tip: 'latest tip', menu: [
172
+ { id: 'command', label: 'First', disabled: true, callback: () => {} },
173
+ { id: 'command', label: 'Duplicate' },
174
+ ] });
175
+ pendingIcons.get('owner')(new Blob(['merged']));
176
+ assert.equal(await pending, true);
177
+ const merged = api.getTrayList('owner')[mergedId];
178
+ assert.equal(merged.tip, 'latest tip');
179
+ assert.equal(merged.icon, 'data:image/png;base64,icon');
180
+ assert.equal(merged.menu.length, 1);
181
+ assert.equal('callback' in merged.menu[0], false);
182
+ api.removeTray('owner', mergedId);
183
+ console.log('Partial updates merge across pending icons and menus retain only public fields with unique command IDs.');
184
+
185
+ // Real task.end releases trays even when there are no application Forms left.
186
+ Object.assign(form, { getMaxZIndexID: async () => null, changeFocus: async () => {},
187
+ elements: { list: { querySelectorAll: () => [] } } });
188
+ Object.assign(modules['./dom'], { removeFromStyleList() {}, clearWatchSize() {}, clearWatch() {} });
189
+ modules['./control'] = { clearComponents() {} };
190
+ modules['./native'] = { clear() {} };
191
+ api.testState.list.exiting = { id: 'exiting', class: {}, forms: {}, threads: {}, timers: {}, app: { package: { clear() {} } } };
192
+ const exitingId = await api.createTray('exiting', { icon: 'data:image/png;base64,exiting' });
193
+ const lateCreate = api.createTray('exiting', { icon: '/slow' });
194
+ await Promise.resolve();
195
+ await api.end('exiting');
196
+ pendingIcons.get('exiting')(new Blob(['late']));
197
+ assert.equal(await lateCreate, false);
198
+ assert.equal(api.getTrayList('sys')[exitingId], undefined);
199
+ assert.equal(api.testState.list.exiting, undefined);
200
+ console.log('Actual task termination removes registered trays and rejects in-flight registrations.');
201
+
202
+ // Escape is handled once by the shared popup service, preserving IME and focus.
203
+ const formSource = await readFile(new URL('../dist/lib/form.ts', import.meta.url), 'utf8');
204
+ const formAst = ts.createSourceFile('form.ts', formSource, ts.ScriptTarget.ES2022, true);
205
+ const popupHandler = formAst.statements.find(statement => ts.isFunctionDeclaration(statement) && statement.name?.text === 'keydownPop');
206
+ assert.ok(popupHandler);
207
+ const popInfo = { list: [], elList: [] };
208
+ let popHidden = 0, focusRestored = 0, popupFocused = 0, childFocused = 0, prevented = 0;
209
+ const popupContext = vm.createContext({ popInfo, hidePop: () => {
210
+ ++popHidden;
211
+ popInfo.list.length = 0;
212
+ popInfo.elList.length = 0;
213
+ } });
214
+ vm.runInContext(ts.transpileModule(popupHandler.getText(formAst), {
215
+ compilerOptions: { target: ts.ScriptTarget.ES2022 },
216
+ }).outputText, popupContext);
217
+ const key = (name, isComposing = false) => ({ key: name, isComposing, preventDefault: () => { ++prevented; } });
218
+ popupContext.keydownPop(key('Escape'));
219
+ assert.equal(popHidden, 0);
220
+ popInfo.list = [
221
+ { focus: () => { ++popupFocused; } },
222
+ { focus: () => { ++popupFocused; } },
223
+ ];
224
+ popInfo.elList = [
225
+ { focus: options => {
226
+ assert.equal(popHidden, 1);
227
+ assert.equal(popInfo.list.length, 0);
228
+ assert.equal(popInfo.elList.length, 0);
229
+ assert.equal(options.preventScroll, true);
230
+ ++focusRestored;
231
+ } },
232
+ { focus: () => { ++childFocused; } },
233
+ ];
234
+ popupContext.keydownPop(key('Escape', true));
235
+ popupContext.keydownPop(key('Enter'));
236
+ assert.equal(popHidden, 0);
237
+ assert.equal(focusRestored, 0);
238
+ assert.equal(prevented, 0);
239
+ popupContext.keydownPop(key('Escape'));
240
+ assert.equal(popHidden, 1);
241
+ assert.equal(focusRestored, 1);
242
+ assert.equal(popupFocused, 0);
243
+ assert.equal(childFocused, 0);
244
+ assert.equal(prevented, 1);
245
+ popupContext.keydownPop(key('Escape'));
246
+ assert.equal(popHidden, 1);
247
+ assert.equal(focusRestored, 1);
248
+ assert.equal(prevented, 1);
249
+ console.log('Shared popup Escape handling ignores IME, closes once and restores the trigger focus.');
250
+
251
+ // Tray keyboard and lifecycle use the same public popup path as pointer menus.
252
+ const trayEvents = [], trayPops = [];
253
+ clickgo.control = { AbstractControl: class {
254
+ constructor() { this.slots = {}; this.refs = { pop: {} }; this.element = { dataset: {} }; }
255
+ emit(...args) { trayEvents.push(args); }
256
+ watch(_name, callback) { this.menuWatcher = callback; }
257
+ } };
258
+ clickgo.form = { hidePop: () => trayPops.push('hide'), showPop: (...args) => trayPops.push(args) };
259
+ const traySource = await readFile(new URL('../dist/sources/control/task-tray/code.ts', import.meta.url), 'utf8');
260
+ const trayExports = {};
261
+ vm.runInNewContext(ts.transpileModule(traySource, {
262
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
263
+ }).outputText, { exports: trayExports, require: () => clickgo });
264
+ const trayControl = new trayExports.default();
265
+ trayControl.keydown({ ...key('Enter'), repeat: true });
266
+ assert.equal(trayEvents.length, 0);
267
+ trayControl.keydown(key('Enter'));
268
+ trayControl.keydown(key(' '));
269
+ assert.deepEqual(trayEvents.map(event => event[0]), ['activate', 'activate']);
270
+ trayControl.keydown(key('ContextMenu'));
271
+ assert.equal(trayPops.filter(Array.isArray).length, 0);
272
+ trayControl.props.menu = [{ id: 'show', label: 'Show' }];
273
+ trayControl.keydown({ ...key('F10'), shiftKey: true });
274
+ assert.equal(trayPops.filter(Array.isArray).length, 1);
275
+ trayControl.select({ id: 'disabled', disabled: true });
276
+ assert.equal(trayEvents.length, 2);
277
+ trayControl.select(trayControl.props.menu[0]);
278
+ assert.equal(trayEvents[2][0], 'menu');
279
+ assert.equal(trayEvents[2][1].detail.id, 'show');
280
+ trayControl.onMounted();
281
+ trayControl.element.dataset.cgPopOpen = '';
282
+ trayControl.menuWatcher();
283
+ trayControl.onBeforeUnmount();
284
+ assert.equal(trayPops.slice(-2).join(','), 'hide,hide');
285
+ console.log('Tray keyboard activation/context menu, disabled commands, menu events and popup cleanup passed.');
286
+
287
+ // Date formatting uses the framework's complete language registry and the local calendar day.
288
+ const toolExports = {};
289
+ vm.runInNewContext(ts.transpileModule(await readFile(new URL('../dist/lib/tool.ts', import.meta.url), 'utf8'), {
290
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
291
+ }).outputText, { exports: toolExports });
292
+ let clockNow = new Date(2026, 9, 1, 23, 59).getTime();
293
+ class ClockDate extends Date {
294
+ constructor(value) { super(value === undefined ? clockNow : value); }
295
+ static now() { return clockNow; }
296
+ }
297
+ let clockTick;
298
+ clickgo.task = { createTimer: (_control, callback, _delay, options) => {
299
+ clockTick = callback;
300
+ if (options.immediate) callback();
301
+ } };
302
+ const clockExports = {};
303
+ vm.runInNewContext(ts.transpileModule(await readFile(new URL('../dist/sources/control/task/code.ts', import.meta.url), 'utf8'), {
304
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
305
+ }).outputText, { exports: clockExports, require: () => clickgo, Date: ClockDate, Intl });
306
+ const clockControl = new clockExports.default();
307
+ clockControl.onMounted();
308
+ assert.equal(clockControl.date, '23:59');
309
+ assert.equal(toolExports.lang.codes.length, 16);
310
+ for (const code of toolExports.lang.codes) {
311
+ clockControl.localeTag = toolExports.lang.getTag(code);
312
+ const text = clockControl.calendarDate;
313
+ const parts = new Intl.DateTimeFormat(clockControl.localeTag, {
314
+ calendar: 'gregory', month: 'short', day: 'numeric',
315
+ }).formatToParts(new Date(clockNow));
316
+ for (const type of ['month', 'day']) {
317
+ assert.ok(text.includes(parts.find(part => part.type === type).value), `${code} must show the localized ${type}`);
318
+ }
319
+ assert.equal(clockControl.timeFirst, code === 'vi', `${code} must use its localized date/time order`);
320
+ }
321
+ clockControl.localeTag = 'zh-CN';
322
+ assert.equal(clockControl.calendarDate, '10月1日');
323
+ clockControl.localeTag = 'en';
324
+ assert.equal(clockControl.calendarDate, 'Oct 1');
325
+ clockNow = new Date(2026, 9, 2, 0, 0).getTime();
326
+ clockTick();
327
+ assert.equal(clockControl.date, '00:00');
328
+ assert.equal(clockControl.calendarDate, 'Oct 2');
329
+ clockControl.localeTag = 'zh-TW';
330
+ assert.equal(clockControl.calendarDate, '10月2日');
331
+ console.log('Task clock shows localized month/day and date/time order for all 16 languages, switches language, and updates at local midnight.');
@@ -0,0 +1,64 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+ import vm from 'node:vm';
4
+ import { JSDOM } from 'jsdom';
5
+ import ts from 'typescript';
6
+
7
+ const dom = new JSDOM('<!doctype html><head></head><body></body>', { url: 'https://example.test/' });
8
+ const source = await readFile(new URL('../dist/lib/tool.ts', import.meta.url), 'utf8');
9
+ const tree = ts.createSourceFile('tool.ts', source, ts.ScriptTarget.ES2022, true);
10
+ const names = new Set(['getHeadElement', 'loadScript', 'loadScripts', 'loadLink', 'loadLinks', 'loadAssets']);
11
+ const nodes = tree.statements.filter(node =>
12
+ (ts.isVariableStatement(node) && node.declarationList.declarations.some(item => item.name.getText(tree) === 'headElement')) ||
13
+ (ts.isFunctionDeclaration(node) && node.body && names.has(node.name?.text)));
14
+ const api = {};
15
+ vm.runInNewContext(ts.transpileModule(nodes.map(node => node.getText(tree)).join('\n'), {
16
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
17
+ }).outputText, { exports: api, document: dom.window.document });
18
+ const head = dom.window.document.head;
19
+ function finish(el, success = true) { el.dispatchEvent(new dom.window.Event(success ? 'load' : 'error')); }
20
+ async function tick() { await new Promise(resolve => setImmediate(resolve)); }
21
+
22
+ assert.equal(await api.loadAssets([]), true);
23
+ assert.equal(head.children.length, 0);
24
+ const urls = ['/base.js?file=theme.css', '/theme.CSS?version=1#dark', '/addon.js#theme.css', '/override.css#last'];
25
+ const events = [];
26
+ let complete = false;
27
+ const loading = api.loadAssets(urls, { loaded: (url, state) => events.push([url, state]) }).then(value => { complete = true; return value; });
28
+ const scripts = [...head.querySelectorAll('script')];
29
+ const links = [...head.querySelectorAll('link')];
30
+ assert.equal(scripts.length, 2);
31
+ assert.equal(links.length, 2);
32
+ assert.equal(head.children.length, 4, 'all JS and CSS requests start before awaiting a resource');
33
+ assert.deepEqual(scripts.map(el => el.getAttribute('src')), [urls[0], urls[2]], 'query/hash values do not change the resource type');
34
+ assert.deepEqual(links.map(el => el.getAttribute('href')), [urls[1], urls[3]], 'CSS insertion order and original URLs are preserved');
35
+ assert.ok(scripts.every(el => el.async === false), 'unified loader uses browser ordered script execution');
36
+ finish(scripts[1]); finish(links[1]); finish(links[0]);
37
+ await tick();
38
+ assert.equal(complete, false, 'the aggregate waits for every file, regardless of completion order');
39
+ finish(scripts[0]);
40
+ assert.equal(await loading, true);
41
+ assert.deepEqual(events.map(([url, state]) => `${url}:${state}`).sort(), urls.map(url => `${url}:1`).sort(), 'each resource reports its own result once');
42
+
43
+ head.replaceChildren();
44
+ const cssFailed = api.loadAssets(['/ok.js', '/missing.css']);
45
+ finish(head.querySelector('link'), false);
46
+ finish(head.querySelector('script'));
47
+ assert.equal(await cssFailed, false, 'CSS errors affect the aggregate result');
48
+ head.replaceChildren();
49
+ const jsFailed = api.loadAssets(['/missing.js', '/ok.css']);
50
+ finish(head.querySelector('script'), false);
51
+ finish(head.querySelector('link'));
52
+ assert.equal(await jsFailed, false, 'JS errors affect the aggregate result');
53
+ head.replaceChildren();
54
+ const legacy = api.loadScript('/legacy.js');
55
+ assert.equal(head.querySelector('script').async, true, 'existing single-script loading retains async behavior');
56
+ finish(head.querySelector('script'));
57
+ assert.equal(await legacy, true);
58
+ head.replaceChildren();
59
+ const batch = api.loadScripts(['/legacy-a.js', '/legacy-b.js']);
60
+ assert.ok([...head.querySelectorAll('script')].every(el => el.async === true), 'existing script batches retain async behavior by default');
61
+ for (const el of head.querySelectorAll('script')) finish(el);
62
+ await batch;
63
+ dom.window.close();
64
+ console.log('Unified asset loading, URL classification, order, concurrency, callbacks, errors and legacy behavior passed.');
@@ -0,0 +1,175 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+ import vm from 'node:vm';
4
+ import { setImmediate } from 'node:timers/promises';
5
+ import ts from 'typescript';
6
+ import { JSDOM } from 'jsdom';
7
+
8
+ const source = await readFile(new URL('../dist/sources/control/tuieditor/code.ts', import.meta.url), 'utf8');
9
+ const compiled = ts.transpileModule(source, {
10
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: false },
11
+ }).outputText;
12
+ const dom = new JSDOM('<!doctype html><html><body></body></html>');
13
+ const { document } = dom.window;
14
+ const requests = [];
15
+ const editors = [];
16
+ function deferred() {
17
+ let resolve;
18
+ let reject;
19
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
20
+ return { promise, resolve, reject };
21
+ }
22
+ class AbstractControl {
23
+ get locale() { return this.testLocale; }
24
+ propBoolean(name) { return this.props[name] === true; }
25
+ watch(name, callback, options) {
26
+ this.watches.set(name, callback);
27
+ if (options?.immediate) callback();
28
+ }
29
+ emit() {}
30
+ }
31
+ class Editor {
32
+ constructor(options) {
33
+ this.options = options;
34
+ this.i18n = { get: key => key };
35
+ options.el.appendChild(document.createElement('div'));
36
+ editors.push(this);
37
+ }
38
+ insertToolbarItem() {}
39
+ }
40
+ let main;
41
+ const clickgo = {
42
+ modules: {},
43
+ getCdn: () => 'https://cdn.example',
44
+ getDirname: () => 'https://app.example/clickgo',
45
+ core: { getModule: () => main },
46
+ control: { AbstractControl },
47
+ tool: {
48
+ loadScript(url) {
49
+ const pending = deferred();
50
+ requests.push({ url, ...pending });
51
+ return pending.promise;
52
+ },
53
+ },
54
+ dom: { createElement: name => document.createElement(name), watchStyle() {} },
55
+ };
56
+ // Build the actual shared module once, then evaluate controls independently as different tasks do.
57
+ const coreSource = await readFile(new URL('../dist/lib/core.ts', import.meta.url), 'utf8');
58
+ const tree = ts.createSourceFile('core.ts', coreSource, ts.ScriptTarget.ES2022, true);
59
+ const names = new Set(['getModule', 'loadModule']);
60
+ const selected = tree.statements.filter(node =>
61
+ (ts.isVariableStatement(node) && node.declarationList.declarations.some(item => item.name.getText(tree) === 'modules')) ||
62
+ (ts.isFunctionDeclaration(node) && node.body && names.has(node.name?.text)));
63
+ const core = {};
64
+ vm.runInNewContext(ts.transpileModule(selected.map(node => node.getText(tree)).join('\n'), {
65
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: false },
66
+ }).outputText, {
67
+ exports: core, window: { toastui: { Editor } }, clickgo,
68
+ lTool: { ...clickgo.tool, loadAssets: async () => true, loadStyle() {}, urlResolve: (base, path) => new URL(path, base).href },
69
+ });
70
+ const shared = await core.getModule('@toast-ui/editor');
71
+ main = Promise.resolve(shared);
72
+ function loadControl() {
73
+ const exports = {};
74
+ vm.runInNewContext(compiled, { exports, require: () => clickgo });
75
+ return exports.default;
76
+ }
77
+ let Control = loadControl();
78
+ function create(locale) {
79
+ const control = new Control();
80
+ control.testLocale = locale;
81
+ control.watches = new Map();
82
+ control.element = document.createElement('div');
83
+ const content = document.createElement('div');
84
+ const pop = document.createElement('div');
85
+ control.element.append(content, pop);
86
+ control.refs = { content, pop };
87
+ document.body.appendChild(control.element);
88
+ return control;
89
+ }
90
+ await create('en').onMounted();
91
+ await create('ar').onMounted();
92
+ assert.equal(requests.length, 0, 'English and unsupported languages need no locale scripts');
93
+ assert.equal(editors.at(-1).options.language, 'en');
94
+ assert.equal(editors.at(-1).options.el.dir, 'rtl');
95
+
96
+ const base = deferred();
97
+ main = base.promise;
98
+ const first = create('sc');
99
+ Control = loadControl();
100
+ const second = create('sc');
101
+ const a = first.onMounted();
102
+ const b = second.onMounted();
103
+ assert.equal(requests.length, 0, 'locale registration waits for the main library');
104
+ base.resolve(shared);
105
+ await setImmediate();
106
+ assert.equal(requests.length, 1, 'independent task control modules share one pending language request');
107
+ assert.ok(requests[0].url.endsWith('/i18n/zh-cn.min.js'));
108
+ assert.equal(editors.length, 2, 'instances wait until their locale is registered');
109
+ requests[0].resolve(true);
110
+ await Promise.all([a, b]);
111
+ assert.equal(first.access.tuieditor.options.language, 'zh-CN');
112
+ assert.equal(second.access.tuieditor.options.language, 'zh-CN');
113
+ assert.notEqual(first.access.tuieditor, second.access.tuieditor);
114
+ await create('sc').onMounted();
115
+ assert.equal(requests.length, 1, 'ready locale scripts are reused across task control modules');
116
+
117
+ Control = loadControl();
118
+ await create('sc').onMounted();
119
+ assert.equal(requests.length, 1, 'a later task reuses the same completed language request');
120
+
121
+ first.testLocale = 'ja';
122
+ first.watches.get('locale')();
123
+ assert.equal(requests.length, 1, 'changing task locale does not reload an existing editor');
124
+ assert.equal(first.access.tuieditor.options.language, 'zh-CN');
125
+ main = Promise.resolve(shared);
126
+ const japanese = create('ja');
127
+ const c = japanese.onMounted();
128
+ await setImmediate();
129
+ assert.ok(requests.at(-1).url.endsWith('/i18n/ja-jp.min.js'));
130
+ requests.at(-1).resolve(true);
131
+ await c;
132
+ assert.equal(japanese.access.tuieditor.options.language, 'ja-JP', 'new editors use the current task language');
133
+
134
+ const french = create('fr');
135
+ const d = french.onMounted();
136
+ await setImmediate();
137
+ requests.at(-1).resolve(false);
138
+ await d;
139
+ assert.equal(french.access.tuieditor.options.language, 'en', 'locale failure falls back without disabling editing');
140
+ const beforeRetry = requests.length;
141
+ const retry = create('fr').onMounted();
142
+ await setImmediate();
143
+ assert.equal(requests.length, beforeRetry + 1, 'failed locale requests can be retried');
144
+ requests.at(-1).resolve(true);
145
+ await retry;
146
+ assert.equal(editors.at(-1).options.language, 'fr-FR');
147
+
148
+ const rejected = shared.loadLanguage('de-DE');
149
+ const rejectedWaiter = shared.loadLanguage('de-de');
150
+ requests.at(-1).reject(new Error('external loader rejected'));
151
+ assert.equal(await rejected, false);
152
+ assert.equal(await rejectedWaiter, false, 'external loader errors release all waiters');
153
+ const rejectedRetry = shared.loadLanguage('de-DE');
154
+ requests.at(-1).resolve(true);
155
+ assert.equal(await rejectedRetry, true, 'external errors do not poison the shared cache');
156
+
157
+ const removed = create('tc');
158
+ const e = removed.onMounted();
159
+ await setImmediate();
160
+ assert.ok(requests.at(-1).url.endsWith('/i18n/zh-tw.min.js'));
161
+ removed.element.remove();
162
+ const count = editors.length;
163
+ requests.at(-1).resolve(true);
164
+ await e;
165
+ assert.equal(editors.length, count, 'removed hosts do not receive late editor instances');
166
+
167
+ Control = loadControl();
168
+ main = Promise.resolve(null);
169
+ const missing = create('sc');
170
+ const beforeMissing = requests.length;
171
+ await missing.onMounted();
172
+ assert.equal(missing.notInit, true);
173
+ assert.equal(missing.isLoading, false);
174
+ assert.equal(requests.length, beforeMissing, 'a missing main library triggers no locale request');
175
+ console.log('Toast UI cross-task locale requests, reuse, fallback, retries and initialization language passed.');