XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESystem

【Rust】XFE系统,基于Rust和汇编

公开
关注 0 Fork 0 Star 0
UTF-8
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';

// Host-only integration evidence. This never controls the user's existing browser or QEMU.
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outputRoot = path.join(root, 'build', 'CSharpDocuments');
fs.mkdirSync(outputRoot, { recursive: true });
const output = fs.mkdtempSync(path.join(outputRoot, 'Browser-'));
const fixtures = path.join(output, 'Fixtures');
const artifacts = path.join(output, 'Artifacts');
const exportResult = spawnSync('dotnet', ['run', '--project', path.join(root, 'Tests/XFESystem.Documents.PreviewHost'),
    '--configuration', 'Release', `-p:ArtifactsPath=${artifacts}`, '--', fixtures],
{ cwd: root, encoding: 'utf8', windowsHide: true, timeout: 60000 });
fs.writeFileSync(path.join(output, 'Export.log'), exportResult.stdout + exportResult.stderr);
assert.equal(exportResult.status, 0, exportResult.stdout + exportResult.stderr);

const executable = process.env.XFE_DOCUMENT_BROWSER || 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
assert.ok(fs.existsSync(executable), 'Set XFE_DOCUMENT_BROWSER to a Chromium browser executable.');
const profile = path.join(output, 'Profile');
const stderr = fs.openSync(path.join(output, 'Browser.log'), 'a');
const browser = spawn(executable, ['--headless=new', '--disable-gpu', '--disable-extensions', '--disable-background-networking',
    '--no-first-run', '--no-default-browser-check', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0',
    '--window-size=1280,1000', `--user-data-dir=${profile}`, 'about:blank'],
{ windowsHide: true, stdio: ['ignore', 'ignore', stderr] });
fs.closeSync(stderr);
const browserExit = new Promise(resolve => browser.once('exit', (code, signal) => resolve({ code, signal })));
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const until = async (condition, description, timeout = 20000) => {
    const start = Date.now();
    while (Date.now() - start < timeout) {
        const value = await condition();
        if (value) return value;
        await delay(30);
    }
    throw new Error(`Timed out: ${description}`);
};

let socket;
let id = 0;
const pending = new Map();
const contexts = new Map();
const resourceLoads = [];
const rejectedRequests = [];
const scriptExceptions = [];
const browserWarnings = [];
let interceptionFailure;
const result = { Kind: 'WindowsHostOnly', GuestAcceptance: false, Output: output, Checks: [], ResourceLoads: resourceLoads,
    RejectedRequests: rejectedRequests, ScriptExceptions: scriptExceptions, BrowserWarnings: browserWarnings };
const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const source = path.join(root, 'Source/XFESystem.Documents');
result.SourceHashes = Object.fromEntries(fs.readdirSync(source).filter(name => /\.(cs|csproj|json)$/.test(name)).map(name => [name, sha256(path.join(source, name))]));
result.AssemblySha256 = sha256(path.join(artifacts, 'bin/XFESystem.Documents.PreviewHost/release/XFESystem.Documents.dll'));
result.WebsiteManifestSha256 = sha256(path.join(fixtures, 'Assets', 'SourceManifest.json'));
const command = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
    const requestId = ++id;
    const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`CDP timeout: ${method}`)); }, 20000);
    pending.set(requestId, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
    socket.send(JSON.stringify({ id: requestId, method, params, ...(sessionId ? { sessionId } : {}) }));
});

