325 lines
12 KiB
JavaScript
Executable File
325 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const root = path.resolve(__dirname, '..');
|
|
const sourcePath = path.join(root, 'plugins', 'todo', 'frontend', 'src', 'index.js');
|
|
const manifestPath = path.join(root, 'plugins', 'todo', 'plugin.json');
|
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
const localeCatalogs = {
|
|
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'todo', 'locales', 'en.json'), 'utf8')),
|
|
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'todo', 'locales', 'ru.json'), 'utf8')),
|
|
};
|
|
|
|
class FakeNode {
|
|
constructor(tagName) {
|
|
this.tagName = String(tagName || '').toUpperCase();
|
|
this.children = [];
|
|
this.attributes = {};
|
|
this.listeners = {};
|
|
this.style = {};
|
|
this.className = '';
|
|
this.value = '';
|
|
this.checked = false;
|
|
this.disabled = false;
|
|
this.parentNode = null;
|
|
this._textContent = '';
|
|
this._innerHTML = '';
|
|
}
|
|
|
|
appendChild(node) {
|
|
if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode');
|
|
this.children.push(node);
|
|
node.parentNode = this;
|
|
return node;
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
this.attributes[name] = String(value);
|
|
}
|
|
|
|
getAttribute(name) {
|
|
return this.attributes[name];
|
|
}
|
|
|
|
removeAttribute(name) {
|
|
delete this.attributes[name];
|
|
}
|
|
|
|
addEventListener(type, handler) {
|
|
this.listeners[type] = this.listeners[type] || [];
|
|
this.listeners[type].push(handler);
|
|
}
|
|
|
|
dispatchEvent(type, event = {}) {
|
|
(this.listeners[type] || []).forEach((handler) => handler({
|
|
target: this,
|
|
currentTarget: this,
|
|
preventDefault() {},
|
|
stopPropagation() {},
|
|
...event,
|
|
}));
|
|
}
|
|
|
|
click() {
|
|
this.dispatchEvent('click');
|
|
}
|
|
|
|
focus() {}
|
|
|
|
set innerHTML(value) {
|
|
this._innerHTML = String(value || '');
|
|
this.children = [];
|
|
}
|
|
|
|
get innerHTML() {
|
|
return this._innerHTML;
|
|
}
|
|
|
|
set textContent(value) {
|
|
this._textContent = String(value || '');
|
|
this.children = [];
|
|
}
|
|
|
|
get textContent() {
|
|
if (this.tagName === '#TEXT') return this._textContent;
|
|
return this._textContent + this.children.map((child) => child.textContent).join('');
|
|
}
|
|
}
|
|
|
|
function walk(node, predicate) {
|
|
if (predicate(node)) return node;
|
|
for (const child of node.children) {
|
|
const found = walk(child, predicate);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function byData(node, name, value) {
|
|
return walk(node, (item) => item.getAttribute && item.getAttribute(name) === value);
|
|
}
|
|
|
|
function makeDocument() {
|
|
return {
|
|
head: new FakeNode('head'),
|
|
body: new FakeNode('body'),
|
|
createElement(tagName) {
|
|
return new FakeNode(tagName);
|
|
},
|
|
createTextNode(value) {
|
|
const node = new FakeNode('#text');
|
|
node.textContent = value;
|
|
return node;
|
|
},
|
|
getElementById() {
|
|
return null;
|
|
},
|
|
};
|
|
}
|
|
|
|
function FakeCustomEvent(type, options = {}) {
|
|
this.type = type;
|
|
this.detail = options.detail;
|
|
}
|
|
|
|
function loadComponent(document, emittedEvents) {
|
|
const registry = {};
|
|
const window = {
|
|
VerstakPluginRegister(pluginId, bundle) {
|
|
registry[pluginId] = bundle.components || {};
|
|
},
|
|
dispatchEvent(event) {
|
|
emittedEvents.push(event);
|
|
},
|
|
};
|
|
window.window = window;
|
|
window.document = document;
|
|
window.CustomEvent = FakeCustomEvent;
|
|
vm.runInNewContext(source, { console, Date, Math, CustomEvent: FakeCustomEvent, document, window }, { filename: sourcePath });
|
|
const component = registry['verstak.todo'] && registry['verstak.todo'].TodoView;
|
|
if (!component) throw new Error('TodoView was not registered');
|
|
return component;
|
|
}
|
|
|
|
function makeApi(initialSettings = {}, initialLocale = 'en') {
|
|
const settings = { ...initialSettings };
|
|
let locale = initialLocale;
|
|
const localeListeners = [];
|
|
return {
|
|
settings,
|
|
settingsApi: {
|
|
read: async (key) => (key ? settings[key] : { ...settings }),
|
|
write: async (key, value) => {
|
|
settings[key] = value;
|
|
return { ...settings };
|
|
},
|
|
},
|
|
files: {
|
|
list: async () => [
|
|
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
|
|
{ name: 'Project', relativePath: 'Project', type: 'folder' },
|
|
],
|
|
},
|
|
setLocale(nextLocale) {
|
|
locale = nextLocale;
|
|
localeListeners.slice().forEach((listener) => listener(locale));
|
|
},
|
|
get api() {
|
|
return {
|
|
settings: this.settingsApi,
|
|
files: this.files,
|
|
i18n: {
|
|
getLocale: () => locale,
|
|
t: (key, params, fallback) => {
|
|
const message = localeCatalogs[locale]?.[key] || localeCatalogs.en[key] || fallback || key;
|
|
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
|
|
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
|
|
));
|
|
},
|
|
onDidChangeLocale: (listener) => {
|
|
localeListeners.push(listener);
|
|
return () => {
|
|
const index = localeListeners.indexOf(listener);
|
|
if (index !== -1) localeListeners.splice(index, 1);
|
|
};
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
async function flush() {
|
|
for (let index = 0; index < 10; index += 1) await Promise.resolve();
|
|
}
|
|
|
|
async function mountWithApi(apiState, props, emittedEvents = [], document = makeDocument()) {
|
|
const component = loadComponent(document, emittedEvents);
|
|
const container = new FakeNode('div');
|
|
component.mount(container, props, apiState.api);
|
|
await flush();
|
|
return { component, container, document, emittedEvents };
|
|
}
|
|
|
|
(async () => {
|
|
if (manifest.id !== 'verstak.todo') throw new Error('todo manifest id mismatch');
|
|
if (!manifest.permissions.includes('storage.namespace')) throw new Error('todo manifest must request storage.namespace');
|
|
if (!manifest.permissions.includes('files.read')) throw new Error('todo manifest must request files.read');
|
|
if (!(manifest.contributes.views || []).length) throw new Error('todo manifest must contribute a global view');
|
|
if (!(manifest.contributes.workspaceItems || []).length) throw new Error('todo manifest must contribute a workspace item');
|
|
|
|
const apiState = makeApi();
|
|
const workspaceView = await mountWithApi(apiState, { workspaceRootPath: 'Project', workspaceNode: { name: 'Project' } });
|
|
const { container } = workspaceView;
|
|
if (!container.textContent.includes('Todos · Project')) throw new Error('workspace Todo title was not rendered');
|
|
if (!byData(container, 'data-todo-action', 'add')) throw new Error('workspace add action was not rendered');
|
|
|
|
byData(container, 'data-todo-action', 'add').click();
|
|
byData(container, 'data-todo-input', 'title').value = 'Prepare project review';
|
|
byData(container, 'data-todo-input', 'description').value = 'Collect factual review notes.';
|
|
byData(container, 'data-todo-input', 'priority').value = 'high';
|
|
byData(container, 'data-todo-input', 'dueAt').value = '2000-01-01';
|
|
byData(container, 'data-todo-input', 'reminderAt').value = '2000-01-01T09:00';
|
|
byData(container, 'data-todo-action', 'save').click();
|
|
await flush();
|
|
|
|
const storedAfterCreate = apiState.settings['todos:global'] || [];
|
|
if (storedAfterCreate.length !== 1) throw new Error(`expected one stored todo, got ${storedAfterCreate.length}`);
|
|
const createdTodo = storedAfterCreate[0];
|
|
if (createdTodo.workspaceRootPath !== 'Project') throw new Error('workspace Todo did not keep the Project root path');
|
|
if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored');
|
|
if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored');
|
|
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
|
|
|
|
apiState.setLocale('ru');
|
|
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
|
|
throw new Error('Todo view did not update to Russian without remounting');
|
|
}
|
|
if ((apiState.settings['todos:global'] || []).length !== 1) throw new Error('locale change lost Todo state');
|
|
apiState.setLocale('en');
|
|
|
|
byData(container, 'data-todo-action', 'edit').click();
|
|
byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated';
|
|
byData(container, 'data-todo-action', 'save').click();
|
|
await flush();
|
|
if (apiState.settings['todos:global'][0].title !== 'Prepare project review updated') throw new Error('Todo edit was not persisted');
|
|
|
|
byData(container, 'data-todo-action', 'mark-done').click();
|
|
await flush();
|
|
if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) {
|
|
throw new Error('mark done did not persist completed state');
|
|
}
|
|
if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action');
|
|
if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion');
|
|
|
|
byData(container, 'data-todo-action', 'create-journal-entry').click();
|
|
const journalEvent = workspaceView.emittedEvents.find((event) => event.type === 'verstak:workspace-open-tool');
|
|
if (!journalEvent || !journalEvent.detail || journalEvent.detail.kind !== 'journal') {
|
|
throw new Error('Todo Journal conversion did not request the Journal workspace tool');
|
|
}
|
|
const journalRequest = journalEvent.detail.toolRequest;
|
|
if (!journalRequest || journalRequest.type !== 'completed-todo' || !journalRequest.todo) {
|
|
throw new Error('Todo Journal conversion did not send a completed-todo request');
|
|
}
|
|
if (journalRequest.todo.id !== createdTodo.id
|
|
|| journalRequest.todo.title !== 'Prepare project review updated'
|
|
|| journalRequest.todo.description !== 'Collect factual review notes.'
|
|
|| journalRequest.todo.workspaceRootPath !== 'Project'
|
|
|| !journalRequest.todo.completedAt) {
|
|
throw new Error('Todo Journal conversion did not preserve factual completed Todo fields');
|
|
}
|
|
|
|
apiState.settings['todos:global'].push({
|
|
id: 'todo-client',
|
|
title: 'Client follow-up',
|
|
workspaceRootPath: 'ClientA',
|
|
status: 'open',
|
|
priority: 'normal',
|
|
createdAt: '2026-06-30T08:00:00.000Z',
|
|
updatedAt: '2026-06-30T08:00:00.000Z',
|
|
});
|
|
const globalView = await mountWithApi(apiState, {});
|
|
if (!globalView.container.textContent.includes('Prepare project review updated') || !globalView.container.textContent.includes('Client follow-up')) {
|
|
throw new Error('global Todo view did not aggregate workspace todos');
|
|
}
|
|
const workspaceFilter = byData(globalView.container, 'data-todo-filter', 'workspace');
|
|
workspaceFilter.value = 'ClientA';
|
|
workspaceFilter.dispatchEvent('change');
|
|
await flush();
|
|
if (!globalView.container.textContent.includes('Client follow-up') || globalView.container.textContent.includes('Prepare project review updated')) {
|
|
throw new Error('global Todo workspace filter was not applied');
|
|
}
|
|
const statusFilter = byData(globalView.container, 'data-todo-filter', 'status');
|
|
workspaceFilter.value = '';
|
|
workspaceFilter.dispatchEvent('change');
|
|
statusFilter.value = 'done';
|
|
statusFilter.dispatchEvent('change');
|
|
await flush();
|
|
if (!globalView.container.textContent.includes('Prepare project review updated') || globalView.container.textContent.includes('Client follow-up')) {
|
|
throw new Error('global Todo status filter was not applied');
|
|
}
|
|
|
|
const clientView = await mountWithApi(apiState, { workspaceRootPath: 'ClientA', workspaceNode: { name: 'ClientA' } });
|
|
if (!clientView.container.textContent.includes('Client follow-up') || clientView.container.textContent.includes('Prepare project review updated')) {
|
|
throw new Error('workspace Todo view leaked another workspace todo');
|
|
}
|
|
|
|
statusFilter.value = 'all';
|
|
statusFilter.dispatchEvent('change');
|
|
await flush();
|
|
byData(container, 'data-todo-action', 'delete').click();
|
|
await flush();
|
|
if ((apiState.settings['todos:global'] || []).some((todo) => todo.id === createdTodo.id)) throw new Error('Todo delete was not persisted');
|
|
|
|
workspaceView.component.unmount && workspaceView.component.unmount(container);
|
|
globalView.component.unmount && globalView.component.unmount(globalView.container);
|
|
clientView.component.unmount && clientView.component.unmount(clientView.container);
|
|
console.log('todo plugin smoke passed');
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|