clickgo 6.9.0 → 6.10.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.
Files changed (44) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +4 -0
  3. package/dist/app/demo.cga +0 -0
  4. package/dist/app/task.cga +0 -0
  5. package/dist/clickgo.d.ts +2 -1
  6. package/dist/index.js +1 -1
  7. package/dist/lib/command/native.d.ts +18 -0
  8. package/dist/lib/command/palette.d.ts +10 -0
  9. package/dist/lib/command/schema.d.ts +31 -0
  10. package/dist/lib/command.d.ts +218 -0
  11. package/dist/lib/native.d.ts +12 -0
  12. package/dist/lib/task.d.ts +2 -0
  13. package/doc/clickgo-rag.md +2376 -537
  14. package/package.json +9 -3
  15. package/test/core-module-loading.mjs +0 -169
  16. package/test/desktop.mjs +0 -715
  17. package/test/dock-lifecycle.mjs +0 -280
  18. package/test/dom-style.mjs +0 -261
  19. package/test/form-launcher.mjs +0 -177
  20. package/test/form-modal-layer.mjs +0 -75
  21. package/test/form-native-regions.mjs +0 -115
  22. package/test/form-native-style.mjs +0 -80
  23. package/test/form-native.mjs +0 -68
  24. package/test/form-notify-animation.mjs +0 -102
  25. package/test/form-notify.mjs +0 -90
  26. package/test/form-viewport.mjs +0 -127
  27. package/test/iconview.mjs +0 -70
  28. package/test/jodit-lifecycle.mjs +0 -212
  29. package/test/locale-integrity.mjs +0 -189
  30. package/test/pop-max-size.mjs +0 -66
  31. package/test/rtl-archive.mjs +0 -440
  32. package/test/rtl-direction.mjs +0 -193
  33. package/test/scroll.mjs +0 -145
  34. package/test/select.mjs +0 -885
  35. package/test/sidebar-toggle.mjs +0 -75
  36. package/test/task-package-loading.mjs +0 -94
  37. package/test/task-start.mjs +0 -141
  38. package/test/task-tray.mjs +0 -331
  39. package/test/tool-assets.mjs +0 -64
  40. package/test/tool-clone.mjs +0 -42
  41. package/test/toolbox.mjs +0 -88
  42. package/test/tuieditor-locale.mjs +0 -175
  43. package/test/vflow.mjs +0 -122
  44. package/test/web-drag-mask.mjs +0 -96