const mime = name => ({ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
    '.css': 'text/css; charset=utf-8', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf' })[path.extname(name)] || 'application/octet-stream';
const serve = async request => {
    const url = new URL(request.request.url);
    let file;
    let resource;
    if (url.origin === 'https://xfe-preview.invalid' && url.pathname === '/favicon.ico') {
        await command('Fetch.fulfillRequest', { requestId: request.requestId, responseCode: 204 });
        return;
    }
    if (url.origin === 'https://xfe-preview.invalid' && /^\/(Markdown|Code|JavaScript)\.html$/.test(url.pathname)) {
        file = path.join(fixtures, url.pathname.slice(1));
    } else if (url.origin === 'https://xfe-resources.invalid' && url.pathname.startsWith('/Documents/')) {
        resource = decodeURIComponent(url.pathname.slice('/Documents/'.length));
        assert.ok(!resource.includes('\\') && !resource.split('/').some(segment => !segment || segment === '.' || segment === '..'));
        file = path.join(fixtures, 'Assets', resource);
    }
    if (!file || !fs.existsSync(file)) {
        rejectedRequests.push(request.request.url);
        await command('Fetch.failRequest', { requestId: request.requestId, errorReason: 'BlockedByClient' });
        return;
    }
    const bytes = fs.readFileSync(file);
    if (resource) resourceLoads.push({ Name: resource, Bytes: bytes.length, Mime: mime(file) });
    await command('Fetch.fulfillRequest', { requestId: request.requestId, responseCode: 200,
        responseHeaders: [{ name: 'Content-Type', value: mime(file) }, { name: 'X-Content-Type-Options', value: 'nosniff' },
            { name: 'Access-Control-Allow-Origin', value: '*' }],
        body: bytes.toString('base64') });
};

const evaluate = async (expression, contextId, sessionId) => {
    const value = await command('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true,
        ...(contextId ? { contextId } : {}) }, sessionId);
    if (value.exceptionDetails) throw new Error(value.exceptionDetails.text + ': ' + value.exceptionDetails.exception?.description);
    return value.result.value;
};
const navigate = async name => {
    const url = `https://xfe-preview.invalid/${name}.html`;
    await command('Page.navigate', { url });
    await until(async () => await evaluate(`location.href === ${JSON.stringify(url)} && document.readyState === 'complete'`), `${name} document complete`);
    if (interceptionFailure) throw interceptionFailure;
};
const screenshot = async name => {
    const shot = await command('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
    fs.writeFileSync(path.join(output, name + '.png'), Buffer.from(shot.data, 'base64'));
};

try {
    await until(() => fs.existsSync(path.join(profile, 'DevToolsActivePort')), 'isolated Edge DevTools port');
    const [port] = fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').trim().split(/\r?\n/);
    const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
    result.BrowserVersion = version.Browser;
    result.BrowserPid = browser.pid;
    const page = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json();
    socket = new WebSocket(page.webSocketDebuggerUrl);
    await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
    socket.addEventListener('message', event => {
        const message = JSON.parse(event.data);
        if (message.id) {
            const request = pending.get(message.id);
            if (!request) return;
            pending.delete(message.id);
            message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result);
        } else if (message.method === 'Fetch.requestPaused') {
            serve(message.params).catch(error => { interceptionFailure = error; });
        } else if (message.method === 'Runtime.executionContextCreated') {
            contexts.set((message.sessionId || '') + ':' + message.params.context.id,
                { ...message.params.context, sessionId: message.sessionId });
        } else if (message.method === 'Runtime.executionContextDestroyed') {
            contexts.delete((message.sessionId || '') + ':' + message.params.executionContextId);
        } else if (message.method === 'Runtime.exceptionThrown') {
            scriptExceptions.push(message.params.exceptionDetails);
        } else if (message.method === 'Log.entryAdded') {
            browserWarnings.push(message.params.entry);
        }
    });
    await command('Page.enable');
    await command('Runtime.enable');
    await command('Log.enable');
    await command('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });

    await navigate('Markdown');
    await until(async () => await evaluate('Boolean(document.querySelector(".katex") && document.querySelector(".mermaid-wrapper svg") && document.querySelector(".hljs-keyword"))'), 'actual website highlighting, KaTeX and Mermaid DOM');
    const markdownState = await evaluate(`({ Heading: document.querySelector('h1').textContent, TableRows: document.querySelectorAll('table tr').length,
        Highlighted: document.querySelectorAll('code .hljs-keyword').length, CodeTools: document.querySelectorAll('[data-code-action]').length,
        Math: document.querySelectorAll('.katex').length, Diagrams: document.querySelectorAll('.mermaid-wrapper svg').length,
        ScriptRan: globalThis.MarkdownScriptRan === true, EventRan: globalThis.MarkdownEventRan === true,
        Width: document.querySelector('main').getBoundingClientRect().width, ScrollWidth: document.documentElement.scrollWidth,
        ViewWidth: innerWidth })`);
    assert.equal(markdownState.Heading, 'XFESystem 文档');
    assert.equal(markdownState.TableRows, 2);
    assert.ok(markdownState.Highlighted > 0 && markdownState.CodeTools > 0 && markdownState.Math > 0 && markdownState.Diagrams > 0);
    assert.equal(markdownState.ScriptRan, false);
    assert.equal(markdownState.EventRan, false);
    assert.ok(markdownState.Width > 100);
    assert.ok(markdownState.ScrollWidth <= markdownState.ViewWidth + 1);
    result.Checks.push({ Name: 'MarkdownWebsiteDom', Passed: true, State: markdownState });
    await screenshot('Markdown');

    await command('Emulation.setDeviceMetricsOverride', { width: 375, height: 900, deviceScaleFactor: 1, mobile: false });
    await navigate('Markdown');
    await until(async () => await evaluate('Boolean(document.querySelector(".mermaid-wrapper svg"))'), 'narrow-screen Mermaid render');
    const narrowState = await evaluate(`({ ViewWidth: innerWidth, ScrollWidth: document.documentElement.scrollWidth,
        ButtonText: [...document.querySelectorAll('[data-tool-label]')].map(b=>b.dataset.toolLabel) })`);
    assert.equal(narrowState.ViewWidth, 375);
    assert.ok(narrowState.ScrollWidth <= narrowState.ViewWidth + 1);
    assert.ok(narrowState.ButtonText.includes('复制') && narrowState.ButtonText.includes('缩小'));
    result.Checks.push({ Name: 'NarrowMarkdownWebsiteDom', Passed: true, State: narrowState });
    await screenshot('MarkdownNarrow');
    await command('Emulation.clearDeviceMetricsOverride');

    await navigate('Code');
    await until(async () => await evaluate('document.querySelectorAll("code .hljs-keyword").length > 0'), 'CSharp token coloring');
    const codeState = await evaluate(`({ Content: document.querySelector('code').textContent,
        KeywordTokens: document.querySelectorAll('code .hljs-keyword').length, StringTokens: document.querySelectorAll('code .hljs-string').length })`);
    assert.ok(codeState.KeywordTokens > 0 && codeState.StringTokens > 0);
    assert.ok(codeState.Content.includes('Sample'));
    result.Checks.push({ Name: 'CodeWebsiteDom', Passed: true, State: codeState });
    await screenshot('Code');

    await navigate('JavaScript');
    const iframeTarget = await until(async () => (await command('Target.getTargets')).targetInfos.find(item =>
        item.type === 'iframe' && item.url === 'about:srcdoc'), 'sandboxed JavaScript out-of-process iframe');
    const { sessionId: iframeSession } = await command('Target.attachToTarget', { targetId: iframeTarget.targetId, flatten: true });
    await command('Runtime.enable', {}, iframeSession);
    const contextId = await until(() => [...contexts.values()].find(item => item.sessionId === iframeSession && item.auxData?.isDefault)?.id,
        'isolated iframe execution context');
    await until(async () => (await evaluate('document.getElementById("Output").textContent', contextId, iframeSession)).includes('Network blocked'), 'JavaScript execution and blocked network');
    const jsState = await evaluate(`({ Output: document.getElementById('Output').textContent, Executed: document.body.dataset.Executed,
        ParentAccessible: (() => { try { return Boolean(parent.document); } catch { return false; } })() })`, contextId, iframeSession);
    assert.ok(jsState.Output.includes('Result 42'));
    assert.ok(jsState.Output.includes('Host bridge undefined'));
    assert.equal(jsState.Executed, 'yes');
    assert.equal(jsState.ParentAccessible, false);
    assert.equal(rejectedRequests.length, 0, JSON.stringify(rejectedRequests));
    result.Checks.push({ Name: 'JavaScriptBrowserHostOnly', Passed: true, State: jsState,
        OutOfProcessIframe: true, BudgetEnforcementTested: false,
        Note: 'Safe fixture only; process/timeout/memory requirements are not claimed as an OS renderer implementation.' });
    await screenshot('JavaScript');
    if (interceptionFailure) throw interceptionFailure;
    assert.equal(scriptExceptions.length, 0, JSON.stringify(scriptExceptions));
    const unexpectedWarnings = browserWarnings.filter(entry => entry.level === 'error' &&
        !entry.text.includes('https://outside.invalid/Blocked.png') &&
        !entry.text.includes('https://outside.invalid/Blocked'));
    assert.equal(unexpectedWarnings.length, 0, JSON.stringify(unexpectedWarnings));
    result.Passed = true;
} catch (error) {
    result.Passed = false;
    result.Error = error.stack;
    process.exitCode = 1;
} finally {
    if (socket?.readyState === WebSocket.OPEN) {
        try { await command('Browser.close'); } catch { /* Edge can close the CDP socket before responding. */ }
        socket.close();
    }
    if (browser.exitCode === null) {
        await Promise.race([browserExit, delay(5000)]);
        if (browser.exitCode === null) browser.kill();
    }
    result.BrowserExited = await Promise.race([browserExit, delay(3000).then(() => null)]);
    for (const request of pending.values()) request.reject(new Error('Browser test ended.'));
    pending.clear();
    fs.writeFileSync(path.join(output, 'Result.json'), JSON.stringify(result, null, 2));
    console.log(JSON.stringify({ Passed: result.Passed, Checks: result.Checks.length, Result: path.join(output, 'Result.json'), Error: result.Error }, null, 2));
}