Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions tests/js/capture_dialog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Harness for tests/test_batch_404_dialog_guard.py — see that file for the
// contract this serves.
//
// Drives a batch path of the SHIPPED client through a 404 response and prints,
// as a single JSON line on stdout, the text the user would have been shown.
//
// usage: node --preserve-symlinks capture_dialog.mjs <install|uninstall> [body]
//
// The client under test is reached through `./pkg/js`, which the Python side
// points at whichever tree it wants to measure. Running under
// `--preserve-symlinks` keeps module resolution inside this sandbox, so the
// client's own `../../scripts/*.js` imports land on the stand-ins next to this
// file while the file EXECUTED is the real one from the tree being measured.
// Only the network and the browser are stood in for; no client code is copied
// or reimplemented here.
import './dom.mjs';
import { setRoutes } from './scripts/api.js';
import { shown } from './scripts/app.js';

const site = process.argv[2];
const body = process.argv[3] || 'A security error has occurred. Please check the terminal logs';

if (site !== 'install' && site !== 'uninstall') {
console.error(`capture_dialog: expected site 'install' or 'uninstall', got ${JSON.stringify(site)}`);
process.exit(2);
}

const denied = async () => ({
status: 404,
async text() { return body; },
async json() { throw new Error('the 404 body is not JSON'); },
});

setRoutes({
'/manager/queue/status': async () => ({
status: 200,
async json() { return { is_processing: false, done_count: 0, total_count: 0 }; },
}),
'/manager/queue/reset': async () => ({ status: 200, async text() { return ''; } }),
'/manager/queue/install': denied,
'/manager/queue/uninstall': denied,
});

const PACK = 'comfyui-some-pack';
let callback = null;

if (site === 'uninstall') {
const { uninstallNodes } = await import('./pkg/js/common.js');
await uninstallNodes(
[{ title: PACK, name: PACK, version: 'unknown', files: [`https://github.com/x/${PACK}`] }],
{ title: PACK, onError: (m) => { callback = m; } }
);
} else {
const { CustomNodesManager } = await import('./pkg/js/custom-nodes-manager.js');
const item = {
hash: 'h1',
title: PACK,
originalData: { id: PACK, version: 'unknown', files: [`https://github.com/x/${PACK}`] },
};
// installNodes is a method, so it is invoked through the real prototype with
// stand-in collaborators; the body that runs is the shipped one.
const self = {
channel: 'default',
mode: 'cache',
showError: (m) => { if (m) callback = m; },
showStatus: () => {},
focusInstall: () => true,
grid: {
getRowItemBy: () => item,
scrollRowIntoView: () => {},
onNextUpdated: () => {},
updateCell: () => {},
},
};
const btn = { target: { classList: { add() {}, remove() {} } }, label: 'Install', mode: 'install' };
await CustomNodesManager.prototype.installNodes.call(self, ['h1'], btn, PACK, 'latest');
}

console.log(JSON.stringify({
site,
server_body: body,
dialog: shown.join('\n'),
callback,
}));
73 changes: 73 additions & 0 deletions tests/js/dom.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Minimal DOM stand-in for tests/test_batch_404_dialog_guard.py.
//
// Enough for the confirm modal js/common.js builds, and it AUTO-CLICKS the
// Confirm button so `customConfirm()` resolves in a headless run. Nothing the
// guard measures is reimplemented here — this only stands in for the browser.
function makeEl(tag) {
return {
tagName: String(tag).toUpperCase(),
style: {},
children: [],
textContent: '',
innerHTML: '',
_handlers: {},
classList: { add() {}, remove() {}, contains() { return false; } },
addEventListener(type, fn) { (this._handlers[type] ||= []).push(fn); },
removeEventListener() {},
appendChild(child) { this.children.push(child); return child; },
removeChild(child) {
const i = this.children.indexOf(child);
if (i >= 0) this.children.splice(i, 1);
return child;
},
remove() {},
setAttribute() {},
querySelector() { return null; },
querySelectorAll() { return []; },
click() { (this._handlers.click || []).forEach((fn) => fn({})); },
};
}

function walk(el, out = []) {
out.push(el);
for (const c of el.children || []) walk(c, out);
return out;
}

const body = makeEl('body');
const realAppend = body.appendChild.bind(body);
body.appendChild = (child) => {
realAppend(child);
// The confirm modal resolves on a click; fire it so the flow continues.
const buttons = walk(child).filter((e) => e.tagName === 'BUTTON');
const confirmBtn = buttons.find((e) => /confirm|yes|ok/i.test(e.textContent || ''));
if (confirmBtn) {
setTimeout(() => confirmBtn.click(), 0);
} else if (buttons.length) {
// A dialog with buttons, none of which this stub recognizes: the client
// would wait forever on customConfirm() and node would hang until the
// caller's subprocess timeout, surfacing as a multi-minute mystery
// rather than a diagnosis. Name what was seen and stop now.
console.error(
'capture_dialog: no confirm button matched /confirm|yes|ok/i, so the '
+ 'client would block on customConfirm(). Buttons seen: '
+ JSON.stringify(buttons.map((b) => b.textContent))
+ '. Update the pattern in tests/js/dom.mjs if the button was reworded.'
);
process.exit(3);
}
return child;
};

globalThis.document = {
body,
head: makeEl('head'),
createElement: makeEl,
createTextNode: (t) => ({ textContent: t }),
querySelector: () => null,
querySelectorAll: () => [],
addEventListener() {},
getElementById: () => null,
};
globalThis.window = { addEventListener() {}, location: { href: 'http://localhost:8188/' } };
globalThis.navigator = { userAgent: 'node', clipboard: { writeText: async () => {} } };
17 changes: 17 additions & 0 deletions tests/js/scripts/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Stand-in for ComfyUI's `scripts/api.js`, for tests/test_batch_404_dialog_guard.py.
//
// `fetchApi` is the network boundary. The harness installs per-scenario
// responses through setRoutes(); nothing here reaches a real server.
let routes = {};
export function setRoutes(r) { routes = r; }
export const api = {
async fetchApi(path, options) {
for (const key of Object.keys(routes)) {
if (path.startsWith(key)) return routes[key](path, options);
}
return { status: 200, async json() { return {}; }, async text() { return ''; } };
},
addEventListener() {},
removeEventListener() {},
apiURL(p) { return p; },
};
16 changes: 16 additions & 0 deletions tests/js/scripts/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Stand-in for ComfyUI's `scripts/app.js`, for tests/test_batch_404_dialog_guard.py.
//
// `app.ui.dialog.show()` is the sink the manager's error text actually reaches
// in the browser, so it is what the guard measures: every call is recorded and
// the harness prints what the user would have been shown.
export const shown = [];
export const app = {
ui: {
dialog: {
show(msg) { shown.push(msg); },
element: { style: {} },
},
},
extensionManager: { toast: { add() {} } },
registerExtension() {},
};
10 changes: 10 additions & 0 deletions tests/js/scripts/ui.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Stand-in for ComfyUI's `scripts/ui.js`, for tests/test_batch_404_dialog_guard.py.
// Import-time surface only — the guard never exercises these.
export function $el(tag, props = {}, children = []) {
return { tag, props, children, style: {}, classList: { add() {}, remove() {} } };
}
export class ComfyDialog {
constructor() { this.element = { style: {}, classList: { add() {}, remove() {} } }; }
show() {}
close() {}
}
Loading
Loading