@@ -1,75 +0,0 @@
1
- // Run after TypeScript compilation: node --experimental-vm-modules test/sidebar-toggle.mjs
2
- import assert from 'node:assert/strict';
3
- import { readFile } from 'node:fs/promises';
4
- import { SourceTextModule, SyntheticModule } from 'node:vm';
5
-
6
- class AbstractControl {
7
- constructor() {
8
- this.emitted = [];
9
- this.locale = 'sc';
10
- }
11
- propBoolean(name) {
12
- return [true, 'true', ''].includes(this.props[name]);
13
- }
14
- l(key) {
15
- return this.localeData[this.locale][key];
16
- }
17
- emit(name, value) {
18
- this.emitted.push([name, value]);
19
- }
20
- }
21
-
22
- const clickgo = new SyntheticModule(['control'], function() {
23
- this.setExport('control', { AbstractControl });
24
- });
25
- const mod = new SourceTextModule(await readFile(new URL('../dist/sources/control/sidebar-toggle/code.js', import.meta.url), 'utf8'));
26
- await mod.link(() => clickgo);
27
- await mod.evaluate();
28
- const SidebarToggle = mod.namespace.default;
29
- const toggle = new SidebarToggle();
30
-
31
- for (const [position, expanded, pointsRight] of [
32
- ['left', false, true], ['left', true, false], ['right', false, false], ['right', true, true],
33
- ]) {
34
- toggle.props.position = position;
35
- toggle.props.expanded = expanded;
36
- assert.equal(toggle.chevronRight, pointsRight, `${position}, expanded=${expanded}`);
37
- }
38
-
39
- toggle.props.expanded = 'false';
40
- toggle.props.label = 'Project';
41
- assert.equal(toggle.tipLabelComp, '展开侧栏 · Project');
42
- toggle.props.expanded = 'true';
43
- assert.equal(toggle.tipLabelComp, '收起侧栏 · Project');
44
- toggle.locale = 'ar';
45
- assert.equal(toggle.tipLabelComp, 'طي الشريط الجانبي · Project');
46
- toggle.props.tipLabel = 'Use one tool column';
47
- assert.equal(toggle.tipLabelComp, 'Use one tool column', 'Toolbox keeps its column-specific operation');
48
-
49
- for (const key of ['Enter', ' ']) {
50
- toggle.props.expanded = false;
51
- const count = toggle.emitted.length;
52
- let prevented = false;
53
- let stopped = false;
54
- const event = {
55
- key, 'repeat': false,
56
- 'preventDefault': () => { prevented = true; },
57
- 'stopPropagation': () => { stopped = true; },
58
- };
59
- toggle.keydown(event);
60
- assert.equal(prevented && stopped, true);
61
- assert.deepEqual(toggle.emitted[count], ['update:expanded', true]);
62
- assert.equal(toggle.props.expanded, false, 'the owner applies the requested state');
63
- toggle.keydown({ ...event, 'repeat': true });
64
- assert.equal(toggle.emitted.length, count + 1, 'holding a key must not repeatedly flip the sidebar');
65
- }
66
- toggle.keydown({
67
- 'key': 'Tab',
68
- 'preventDefault': () => assert.fail('Tab keeps native focus navigation'),
69
- 'stopPropagation': () => assert.fail('Other keys are not consumed'),
70
- });
71
- toggle.props.expanded = true;
72
- toggle.toggle();
73
- assert.deepEqual(toggle.emitted.at(-1), ['update:expanded', false]);
74
-
75
- console.log('Shared sidebar toggle direction, localized tips, controlled state and keyboard activation passed.');
@@ -1,94 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { readFile } from 'node:fs/promises';
3
- import vm from 'node:vm';
4
- import ts from 'typescript';
5
-
6
- const requests = new Map();
7
- const styles = [];
8
- const progress = [];
9
- const events = [];
10
- let id = 0;
11
- let taskApi;
12
- function getContent(taskId, path) {
13
- return new Promise((resolve, reject) => requests.set(path, { resolve, reject }));
14
- }
15
- const tool = {
16
- urlResolve: (base, path) => path,
17
- random: () => `id${++id}`,
18
- stateMachine() {},
19
- runIife: () => class { async main() { events.push('main'); } },
20
- stylePrepend: style => ({ prep: '', style }),
21
- styleUrl2DataUrl: async (base, style) => style,
22
- layoutAddTagClassAndReTagName: layout => layout,
23
- layoutClassPrepend: layout => layout,
24
- eventsAttrWrap: layout => layout,
25
- getClassPrototype: () => ({ method: {}, access: {} }),
26
- };
27
- const core = { config: { locale: 'en' }, trigger: async (...args) => { events.push(args); } };
28
- const form = { notify() {}, notifyContent() {} };
29
- const dom = { createToStyleList() {}, removeFromStyleList() {}, pushStyle: (taskId, style) => styles.push(style) };
30
- const zip = { get: async blob => {
31
- const label = await blob.text();
32
- return {
33
- readDir: path => path === '/' ? [{ name: 'same', isFile: false }] : [
34
- { path: 'same/', name: 'layout.html' }, { path: 'same/', name: 'style.css' },
35
- ],
36
- getContent: async path => path.endsWith('config.json') ?
37
- JSON.stringify({ name: 'same', layout: 'layout', style: 'style' }) :
38
- path.endsWith('html') ? `<div>${label}</div>` : label,
39
- };
40
- } };
41
- tool.getMimeByPath = name => ({ ext: name.split('.').pop() });
42
- async function load(file, modules) {
43
- const source = await readFile(new URL(`../dist/lib/${file}.ts`, import.meta.url), 'utf8');
44
- const exports = {};
45
- vm.runInNewContext(ts.transpileModule(source, {
46
- compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
47
- }).outputText, { exports, require: name => modules[name] ?? {}, Blob, setTimeout, clearTimeout,
48
- window: { innerWidth: 800, innerHeight: 600 } });
49
- return exports;
50
- }
51
- const clickgo = { getDirname: () => '/clickgo/', modules: { vue: { reactive: value => value, watch() {} } } };
52
- const common = { '../clickgo': clickgo, './tool': tool, './core': core, './fs': { getContent }, './dom': dom, './form': form };
53
- const controls = await load('control', { ...common, './zip': zip, './task': { getOrigin: taskId => taskApi.getOrigin(taskId) } });
54
- const themeOrder = [];
55
- taskApi = await load('task', { ...common, './control': controls, './theme': {
56
- read: async blob => ({ name: await blob.text() }),
57
- load: async (taskId, theme) => { themeOrder.push(theme?.name ?? 'global'); },
58
- } });
59
- taskApi.initSysId('sys');
60
- taskApi.init();
61
- const app = {
62
- type: 'app', config: { name: 'Concurrent packages', controls: ['/a', '/b.cgc'], themes: ['/light', '/dark'] },
63
- package: { getContent: async path => path === '/app.js' ? 'app fixture' : null },
64
- };
65
- const running = taskApi.run('sys', app, { notify: false, progress: (loaded, total, type, path) => progress.push(path) });
66
- await new Promise(resolve => setImmediate(resolve));
67
- assert.deepEqual([...requests.keys()], ['/light.cgt', '/dark.cgt', '/a.cgc', '/b.cgc'], 'all themes and controls request before the first package completes');
68
- requests.get('/b.cgc').resolve(new Blob(['second']));
69
- requests.get('/dark.cgt').resolve(new Blob(['dark']));
70
- await new Promise(resolve => setImmediate(resolve));
71
- assert.deepEqual(styles, [], 'later downloads cannot change registration order');
72
- assert.deepEqual(themeOrder, [], 'themes wait for controls and preceding themes');
73
- requests.get('/a.cgc').resolve(new Blob(['first']));
74
- await new Promise(resolve => setImmediate(resolve));
75
- assert.deepEqual(styles, ['first', 'second'], 'CSS registers in configured order');
76
- assert.deepEqual(progress, ['/a.cgc', '/b.cgc']);
77
- requests.get('/light.cgt').resolve(new Blob(['light']));
78
- const taskId = await running;
79
- assert.equal(typeof taskId, 'string');
80
- assert.ok(taskApi.getOrigin(taskId).controls.same.layout.includes('second'), 'last configured duplicate control wins');
81
- assert.deepEqual(themeOrder, ['light', 'dark'], 'theme application preserves configured order');
82
- assert.equal(events.at(-1), 'main');
83
-
84
- requests.clear();
85
- const failed = taskApi.run('sys', { ...app, config: { ...app.config, controls: ['/missing', '/later'], themes: ['/unused'] } }, { notify: false });
86
- await new Promise(resolve => setImmediate(resolve));
87
- requests.get('/later.cgc').reject(new Error('later request failure'));
88
- requests.get('/unused.cgt').reject(new Error('unused theme failure'));
89
- await new Promise(resolve => setImmediate(resolve));
90
- requests.get('/missing.cgc').resolve(null);
91
- assert.equal(await failed, -905, 'missing control retains the task failure code');
92
- await new Promise(resolve => setImmediate(resolve));
93
- assert.deepEqual(themeOrder, ['light', 'dark'], 'failure does not apply prefetched themes');
94
- console.log('Concurrent CGC/CGT downloads, ordered registration/themes, duplicate controls and failure handling passed.');
@@ -1,141 +0,0 @@
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
- import JSZip from 'jszip';
6
- import { JSDOM } from 'jsdom';
7
-
8
- const source = await readFile(new URL('../dist/sources/control/task-start/code.ts', import.meta.url), 'utf8');
9
- const scope = {
10
- exports: {},
11
- require: () => ({ control: { AbstractControl: class {
12
- propBoolean(name) { return this.props[name] === true || this.props[name] === 'true' || this.props[name] === ''; }
13
- parentByName() { return this.taskParent; }
14
- watch(name, callback) { this.watched = callback; }
15
- emit(name) { this.events.push(name); }
16
- } } }),
17
- };
18
- vm.runInNewContext(ts.transpileModule(source, {
19
- compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
20
- }).outputText, scope);
21
- const start = new scope.exports.default();
22
- start.events = [];
23
- let nativeClicks = 0;
24
- // --- 原生 click 不会触发框架转换后的 tap 监听,键盘必须直接发送控件事件。 ---
25
- start.element = { click: () => ++nativeClicks };
26
- const key = (value, extra = {}) => ({ key: value, preventDefault() {}, stopPropagation() {}, ...extra });
27
- assert.equal(start.labelMode, 'auto');
28
- start.props.showLabel = 'false';
29
- assert.equal(start.labelMode, 'hide');
30
- start.props.showLabel = true;
31
- assert.equal(start.labelMode, 'show');
32
- start.taskParent = { position: 'right', mode: 'dock' };
33
- assert.equal(start.position, 'right');
34
- assert.equal(start.mode, 'dock');
35
- start.keydown(key(' '));
36
- assert.equal(start.isSpaceDown, true);
37
- assert.equal(start.events.length, 0, 'Space activates on release');
38
- start.keydown(key(' ', { repeat: true }));
39
- start.keyup(key(' '));
40
- start.keyup(key(' '));
41
- assert.deepEqual(start.events, ['tap']);
42
- start.props.opened = true;
43
- start.keydown(key('Enter'));
44
- start.keydown(key('Enter', { repeat: true }));
45
- assert.equal(start.events.length, 2, 'held Enter does not repeatedly activate');
46
- assert.equal(start.props.opened, true, 'activation does not overwrite the caller-owned menu state');
47
- start.keydown(key('Enter', { isComposing: true }));
48
- start.onMounted();
49
- start.keydown(key(' '));
50
- start.props.disabled = true;
51
- start.watched();
52
- assert.equal(start.isSpaceDown, false);
53
- start.keyup(key(' '));
54
- start.keydown(key('Enter'));
55
- start.click({ stopPropagation() {} });
56
- assert.equal(start.events.length, 2, 'disabled controls never activate');
57
- assert.equal(nativeClicks, 0, 'keyboard activation does not depend on a native click reaching a tap listener');
58
-
59
- // --- 使用真实 Vue、Pointer.js 与框架事件转换,验证 @click 不丢失键盘或重复指针激活。 ---
60
- const dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>');
61
- for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Event', 'CustomEvent']) {
62
- Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name] });
63
- }
64
- const vue = await import('vue');
65
- const pointer = await import('../node_modules/@litert/pointer/dist/index.esm.js');
66
- const toolSource = await readFile(new URL('../dist/lib/tool.ts', import.meta.url), 'utf8');
67
- const toolTree = ts.createSourceFile('tool.ts', toolSource, ts.ScriptTarget.ES2022, true);
68
- const wrapSource = toolTree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'eventsAttrWrap').getText(toolTree);
69
- const toolScope = { exports: {} };
70
- vm.runInNewContext(ts.transpileModule(wrapSource, {
71
- compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
72
- }).outputText, toolScope);
73
- const prepare = layout => toolScope.exports.eventsAttrWrap(layout).replaceAll('@click="', '@tap="');
74
- const methods = {};
75
- const computed = {};
76
- for (const name of Object.getOwnPropertyNames(Object.getPrototypeOf(start))) {
77
- if (name === 'constructor') continue;
78
- const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(start), name);
79
- if (descriptor.value) methods[name] = descriptor.value;
80
- else if (descriptor.get) computed[name] = descriptor.get;
81
- }
82
- methods.propBoolean = function(name) { return pointerBoolean(this.props[name]); };
83
- function pointerBoolean(value) { return value === true || value === 'true' || value === ''; }
84
- methods.parentByName = () => null;
85
- methods.emit = function(name, event) { this.$emit(name, event); };
86
- methods.allowEvent = pointer.allowEvent;
87
- const layout = await readFile(new URL('../dist/sources/control/task-start/layout.html', import.meta.url), 'utf8');
88
- const component = {
89
- template: prepare(layout), props: Object.fromEntries(Object.entries(start.props).map(([name, value]) => [name, { default: value }])),
90
- components: { 'cg-img': { template: '<img>' } },
91
- emits: start.emits, methods, computed,
92
- data: () => ({ isSpaceDown: false, props: {}, element: null }),
93
- created() { this.props = this.$props; }, mounted() { this.element = this.$el; },
94
- };
95
- const app = vue.createApp({
96
- template: prepare('<task-start label="Start" :opened="opened" :disabled="disabled" @click="count++; opened = !opened"></task-start>'),
97
- components: { 'task-start': component }, methods: { allowEvent: pointer.allowEvent },
98
- data: () => ({ count: 0, opened: false, disabled: false }),
99
- });
100
- const root = app.mount('#app');
101
- const button = document.querySelector('[role="button"]');
102
- const keyboard = (type, key) => button.dispatchEvent(new window.KeyboardEvent(type, { key, bubbles: true, cancelable: true }));
103
- keyboard('keydown', 'Enter');
104
- await vue.nextTick();
105
- assert.equal(root.count, 1);
106
- assert.equal(button.getAttribute('aria-expanded'), 'true');
107
- keyboard('keydown', ' ');
108
- await vue.nextTick();
109
- assert.equal(root.count, 1);
110
- assert.ok(button.classList.contains('active'));
111
- keyboard('keyup', ' ');
112
- await vue.nextTick();
113
- assert.equal(root.count, 2);
114
- assert.equal(button.getAttribute('aria-expanded'), 'false');
115
- function pointerEvent(type) {
116
- const event = new window.MouseEvent(type, { bubbles: true, cancelable: true, clientX: 10, clientY: 10, button: 0, view: window });
117
- Object.defineProperties(event, { pointerId: { value: 1 }, pointerType: { value: 'mouse' } });
118
- button.dispatchEvent(event);
119
- }
120
- pointerEvent('pointerdown');
121
- pointerEvent('pointerup');
122
- await vue.nextTick();
123
- assert.equal(root.count, 3, 'pointer tap invokes the caller exactly once');
124
- root.disabled = true;
125
- await vue.nextTick();
126
- keyboard('keydown', 'Enter');
127
- pointerEvent('pointerdown');
128
- pointerEvent('pointerup');
129
- assert.equal(root.count, 3);
130
- assert.equal(button.getAttribute('tabindex'), null);
131
- app.unmount();
132
- dom.window.close();
133
-
134
- // --- 检查真实 task 包包含新控件,避免源码存在但用户无法加载。 ---
135
- const archive = await JSZip.loadAsync(await readFile(new URL('../dist/control/task.cgc', import.meta.url)));
136
- for (const file of ['config.json', 'code.js', 'layout.html', 'style.css']) {
137
- assert.ok(archive.file(`task-start/${file}`), `task.cgc contains task-start/${file}`);
138
- }
139
- assert.equal(await archive.file('task-start/layout.html').async('string'),
140
- (await readFile(new URL('../dist/sources/control/task-start/layout.html', import.meta.url), 'utf8')).replace(/>\s+</g, '><').trim());
141
- console.log('Task start caller-owned state, label override, bar/dock, keyboard, disabled and package loading passed.');
@@ -1,331 +0,0 @@
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.');