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
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[submodule "packages/react/preact-upstream-tests/preact"]
path = packages/react/preact-upstream-tests/preact
url = https://github.com/hzy/preact.git
branch = lynx/v10.24.x
Comment on lines +3 to +4
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use an organization-controlled submodule remote.

A personal-namespace GitHub URL increases availability/supply-chain risk for a critical test dependency. Please point this submodule at an org-owned canonical mirror (while keeping the same tracked branch/commit behavior).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitmodules around lines 3 - 4, Update the submodule remote URL in
.gitmodules to point at an organization-controlled canonical mirror instead of
the personal repo (replace the url = https://github.com/hzy/preact.git value
with the org mirror URL), while leaving the branch = lynx/v10.24.x entry
untouched so tracked branch/commit behavior is preserved; after changing the url
entry, run git submodule sync && git submodule update --init --recursive to
ensure the working tree uses the new org-controlled remote.

4 changes: 4 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ extend-exclude = [
"packages/web-platform/web-tests/tests/react.spec.ts",
"packages/web-platform/web-tests/tests/react/basic-element-x-overlay-ng-playground-test/index.jsx",
"packages/web-platform/offscreen-document/src/webworker/OffscreenCSSStyleDeclaration.ts",
# Upstream Preact submodule — do not spell-check third-party content
"packages/react/preact-upstream-tests/preact/**",
]

