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 { createHash } from 'node:crypto';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

// Reference only: a fresh Chromium profile, exact source files, no running user
// browser, website database, website document or QEMU instance is modified.
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const website = process.env.XFE_WEBSITE_ROOT || path.resolve(root, '../XFEStudioWebSite');
const codeReference = process.argv.includes('--code-reference');
const outputBase = path.join(root, 'build/CSharpDocuments');
fs.mkdirSync(outputBase, { recursive: true });
const output = fs.mkdtempSync(path.join(outputBase, 'WebsiteReference-'));
const fixtures = path.join(output, 'Fixtures');
const artifacts = path.join(output, 'Artifacts');
const sha = value => createHash('sha256').update(value).digest('hex');
const exported = spawnSync('dotnet', ['run', '--project', path.join(root, 'Tests/XFESystem.Documents.Tests/WebsiteReferenceHost/WebsiteReferenceHost.csproj'),
  '-c', 'Release', '--artifacts-path', artifacts, '--', fixtures, ...(codeReference ? ['--code-reference'] : [])], { cwd: root, encoding: 'utf8', windowsHide: true, timeout: 60000 });
fs.writeFileSync(path.join(output, 'Export.log'), exported.stdout + exported.stderr);
assert.equal(exported.status, 0, exported.stdout + exported.stderr);
const manifest = JSON.parse(fs.readFileSync(path.join(fixtures, 'SourceManifest.json'), 'utf8'));
const sources = new Map(manifest.Entries.filter(e => e.UrlPath).map(e => [e.UrlPath, e]));
const verified = [];
for (const entry of manifest.Entries) {
  const source = path.join(website, entry.SourcePath);
  assert.equal(sha(fs.readFileSync(source)), entry.SourceSha256, 'Website source changed: ' + entry.SourcePath);
  assert.equal(entry.SourceSha256, entry.SnapshotSha256, 'Snapshot transformed: ' + entry.SourcePath);
  verified.push({ SourcePath: entry.SourcePath, Sha256: entry.SourceSha256 });
}
// The real site's _Layout points to this fixed CDN/version; its icon font is
// absent from the website repository. Cache it for the reference only and
// retain its URL/hash, without changing the exact CSS or the native assets.
const supplements = new Map(), supplementReceipts = [];
for (const extension of ['woff2', 'woff']) {
  const name = 'bootstrap-icons.' + extension;
  const sourceUrl = 'https://lib.baomitu.com/bootstrap-icons/1.11.3/font/fonts/' + name;
  try {
    const response = await fetch(sourceUrl, { signal: AbortSignal.timeout(10000) });
    assert.equal(response.status, 200, sourceUrl);
    const bytes = Buffer.from(await response.arrayBuffer());
    const cached = path.join(output, name);
    fs.writeFileSync(cached, bytes);
    supplements.set('/css/fonts/' + name, { bytes, mime: 'font/' + extension, source: sourceUrl });
    supplementReceipts.push({ SourceUrl: sourceUrl, CachedPath: cached, Sha256: sha(bytes), Bytes: bytes.length,
      License: 'MIT; Bootstrap Icons 1.11.3; original CSS license header retained' });
  } catch (error) { supplementReceipts.push({ SourceUrl: sourceUrl, Error: String(error) }); }
}

