Files

279 lines
15 KiB
Python

"""Execute the generated iframe guard with a deterministic DOM/event-loop model."""
from __future__ import annotations
import ast
import json
import re
import shutil
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
def _embed_script(source: str | None = None) -> str:
path = Path(__file__).parents[1] / "app/gateway/routers/llmwiki.py"
tree = ast.parse(source if source is not None else path.read_text(encoding="utf-8"))
function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "_weknora_embed_injection")
# Compile the actual function without importing the Gateway and its runtime
# settings; only its surrounding serialization helpers need tiny stand-ins.
module = ast.Module(body=[ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0), function], type_ignores=[])
namespace = {
"_tenant_id_from_session": lambda _: "tenant-test",
"_embed_allowed_weknora_ids": lambda ctx: [ctx.weknora_id],
"_with_public_prefix": lambda prefix, path: prefix + path,
"_WEKNORA_EMBED_API_PREFIX": "/api/llmwiki/weknora",
"_escape_script_json": json.dumps,
}
exec(compile(ast.fix_missing_locations(module), str(path), "exec"), namespace)
html = namespace["_weknora_embed_injection"](
allowed_path="/platform/knowledge-bases/base-test",
tab="documents",
session={},
ctx=SimpleNamespace(can_write=True, weknora_id="base-test"),
)
return re.search(r'<script id="deerflow-weknora-embed-guard">(.*?)</script>', html, re.DOTALL).group(1)
HARNESS = r"""
const vm = require('node:vm');
const assert = require('node:assert/strict');
const input = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
const observers = new Set();
const timers = new Map();
const events = new Map();
const metrics = { globalQueries: 0, subtreeQueries: 0, layoutReads: 0, styleWrites: 0, sourceAttempts: 0, clicks: 0 };
let now = 0, nextTimer = 0;
function changed(target, type, attributeName, addedNodes = []) {
for (const observer of observers) {
const { root, options } = observer;
if (!root || (root !== target && (!options.subtree || !root.contains(target)))) continue;
if (type === 'attributes' && (!options.attributes || (options.attributeFilter && !options.attributeFilter.includes(attributeName)))) continue;
if (type === 'childList' && !options.childList) continue;
observer.records.push({ target, type, attributeName, addedNodes });
}
}
class Observer {
constructor(callback) { this.callback = callback; this.records = []; observers.add(this); }
observe(root, options) { this.root = root; this.options = options; }
disconnect() { this.root = null; this.records = []; }
takeRecords() { const records = this.records; this.records = []; return records; }
}
function flush() {
for (let turn = 0; turn < 100; turn++) {
const pending = [...observers].filter(observer => observer.records.length);
if (!pending.length) return;
for (const observer of pending) {
const records = observer.takeRecords();
if (records.length) observer.callback(records);
}
}
throw new Error('MutationObserver failed to settle');
}
function timeout(callback, delay = 0, repeat = false) {
const id = ++nextTimer;
timers.set(id, { callback, due: now + delay, delay, repeat });
return id;
}
function advance(duration) {
const end = now + duration;
flush();
for (let steps = 0; steps < 10000; steps++) {
const next = [...timers.entries()].filter(([, timer]) => timer.due <= end).sort((a, b) => a[1].due - b[1].due)[0];
if (!next) { now = end; return; }
const [id, timer] = next;
now = timer.due;
if (timer.repeat) timer.due += timer.delay;
else timers.delete(id);
timer.callback();
flush();
}
throw new Error('Timer loop did not settle');
}
function attrName(key) { return 'data-' + key.replace(/[A-Z]/g, c => '-' + c.toLowerCase()); }
class Element {
constructor(tag = 'div', attributes = {}) {
this.nodeType = 1; this.tagName = tag; this.attrs = new Map(Object.entries(attributes)); this.children = []; this.parentElement = null; this.textContent = '';
this.width = 1000; this.height = 800;
this.dataset = new Proxy({}, {
get: (_, key) => this.getAttribute(attrName(key)) ?? undefined,
set: (_, key, value) => { this.setAttribute(attrName(key), value); return true; },
deleteProperty: (_, key) => { this.removeAttribute(attrName(key)); return true; }
});
const values = new Map();
this.style = new Proxy({
getPropertyValue: key => values.get(key) || '',
setProperty: (key, value) => { values.set(key, value); metrics.styleWrites++; changed(this, 'attributes', 'style'); }
}, { get: (target, key) => key in target ? target[key] : values.get(key) || '', set: (_, key, value) => { values.set(key, value); metrics.styleWrites++; changed(this, 'attributes', 'style'); return true; } });
this.classList = {
contains: name => this.className.split(/\s+/).includes(name),
add: name => { if (!this.classList.contains(name)) this.setAttribute('class', (this.className + ' ' + name).trim()); },
remove: name => { if (this.classList.contains(name)) this.setAttribute('class', this.className.split(/\s+/).filter(value => value !== name).join(' ')); },
toggle: (name, on) => on ? this.classList.add(name) : this.classList.remove(name)
};
}
get className() { return this.getAttribute('class') || ''; }
get src() { return this.getAttribute('src') || ''; }
set src(value) { this.setAttribute('src', value); }
getAttribute(name) { return this.attrs.get(name) ?? null; }
setAttribute(name, value) { this.attrs.set(name, String(value)); changed(this, 'attributes', name); }
removeAttribute(name) { if (this.attrs.delete(name)) changed(this, 'attributes', name); }
contains(node) { return this === node || this.children.some(child => child.contains(node)); }
appendChild(node) { node.parentElement = this; this.children.push(node); changed(this, 'childList', null, [node]); return node; }
remove() { if (!this.parentElement) return; const parent = this.parentElement; parent.children = parent.children.filter(node => node !== this); this.parentElement = null; changed(parent, 'childList', null); }
matches(selector) {
return selector.split(',').some(part => {
part = part.trim();
if (part.includes('>')) return false;
if (part.startsWith('.')) return this.classList.contains(part.slice(1));
if (part.startsWith('#')) return this.getAttribute('id') === part.slice(1);
const attribute = part.match(/^(?:([\w-]+))?\[([^\]=*^]+)(?:(\*=|\^=|=)["']?([^"'\]]*)["']?)?\]$/);
if (attribute) {
if (attribute[1] && attribute[1] !== this.tagName) return false;
const value = this.getAttribute(attribute[2]);
if (!attribute[3]) return value !== null;
return value !== null && (attribute[3] === '*=' ? value.includes(attribute[4]) : attribute[3] === '^=' ? value.startsWith(attribute[4]) : value === attribute[4]);
}
return this.tagName === part;
});
}
closest(selector) { for (let node = this; node; node = node.parentElement) if (node.matches(selector)) return node; return null; }
querySelectorAll(selector) { metrics.subtreeQueries++; const result = []; const walk = node => { for (const child of node.children) { if (child.matches(selector)) result.push(child); walk(child); } }; walk(this); return result; }
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
getBoundingClientRect() { metrics.layoutReads++; return { left: 0, right: this.width, width: this.width, height: this.height }; }
click() { metrics.clicks++; this.onClick?.(); }
}
const root = new Element('html');
const body = root.appendChild(new Element('body'));
const app = body.appendChild(new Element('div', { id: 'app' }));
const main = app.appendChild(new Element('div', { class: 'main' }));
const outer = main.appendChild(new Element('aside', { class: 'sidebar' })); outer.width = 200;
const content = main.appendChild(new Element('div', { class: 'knowledge-layout' }));
const inner = content.appendChild(new Element('aside', { class: 'sidebar wiki-directory' })); inner.width = 200;
const document = {
documentElement: root, body, readyState: 'complete',
querySelectorAll: selector => { metrics.globalQueries++; return root.querySelectorAll(selector); },
querySelector: selector => { metrics.globalQueries++; if (selector === '[data-id="missing"]') metrics.sourceAttempts++; return root.querySelector(selector); },
getElementById: id => root.querySelector('#' + id),
addEventListener() {}, contains: node => root.contains(node)
};
const storage = () => { const values = new Map(); return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)) }; };
const url = new URL('http://iframe.test/platform/knowledge-bases/base-test?theme=light' + (input.case.startsWith('source') ? '&documentId=' + (input.case === 'source-success' ? 'found' : 'missing') : ''));
const context = {
document, location: url, history: { pushState() {}, replaceState() {} },
localStorage: storage(), sessionStorage: storage(), MutationObserver: Observer,
Node: { ELEMENT_NODE: 1 }, URL, URLSearchParams, Request, XMLHttpRequest: function () {},
innerWidth: 1000, innerHeight: 800,
getComputedStyle: node => ({ position: node.style.position || 'fixed' }),
setTimeout: (callback, delay) => timeout(callback, delay), clearTimeout: id => timers.delete(id),
setInterval: (callback, delay) => timeout(callback, delay, true), clearInterval: id => timers.delete(id),
addEventListener: (type, callback) => { if (!events.has(type)) events.set(type, []); events.get(type).push(callback); },
fetch: async () => ({}), CSS: { escape: value => value }
};
context.XMLHttpRequest.prototype.open = () => {};
context.window = context;
if (input.case === 'source-success') {
const row = content.appendChild(new Element('button', { 'data-document-id': 'found' }));
row.onClick = () => { const drawer = body.appendChild(new Element('div', { class: 't-drawer' })); drawer.textContent = '基本信息 文档内容'; };
}
vm.runInNewContext(input.script, context, { timeout: 2000 });
advance(1000);
const settled = { ...metrics };
if (input.case === 'idle' || input.case === 'baseline') {
advance(5000);
const delta = Object.fromEntries(Object.keys(metrics).map(key => [key, metrics[key] - settled[key]]));
if (input.case === 'idle') {
assert.equal(delta.globalQueries, 0, 'idle guard must stop full-document scans');
assert.equal(delta.layoutReads, 0, 'idle guard must stop forcing layout');
assert.equal(delta.styleWrites, 0, 'idle theme must stop rewriting CSS');
assert.equal(timers.size, 0, 'ordinary loaded pages need no recurring timers');
}
process.stdout.write(JSON.stringify(delta));
} else if (input.case === 'theme') {
const message = { type: 'deerflow-weknora-theme', theme: 'dark', vars: { primary: '#123456', background: '#010203' } };
for (const callback of events.get('message')) callback({ data: message });
advance(500);
assert.equal(root.getAttribute('theme-mode'), 'dark');
assert.equal(root.style.getPropertyValue('--td-brand-color'), '#123456');
root.setAttribute('data-theme', 'light');
root.classList.remove('dark');
root.style.setProperty('--td-brand-color', 'red');
advance(500);
assert.equal(root.getAttribute('data-theme'), 'dark');
assert.equal(root.classList.contains('dark'), true);
assert.equal(root.style.getPropertyValue('--td-brand-color'), '#123456');
const count = metrics.styleWrites;
advance(5000);
assert.equal(metrics.styleWrites, count);
assert.equal(metrics.globalQueries, settled.globalQueries, 'root theme changes must not scan application content');
} else if (input.case === 'batch') {
const globalBefore = metrics.globalQueries;
const scansBefore = metrics.subtreeQueries;
const section = content.appendChild(new Element('section'));
for (let index = 0; index < 50; index++) section.appendChild(new Element('p'));
flush();
advance(119);
const before = metrics.subtreeQueries;
advance(1);
assert.equal(metrics.subtreeQueries - before, 3, 'one inserted branch receives one guide/sidebar/content pass');
assert.equal(metrics.globalQueries, globalBefore, 'new branches must not trigger global scans');
assert.ok(metrics.subtreeQueries > scansBefore);
const complete = metrics.subtreeQueries;
advance(2000);
assert.equal(metrics.subtreeQueries, complete);
} else if (input.case === 'shell-images') {
assert.equal(outer.dataset.deerflowWeknoraChrome, 'hidden');
assert.equal(inner.dataset.deerflowWeknoraChrome, undefined, 'Wiki directory is not application chrome');
const sidebar = main.appendChild(new Element('aside', { class: 'sidebar' })); sidebar.width = 200;
const guide = content.appendChild(new Element('div', { class: 't-guide' }));
const image = content.appendChild(new Element('img', { 'data-protected-src': 'minio://bucket/image.png' }));
advance(500);
assert.equal(sidebar.dataset.deerflowWeknoraChrome, 'hidden');
assert.equal(guide.parentElement, null);
assert.match(image.src, /\/api\/llmwiki\/weknora\/api\/v1\/knowledge-bases\/base-test\/files\?file_path=/);
image.src = 'transparent.gif';
advance(200);
assert.match(image.src, /file_path=/, 'image replacement is still repaired');
} else if (input.case === 'source-missing') {
advance(10000);
assert.equal(root.classList.contains('deerflow-source-detail-open'), true);
assert.equal(metrics.sourceAttempts, 18);
assert.equal(timers.size, 0, 'source fallback stops retries');
const count = metrics.globalQueries;
advance(10000);
assert.equal(metrics.globalQueries, count);
} else if (input.case === 'source-success') {
assert.equal(root.classList.contains('deerflow-source-detail-open'), true);
assert.equal(metrics.clicks, 1);
const drawer = root.querySelector('.t-drawer');
assert.equal(drawer.dataset.deerflowSourceDetailPanel, 'true');
assert.equal(timers.size, 0);
const count = metrics.globalQueries;
advance(10000);
assert.equal(metrics.globalQueries, count);
}
"""
def _run_script(script: str, case: str) -> subprocess.CompletedProcess[str]:
node = shutil.which("node")
if node is None:
pytest.skip("Node.js is required to execute the generated iframe script")
return subprocess.run(
[node, "-e", HARNESS],
input=json.dumps({"script": script, "case": case}),
capture_output=True,
text=True,
encoding="utf-8",
timeout=30,
check=False,
)
@pytest.mark.parametrize("case", ["idle", "theme", "batch", "shell-images", "source-missing", "source-success"])
def test_iframe_guard_settles_and_preserves_interactions(case: str) -> None:
result = _run_script(_embed_script(), case)
assert result.returncode == 0, result.stdout + result.stderr