feat: add global and workspace todo plugin
This commit is contained in:
@@ -227,6 +227,8 @@ if command -v node &>/dev/null; then
|
||||
report "journal frontend behavior" $?
|
||||
node "$ROOT/scripts/smoke-browser-inbox-plugin.js"
|
||||
report "browser inbox frontend behavior" $?
|
||||
node "$ROOT/scripts/smoke-todo-plugin.js"
|
||||
report "todo frontend behavior" $?
|
||||
node "$ROOT/scripts/smoke-search-plugin.js"
|
||||
report "search frontend behavior" $?
|
||||
node "$ROOT/scripts/smoke-secrets-plugin.js"
|
||||
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/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'));
|
||||
|
||||
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 loadComponent(document) {
|
||||
const registry = {};
|
||||
const window = {
|
||||
VerstakPluginRegister(pluginId, bundle) {
|
||||
registry[pluginId] = bundle.components || {};
|
||||
},
|
||||
};
|
||||
window.window = window;
|
||||
window.document = document;
|
||||
vm.runInNewContext(source, { console, Date, Math, 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 = {}) {
|
||||
const settings = { ...initialSettings };
|
||||
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' },
|
||||
],
|
||||
},
|
||||
get api() {
|
||||
return { settings: this.settingsApi, files: this.files };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let index = 0; index < 10; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
async function mountWithApi(apiState, props, document = makeDocument()) {
|
||||
const component = loadComponent(document);
|
||||
const container = new FakeNode('div');
|
||||
component.mount(container, props, apiState.api);
|
||||
await flush();
|
||||
return { component, container, document };
|
||||
}
|
||||
|
||||
(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');
|
||||
|
||||
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');
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user