const executable = process.env.XFE_DOCUMENT_BROWSER || 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
assert.ok(fs.existsSync(executable));
const options = {};
for (let index = 2; index < process.argv.length; index++) {
  const name = process.argv[index];
  if (name === '--code-reference') continue;
  assert.ok(['--width', '--height', '--device-scale'].includes(name) && process.argv[index + 1], 'Use --width N --height N --device-scale N.');
  options[name] = Number(process.argv[++index]);
}
const width = options['--width'] || Number(process.env.XFE_REFERENCE_WIDTH || 1100), height = options['--height'] || Number(process.env.XFE_REFERENCE_HEIGHT || 900);
const deviceScale = options['--device-scale'] || Number(process.env.XFE_REFERENCE_DEVICE_SCALE || 1);
assert.ok(width >= 320 && width <= 3840 && height >= 240 && height <= 2160 && deviceScale >= 1 && deviceScale <= 3);
const profile = path.join(output, 'Profile');
const errorLog = 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=${width},${height}`, `--user-data-dir=${profile}`, 'about:blank'], { windowsHide: true, stdio: ['ignore', 'ignore', errorLog] });
fs.closeSync(errorLog);
const exited = new Promise(resolve => browser.once('exit', (code, signal) => resolve({ code, signal })));
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function until(condition, name, timeout = 30000) {
  const start = Date.now();
  while (Date.now() - start < timeout) { const result = await condition(); if (result) return result; await pause(50); }
  throw new Error('Timed out: ' + name);
}
let socket, nextId = 0, failure;
const pending = new Map(), requests = [], exceptions = [], warnings = [], states = [], screenshots = [], consoleOutput = [];
const result = { Kind: 'ChromiumWebsiteArticleReference', GuestAcceptance: false, FullMvcSiteStarted: false,
  HtmlOrigin: 'http://xfesystem.local', WebsiteRoot: website, SourceCommit: manifest.SourceCommit,
  WebsiteManifestSha256: sha(fs.readFileSync(path.join(fixtures, 'SourceManifest.json'))),
  FixtureSha256: sha(fs.readFileSync(path.join(fixtures, 'Fixture.md'))), SourceChecks: verified,
  Width: width, Height: height, DeviceScaleFactor: deviceScale, Requests: requests, ScriptExceptions: exceptions, BrowserWarnings: warnings,
  States: states, Screenshots: screenshots, BrowserConsole: consoleOutput, ReferenceSupplementalFonts: supplementReceipts, Output: output };
if (codeReference) {
  result.Component = 'WebsiteStaticCSharpCodeBlock';
  result.ProductionSample = JSON.parse(fs.readFileSync(path.join(fixtures, 'CSharpSample.json'), 'utf8'));
  result.FixtureSha256 = sha(fs.readFileSync(path.join(fixtures, 'CSharpSample.cs')));
  assert.equal(result.FixtureSha256, result.ProductionSample.CodeSha256);
}
const command = (method, params = {}) => new Promise((resolve, reject) => {
  const id = ++nextId;
  const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 30000);
  pending.set(id, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
  socket.send(JSON.stringify({ id, method, params }));
});
async function evaluate(expression) {
  const response = await command('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
  if (response.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description || response.exceptionDetails.text);
  return response.result.value;
}
async function serve(request) {
  const url = new URL(request.request.url);
  let bytes, mime = 'application/octet-stream', sourcePath;
  if (url.origin === 'http://xfesystem.local') {
    if (['/Dark.html', '/Light.html', '/CodeDark.html', '/CodeLight.html'].includes(url.pathname)) {
      bytes = fs.readFileSync(path.join(fixtures, url.pathname.slice(1))); mime = 'text/html; charset=utf-8';
    } else if (url.pathname === '/favicon.ico') {
      await command('Fetch.fulfillRequest', { requestId: request.requestId, responseCode: 204 }); return;
    } else if (sources.has(url.pathname)) {
      const entry = sources.get(url.pathname); sourcePath = entry.SourcePath;
      bytes = fs.readFileSync(path.join(website, entry.SourcePath));
      mime = ({ '.js': 'application/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf' })[path.extname(url.pathname)] || mime;
    } else if (supplements.has(url.pathname)) {
      const supplement = supplements.get(url.pathname); bytes = supplement.bytes; mime = supplement.mime; sourcePath = supplement.source;
    }
  }
  requests.push({ Url: url.href, SourcePath: sourcePath, Sha256: bytes && sha(bytes), Bytes: bytes?.length, Status: bytes ? 200 : 404 });
  await command('Fetch.fulfillRequest', { requestId: request.requestId, responseCode: bytes ? 200 : 404,
    responseHeaders: [{ name: 'Content-Type', value: mime }, { name: 'X-Content-Type-Options', value: 'nosniff' }],
    body: (bytes || Buffer.from('Not present in website source snapshot.')).toString('base64') });
}
const metrics = `(() => {
  const bounds = element => { if(!element)return null;const r=element.getBoundingClientRect(),s=getComputedStyle(element);return {
    Tag:element.tagName,Class:element.className,X:r.x,Y:r.y,Width:r.width,Height:r.height,Font:s.fontFamily,FontSize:s.fontSize,
    LineHeight:s.lineHeight,Color:s.color,Background:s.backgroundColor,Border:s.borderColor,Padding:s.padding,Margin:s.margin}; };
  const code=document.querySelector('pre.code-block-enhanced'),body=document.querySelector('.markdown-body');
  const highlighted=document.querySelector('.language-csharp');
  const token = element => {const scopes=[];let ancestor=element;
    while(ancestor&&ancestor!==highlighted){scopes.unshift(...[...ancestor.classList].filter(name=>name.startsWith('hljs-')||name.endsWith('_')));ancestor=ancestor.parentElement;}
    const range=document.createRange();range.setStart(highlighted,0);range.setEndBefore(element);const start=range.toString().length,style=getComputedStyle(element);
    return {Class:element.className,Scopes:scopes,Text:element.textContent,Utf16Start:start,Utf16End:start+element.textContent.length,
      Color:style.color,Background:style.backgroundColor,FontStyle:style.fontStyle,FontWeight:style.fontWeight};};
  return {Theme:document.documentElement.getAttribute('data-bs-theme'),Viewport:{Width:innerWidth,Height:innerHeight},
    Article:bounds(body),Code:bounds(code),Heading:bounds(document.querySelector('h1')),Quote:bounds(document.querySelector('blockquote')),
    Table:bounds(document.querySelector('table')),CodeText:highlighted.textContent,Tokens:[...document.querySelectorAll('.language-csharp span[class]')].map(token),
    CodeTools:document.querySelectorAll('[data-code-action]').length,Lines:document.querySelectorAll('.code-line-numbers>span').length,
    Math:document.querySelectorAll('.katex').length,Diagrams:document.querySelectorAll('.mermaid-wrapper svg').length,
    Css:[...document.styleSheets].map(s=>({Href:s.href,Rules:s.cssRules.length})),Scripts:[...document.scripts].map(s=>s.src),
    DocumentHeight:document.documentElement.scrollHeight};
})()`;
try {
  await until(() => fs.existsSync(path.join(profile, 'DevToolsActivePort')), 'isolated browser port');
  const [port] = fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').trim().split(/\r?\n/);
  result.BrowserVersion = (await (await fetch(`http://127.0.0.1:${port}/json/version`)).json()).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 task = pending.get(message.id); if (!task) return; pending.delete(message.id); message.error ? task.reject(new Error(message.error.message)) : task.resolve(message.result); }
    else if (message.method === 'Fetch.requestPaused') serve(message.params).catch(error => { failure = error; });
    else if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails);
    else if (message.method === 'Log.entryAdded') warnings.push(message.params.entry);
    else if (message.method === 'Runtime.consoleAPICalled') consoleOutput.push(message.params);
  });
  await command('Page.enable'); await command('Runtime.enable'); await command('Log.enable');
  await command('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: deviceScale, mobile: false });
  await command('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
  for (const theme of ['Dark', 'Light']) {
    const prefix = codeReference ? 'Code' : '';
    await command('Page.navigate', { url: `http://xfesystem.local/${prefix}${theme}.html` });
    const ready = `document.readyState==='complete'&&document.querySelectorAll('.language-csharp .hljs-keyword').length>0` +
      (codeReference ? `&&document.querySelectorAll('[data-code-action]').length===4` : `&&document.querySelectorAll('.katex').length===2&&document.querySelectorAll('.mermaid-wrapper svg').length===1`);
    await until(async () => await evaluate(ready), theme + ' real website initializers');
    await evaluate('document.fonts.ready.then(()=>true)');
    const state = await evaluate(metrics);
    assert.equal(state.CodeTools, 4); assert.ok(state.Lines > 8);
    if (!codeReference) { assert.equal(state.Math, 2); assert.equal(state.Diagrams, 1); }
    else assert.equal(sha(Buffer.from(state.CodeText, 'utf8')), result.ProductionSample.CodeSha256);
    state.OriginalCssOrder = state.Css.map(sheet => { const entry=sources.get(new URL(sheet.Href).pathname);return { UrlPath:entry.UrlPath,SourceSha256:entry.SourceSha256 }; });
    state.OriginalCssOrderSha256 = sha(Buffer.from(JSON.stringify(state.OriginalCssOrder), 'utf8'));
    state.OriginalScriptOrder = state.Scripts.map(url => { const entry=sources.get(new URL(url).pathname);return { UrlPath:entry.UrlPath,SourceSha256:entry.SourceSha256 }; });
    state.OriginalScriptOrderSha256 = sha(Buffer.from(JSON.stringify(state.OriginalScriptOrder), 'utf8'));
    states.push(state);
    fs.writeFileSync(path.join(output, theme + 'Dom.html'), await evaluate('document.documentElement.outerHTML'));
    const viewport = await command('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
    fs.writeFileSync(path.join(output, theme + '.png'), Buffer.from(viewport.data, 'base64'));
    const screenshotPath = path.join(output, theme + '.png');
    const screenshotReceipt = { Kind: 'WebsiteBrowserReferenceScreenshot', ScreenshotPath: screenshotPath,
      ScreenshotSha256: sha(fs.readFileSync(screenshotPath)), Theme: theme.toLowerCase(),
      ViewportCss: { Width: width, Height: height, DeviceScaleFactor: deviceScale },
      WebsiteSourceManifestSha256: result.WebsiteManifestSha256, FixtureSha256: result.FixtureSha256,
      Component: codeReference ? 'WebsiteStaticCSharpCodeBlock' : 'WebsiteMarkdownArticle',
      OriginalCssOrderSha256: state.OriginalCssOrderSha256, OriginalScriptOrderSha256: state.OriginalScriptOrderSha256,
      ProductionSample: result.ProductionSample };
    screenshots.push(screenshotReceipt);
    fs.writeFileSync(path.join(output, theme + 'Screenshot.json'), JSON.stringify(screenshotReceipt, null, 2));
    const full = await command('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true,
      clip: { x: 0, y: 0, width, height: Math.min(16000, state.DocumentHeight), scale: 1 } });
    fs.writeFileSync(path.join(output, theme + 'Full.png'), Buffer.from(full.data, 'base64'));
    const lineToggle = await evaluate(`(() => {const p=document.querySelector('pre.code-block-enhanced'),b=p.querySelector('[data-code-action=lines]'),before=p.classList.contains('code-lines-visible');b.click();const after=p.classList.contains('code-lines-visible');b.click();return before&&!after&&p.classList.contains('code-lines-visible');})()`);
    assert.equal(lineToggle, true); state.LineToggleWorked = true;
    if (failure) throw failure;
  }
  assert.equal(exceptions.length, 0, JSON.stringify(exceptions));
  result.Passed = true;
  result.KnownMissingWebsiteResources = requests.filter(r => r.Status === 404);
} catch (error) { result.Passed = false; result.Error = error.stack; process.exitCode = 1; }
finally {
  if (socket?.readyState === WebSocket.OPEN) { try { await command('Browser.close'); } catch {} socket.close(); }
  if (browser.exitCode === null) { await Promise.race([exited, pause(5000)]); if (browser.exitCode === null) browser.kill(); }
  result.BrowserExited = await Promise.race([exited, pause(3000).then(() => null)]);
  for (const task of pending.values()) task.reject(new Error('Reference capture finished.'));
  pending.clear();
  fs.writeFileSync(path.join(output, 'Result.json'), JSON.stringify(result, null, 2));
  console.log(JSON.stringify({ Passed: result.Passed, Result: path.join(output, 'Result.json'), Error: result.Error }, null, 2));
}