[default]
Expand All @@ -31,3 +33,5 @@ nd = "nd"
bui = "bui"
ba = "ba"
alog = "alog"
# "apppend" is an intentional typo in an upstream Preact test name that we mirror in our skiplist
apppend = "apppend"
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export default tseslint.config(
// testing-library
'packages/testing-library/**',
'packages/react/testing-library/**',
'packages/react/preact-upstream-tests/**',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Narrow the ignore scope to only vendored upstream code.

Ignoring packages/react/preact-upstream-tests/** disables linting for this package’s first-party harness/config code as well. Please ignore only the submodule path so local JS/TS still follows repo lint policy.

Suggested diff
-      'packages/react/preact-upstream-tests/**',
+      'packages/react/preact-upstream-tests/preact/**',

As per coding guidelines: **/*.{ts,tsx,js,jsx} must follow ESLint rules as configured in eslint.config.js.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'packages/react/preact-upstream-tests/**',
'packages/react/preact-upstream-tests/preact/**',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eslint.config.js` at line 101, The ignore entry
'packages/react/preact-upstream-tests/**' is too broad and skips first‑party
harness/config code; update eslint.config.js to only ignore the vendored
upstream submodule directory inside that package (replace
'packages/react/preact-upstream-tests/**' with a pattern that targets only the
vendored path, e.g.
'packages/react/preact-upstream-tests/<vendored-submodule-path>/**'), so local
files under packages/react/preact-upstream-tests/**/*.{ts,tsx,js,jsx} remain
linted.


// gesture-runtime-testing
'packages/lynx/gesture-runtime/__test__/**',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
applyTo: "{skiplist.json,vitest.shared.ts,README.md,package.json}"
---

When updating skiplist categories, always run each selected group in both projects (`preact-upstream` and `preact-upstream-compiled`) with `SKIPLIST_ONLY=<category>:<index>` before moving entries.
Keep mode orthogonality explicit: shared failures stay in `skip_list`/`permanent_skip_list`, no-compile-only failures go to `nocompile_skip_list`, compiled-only failures go to `compiler_skip_list`.
Because skip matching is title-based, check for duplicate test titles across files before removing an entry; a single title may map to multiple test cases with different outcomes.
In README positioning and run-order guidance, treat compiled mode as the primary product-path confidence signal and describe no-compile mode as a runtime baseline/regression-isolation tool.
489 changes: 489 additions & 0 deletions packages/react/preact-upstream-tests/README.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions packages/react/preact-upstream-tests/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@lynx-js/preact-upstream-tests",
"version": "0.0.0",
"private": true,
"scripts": {
"preact:init": "cd ../../.. && git submodule update --init packages/react/preact-upstream-tests/preact",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this package is picked up by the root Vitest project globs, introducing a mandatory manual preact:init step here also makes pnpm test depend on local submodule state. On a fresh checkout this will fail before the reviewer even gets to this package. Could we either keep this suite opt-in at the root level or make the submodule bootstrap automatic/self-checking?

"preact:status": "cd ../../.. && git submodule status packages/react/preact-upstream-tests/preact",
"preact:update": "cd ../../.. && git submodule update --remote packages/react/preact-upstream-tests/preact",
"test": "vitest run --workspace vitest.workspace.ts",
"test:compiled": "vitest run --workspace vitest.workspace.ts --project preact-upstream-compiled",
"test:no-compile": "vitest run --workspace vitest.workspace.ts --project preact-upstream",
"test:report": "node report.mjs",
"test:skipped": "SKIPLIST_ONLY=skip_list,nocompile_skip_list vitest run --workspace vitest.workspace.ts --project preact-upstream",
"test:skipped:compiled": "SKIPLIST_ONLY=skip_list,compiler_skip_list vitest run --workspace vitest.workspace.ts --project preact-upstream-compiled"
},
"devDependencies": {
"@lynx-js/react": "workspace:*",
"@lynx-js/testing-environment": "workspace:*",
"chai": "^5.2.0",
"jsdom": "^26.1.0",
"sinon": "^19.0.2",
"sinon-chai": "^4.0.0",
"vitest": "^3.2.4"
}
}
1 change: 1 addition & 0 deletions packages/react/preact-upstream-tests/preact
Submodule preact added at 4af811
267 changes: 267 additions & 0 deletions packages/react/preact-upstream-tests/report.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
#!/usr/bin/env node
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

/**
* report.mjs — pass/skip dashboard for preact-upstream-tests.
*
* Runs both vitest projects, parses per-file summaries, and prints a grouped
* coverage table. Progress goes to stderr; the table goes to stdout so it can
* be piped / redirected freely.
*
* Usage:
* pnpm test:report
* pnpm test:report > report.txt
*/

import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// ── Groupings (order matters — first match wins) ──────────────────────────────
const GROUPS = [
{
label: 'Core Reconciliation',
match: f => /\/(?:render|components|fragments|keys)\.test\.js$/.test(f),
},
{
label: 'Lifecycle Methods',
match: f => f.includes('/lifecycles/'),
},
{
label: 'Hooks',
match: f => f.includes('/hooks/test/browser/'),
},
{
label: 'API & Utilities',
match: () => true, // catch-all
},
];

// Files excluded from the vitest run (not counted in dashboard totals)
const EXCLUDED = [
{
file: 'getDomSibling.test.js',
tests: 18,
category: 'test_methodology',
reason: 'Preact internals: _children VNode attachment',
},
{ file: 'refs.test.js', tests: 26, category: 'dual_thread', reason: 'BSI refs ≠ DOM nodes (deferred)' },
{
file: 'replaceNode.test.js',
tests: 11,
category: 'test_methodology',
reason: 'SSR replaceNode / pre-populated DOM',
},
];

// Skiplist (with category tags) — used for attribution
const skiplist = JSON.parse(readFileSync(path.resolve(__dirname, 'skiplist.json'), 'utf-8'));

const CATEGORY_LABELS = {
lynx_not_web: 'Lynx ≠ Web',
dual_thread: 'Dual-thread / IPC',
test_methodology: 'Test methodology',
};

// ── Run one vitest project and return per-file stats ──────────────────────────
function runProject(project) {
process.stderr.write(` ${project.padEnd(32)} …`);
const r = spawnSync(
'npx',
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using npx vitest can accidentally run a different Vitest version than the workspace-installed one (especially in monorepos and CI), which can change output formatting and break the parser. Prefer invoking the repo’s/package’s local Vitest binary via the package manager (pnpm vitest …) or by resolving the local executable path to guarantee consistent behavior.

Suggested change
'npx',
'pnpm',

Copilot uses AI. Check for mistakes.
['vitest', 'run', '--workspace', 'vitest.workspace.ts', '--project', project],
{ cwd: __dirname, encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 },
);
const output = (r.stdout ?? '') + (r.stderr ?? '');
const stats = parse(output);
const total = Object.values(stats).reduce((a, v) => a + v.total, 0);
const pass = Object.values(stats).reduce((a, v) => a + v.pass, 0);
process.stderr.write(` done (${pass}/${total} pass)\n`);
return stats;
}
Comment on lines +74 to +85
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast when a Vitest project exits non-zero.

The current flow always parses output and continues, so a failed run can still generate a misleading dashboard.

Proposed fix
 function runProject(project) {
   process.stderr.write(`  ${project.padEnd(32)} …`);
   const r = spawnSync(
     'npx',
     ['vitest', 'run', '--workspace', 'vitest.workspace.ts', '--project', project],
     { cwd: __dirname, encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 },
   );
+  if (r.status !== 0) {
+    throw new Error(
+      `Vitest project "${project}" failed (exit ${r.status ?? 'unknown'}).\n`
+      + `${r.stdout ?? ''}\n${r.stderr ?? ''}`,
+    );
+  }
   const output = (r.stdout ?? '') + (r.stderr ?? '');
   const stats = parse(output);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/preact-upstream-tests/report.mjs` around lines 74 - 85, Check
the spawnSync result (variable r) for failures before parsing: if r.error is set
or r.status is non-zero, write the combined output to stderr (using output) and
fail fast by calling process.exit(r.status || 1) or throwing an error; move this
check to immediately after computing r/output and before calling parse(output)
and computing stats/total/pass so a failing Vitest run doesn’t produce
misleading dashboard results.


// ── Parse vitest verbose output into { file → {total, pass, skip} } ──────────
function parse(raw) {
// Strip ANSI colour codes first
// eslint-disable-next-line no-control-regex
const clean = raw.replace(/\u001b\[[0-9;]*m/g, '');

// Matches lines like:
// ✓ |project| preact/…/file.test.js (12 tests | 4 skipped) 30ms
// ↓ |project| preact/…/file.test.js (11 tests | 11 skipped)
// ✓ |project| preact/…/file.test.js (1 test) 6ms
const re = /\|[^|]+\|\s+(preact[^\s(]+\.test\.js)\s+\((\d+) tests?(?:\s*\|\s*(\d+) skipped)?\)/g;
const stats = {};
for (const m of clean.matchAll(re)) {
const file = m[1].trim();
const total = +m[2];
const skip = m[3] ? +m[3] : 0;
stats[file] = { total, skip, pass: total - skip };
}
return stats;
}

// ── Formatting helpers ────────────────────────────────────────────────────────
const groupOf = f => GROUPS.find(g => g.match(f));
const sum = (obj, k) => Object.values(obj).reduce((a, v) => a + v[k], 0);
const pct = (n, d) => d ? `${Math.round(n * 100 / d)}%` : '—';
const lp = (s, n) => String(s).padStart(n);
const rp = (s, n) => String(s).padEnd(n);
const shortName = f =>
f
.replace('preact/hooks/test/browser/', 'hooks/')
.replace('preact/test/browser/lifecycles/', 'lifecycles/')
.replace('preact/test/browser/', '');

const out = (...a) => process.stdout.write(a.join('') + '\n');

// ── Collect data ──────────────────────────────────────────────────────────────
process.stderr.write('\nCollecting results:\n');
const nc = runProject('preact-upstream');
const co = runProject('preact-upstream-compiled');
const files = [...new Set([...Object.keys(nc), ...Object.keys(co)])].sort();
process.stderr.write('\n');

// ── Overall totals ────────────────────────────────────────────────────────────
const ncT = sum(nc, 'total'), ncP = sum(nc, 'pass'), ncS = sum(nc, 'skip');
const coT = sum(co, 'total'), coP = sum(co, 'pass'), coS = sum(co, 'skip');
const exTests = EXCLUDED.reduce((a, e) => a + e.tests, 0);

const LINE = '─'.repeat(78);
out(LINE);
out(' Preact Upstream Tests — Coverage Dashboard');
out(LINE);
out(` Excluded (not counted): ${EXCLUDED.map(e => `${e.file} (${e.tests})`).join(', ')} = ${exTests}×2 test cases`);
out();

out('OVERALL');
out(` ${''.padEnd(22)} ${'no-compile'.padEnd(20)} compiled`);
out(` ${rp('Total', 22)} ${lp(ncT, 20)} ${coT}`);
out(` ${rp('Pass', 22)} ${rp(`${ncP} (${pct(ncP, ncT)})`, 20)} ${coP} (${pct(coP, coT)})`);
out(` ${rp('Skip', 22)} ${rp(`${ncS} (${pct(ncS, ncT)})`, 20)} ${coS} (${pct(coS, coT)})`);
out();

// ── By group ──────────────────────────────────────────────────────────────────
out(` ${rp('GROUP', 22)} ${rp('total', 6)} ${rp('no-compile pass/skip/%', 26)} compiled pass/skip/%`);
out(' ' + '─'.repeat(74));

for (const { label } of GROUPS) {
const gf = files.filter(f => groupOf(f)?.label === label);
if (gf.length === 0) continue;

const gt = gf.reduce((a, f) => a + (nc[f]?.total ?? co[f]?.total ?? 0), 0);
const ncp = gf.reduce((a, f) => a + (nc[f]?.pass ?? 0), 0);
const ncs = gf.reduce((a, f) => a + (nc[f]?.skip ?? 0), 0);
const cop = gf.reduce((a, f) => a + (co[f]?.pass ?? 0), 0);
const cos = gf.reduce((a, f) => a + (co[f]?.skip ?? 0), 0);

out(
` ${rp(label, 22)} ${lp(gt, 6)} `
+ `${rp(`${ncp}/${ncs} (${pct(ncp, gt)})`, 26)} `
+ `${cop}/${cos} (${pct(cop, gt)})`,
);
}
out();

// ── Per-file detail ───────────────────────────────────────────────────────────
out('PER FILE (pass/skip/%)');
out(' ' + '─'.repeat(74));

for (const { label } of GROUPS) {
const gf = files.filter(f => groupOf(f)?.label === label).sort();
if (gf.length === 0) continue;
out(`\n ── ${label}`);
for (const file of gf) {
const ns = nc[file] ?? { pass: 0, skip: 0, total: 0 };
const cs = co[file] ?? { pass: 0, skip: 0, total: 0 };
const tot = ns.total || cs.total;
const ncStr = `${lp(ns.pass, 3)}/${lp(ns.skip, 3)} ${lp(pct(ns.pass, tot), 4)}`;
const coStr = `${lp(cs.pass, 3)}/${lp(cs.skip, 3)} ${lp(pct(cs.pass, tot), 4)}`;
out(` ${rp(shortName(file), 42)} nc: ${ncStr} co: ${coStr}`);
}
}
out();

// ── Excluded files ────────────────────────────────────────────────────────────
out('EXCLUDED FILES (vitest exclude — not reflected above)');
out(' ' + '─'.repeat(74));
for (const { file, tests, category, reason } of EXCLUDED) {
out(` ${rp(file, 32)} ${lp(tests, 2)} tests/mode [${CATEGORY_LABELS[category]}] ${reason}`);
}
out(`\n Total: ${exTests} tests × 2 modes = ${exTests * 2} test cases`);
out();

// ── Skip attribution ──────────────────────────────────────────────────────────
// For named entries: count test names × mode factor (1 per mode the list applies to).
// For unsupported_features: all lynx_not_web, inferred as actual_skip − named_skip.
// Excluded files are added separately using their own category tags.

function countByCat(entries, factor = 1) {
const acc = {};
for (const entry of (entries ?? [])) {
const cat = entry.category;
acc[cat] = (acc[cat] ?? 0) + entry.tests.length * factor;
}
return acc;
}

function mergeCounts(...maps) {
const out = {};
for (const m of maps) for (const [k, v] of Object.entries(m)) out[k] = (out[k] ?? 0) + v;
return out;
}

// Named skips per mode (each test name counted once per mode the list applies to)
const ncNamedCats = mergeCounts(
countByCat(skiplist.skip_list),
countByCat(skiplist.permanent_skip_list),
countByCat(skiplist.nocompile_skip_list),
);
const coNamedCats = mergeCounts(
countByCat(skiplist.skip_list),
countByCat(skiplist.permanent_skip_list),
countByCat(skiplist.compiler_skip_list),
);

// unsupported_features contribution = actual skip − named skips (all lynx_not_web)
const ncNamedTotal = Object.values(ncNamedCats).reduce((a, v) => a + v, 0);
const coNamedTotal = Object.values(coNamedCats).reduce((a, v) => a + v, 0);
ncNamedCats.lynx_not_web = (ncNamedCats.lynx_not_web ?? 0) + (ncS - ncNamedTotal);
coNamedCats.lynx_not_web = (coNamedCats.lynx_not_web ?? 0) + (coS - coNamedTotal);

// Add excluded files
for (const { tests, category } of EXCLUDED) {
ncNamedCats[category] = (ncNamedCats[category] ?? 0) + tests;
coNamedCats[category] = (coNamedCats[category] ?? 0) + tests;
}

const ncGrand = ncS + exTests;
const coGrand = coS + exTests;

out('SKIP ATTRIBUTION (skipped + excluded, per mode)');
out(` Note: unsupported_features counts inferred (actual skip − named skip), all Lynx ≠ Web`);
out(' ' + '─'.repeat(74));
out(` ${rp('Category', 22)} ${rp('no-compile', 16)} ${rp('compiled', 16)} combined`);
out(' ' + '─'.repeat(74));

for (const [cat, label] of Object.entries(CATEGORY_LABELS)) {
const nc_ = ncNamedCats[cat] ?? 0;
const co_ = coNamedCats[cat] ?? 0;
const total_ = nc_ + co_;
out(
` ${rp(label, 22)} `
+ `${rp(`${nc_} (${pct(nc_, ncGrand)})`, 16)} `
+ `${rp(`${co_} (${pct(co_, coGrand)})`, 16)} `
+ `${total_} (${pct(total_, ncGrand + coGrand)})`,
);
}

out(' ' + '─'.repeat(74));
out(` ${rp('Total not running', 22)} ${rp(`${ncGrand}`, 16)} ${rp(`${coGrand}`, 16)} ${ncGrand + coGrand}`);
out(` ${rp(' of which: skipped', 22)} ${rp(`${ncS}`, 16)} ${rp(`${coS}`, 16)} ${ncS + coS}`);
out(` ${rp(' of which: excluded', 22)} ${rp(`${exTests}`, 16)} ${rp(`${exTests}`, 16)} ${exTests * 2}`);
out(LINE);
Loading