diff --git a/AGENTS.md b/AGENTS.md index 48c266056c..a9befbf12f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,9 @@ an unowned upstream edit is an error until you assign it. → [docs/CONTRIBUTING ## Build +Bare `tools/…` paths below are inside the materialized tree (`out/`); in +this repo they live under `mobile-src/tools/`. + - **Android:** `./tools/android_build.sh [arch]` — **Linux only**. A macOS host cannot complete it: node's gyp links the *host* build-tools with GNU `ar`/`ld` options Apple's toolchain rejects. Not a mobile bug. @@ -53,32 +56,33 @@ an unowned upstream edit is an error until you assign it. → [docs/CONTRIBUTING ## Test -The gate is a curated `test/parallel` subset -(`mobile-src/tools/mobile-test/curated-device-tests.txt`) run through the -proxy harness on an Android emulator and an iOS simulator, plus boot smokes, -a NAPI symbol smoke, and a real-device smoke on BrowserStack. A test passes -when the app writes `PASS` to its per-launch sandbox verdict file — never -scraped from logs. +The PR gate is a curated `test/parallel` subset +(`tools/mobile-test/curated-device-tests.txt`) run through the proxy harness +on an Android emulator and an iOS simulator, plus boot smokes and a NAPI +symbol smoke. The BrowserStack real-device smoke runs only on releases (or +on demand). A test passes when the app writes `PASS` to its per-launch +sandbox verdict file — never scraped from logs. Curation caveats: tests that **spawn a child node process** can't pass on Android and are excluded. The curated list is an **allow-list**, so a test -that is neither listed nor `.status`-skipped never runs on a PR — run -`tools/mobile-test/coverage-manifest.py` for how much that is (roughly 95% -of the runnable suite). The nightly `full-device-suite.yml` sweeps that -remainder on both platforms. → [TESTING.md](./docs/TESTING.md) +that is neither listed nor `.status`-skipped never runs on a PR — that is +most of the runnable suite; `tools/mobile-test/coverage-manifest.py` prints +the current figure. `full-device-suite.yml` sweeps that remainder on both +platforms nightly, on releases, and on upstream-upgrade PRs. +→ [TESTING.md](./docs/TESTING.md) ## Upgrading upstream Bump `upstream-base.txt`, run `prepare.sh`, resolve conflicts in `out/`, -regenerate. Expect a handful of small conflicts. **A clean `git am` is not +regenerate. **A clean `git am` is not proof of correctness** — an upstream restructure can leave a platform guard (`#if TARGET_OS_OSX`) enclosing the wrong span, which only the compile -catches. Two things bite every time: `prepare.sh`'s `--depth 1` clone has no -pre-image blobs, so a patch on an upstream-modified file dies with `sha1 -information is lacking or useless` until you `git -C out fetch --depth 1 -origin tag `; and `prepare.sh` can't resume, so after a conflict -you apply the rest of the series *and* redo the `mobile-src` overlay by hand. -New upstream tests need `.status` triage before the PR, not after — the full +catches. Bump `upstream-base.txt` *without committing* so `prepare.sh` can +fetch the old base's blobs for `--3way`; after resolving a conflict, resume +with `scripts/apply-series.sh out` (it also redoes the `mobile-src` +overlay). Use `git -C out …`, not `cd out`, with absolute patch paths for +`git -C out am`. Triage new *and modified* upstream tests with +`scripts/triage-new-tests.py out ` before the PR — the full device suite gates the merge. → [docs/UPGRADING.md](./docs/UPGRADING.md) ## Gotchas diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 580b90ab8d..4975f97557 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -10,15 +10,16 @@ occasionally, one that only the compile catches (see the warning below). ## Re-base the series ```sh -$EDITOR upstream-base.txt # bump the tag, e.g. v24.20.0 +$EDITOR upstream-base.txt # bump the tag, e.g. v24.20.0 (don't commit yet) scripts/prepare.sh # clone new base + apply series ``` -`prepare.sh` stops at the first patch that no longer applies. For each -conflict, resolve **in `out/`** with the usual `git am` loop -(`git status` → edit → `git add` → `git am --continue`), guided by one -question: *what does this patch intend, and what did upstream change?* -Patterns seen in practice: +`prepare.sh` stops at the first patch that no longer applies and prints a +summary of the unmerged paths. Resolve **in `out/`**, `git -C out am +--continue`, then resume the rest of the series with +`scripts/apply-series.sh out` (see [Resuming](#resuming-after-a-conflict)). +Each conflict comes down to one question: *what does this patch intend, and +what did upstream change?* Patterns seen in practice: - **Adjacent churn** — upstream changed a line next to a mobile hunk (e.g. a version string in `common.gypi`): keep upstream's new value, keep the @@ -28,8 +29,18 @@ Patterns seen in practice: - **Upstream restructured context the patch relied on** (e.g. a macro block the mobile diff sat inside was removed): re-apply only the mobile intent against the new structure. +- **Upstream absorbed the mobile hunk** — upstream now does what the patch + did, often more generally: drop the hunk, and trim the paragraph of the + patch header that explained it (edit the `.patch` header, then + regenerate). Recheck the `Co-authored-by:` trailers against what remains. + v24.22.0 wrapped `--experimental-wasm-jspi` in `#if V8_ENABLE_WEBASSEMBLY` + and started defining that macro for node's own targets in `common.gypi`; + iOS builds V8 in lite mode, so that retired our iOS-only guard. Before + dropping one, trace the chain on the new base — the old header may say + why upstream's spelling would not have worked before. - **Delete/modify** — a file we delete was modified upstream: the intent is - still deletion → `git rm` the unmerged paths, continue. + still deletion → `git rm` the unmerged paths, continue. The conflict + summary prints the one-liner. - **Wholesale-replacement docs** (the fork `README.md` replaces the upstream remainder): resolve to ours. @@ -46,9 +57,11 @@ Also: removal patch doesn't cover yet — `git rm` them in `out/` *and* add each path to `patches/files.map` against the upstream-CI removal patch (`ci-remove-upstream-only-workflows-and-config.patch`), or - `regenerate-patches.py` rejects the deletion as owned by no patch. A - `verify-patches` job asserts the materialized tree carries zero workflow - files, so a missed one fails loudly. + `regenerate-patches.py` rejects the deletion as owned by no patch. + `apply-series.sh` lists any it finds, and a `verify-patches` job asserts + the materialized tree carries zero workflow files. The reverse also + happens: when upstream deletes a file a `files.map` line names (v24.22.0 + dropped `commit-lint-problem-matcher.json`), remove that line. - add `.status` entries for the tests upstream just added — see [new upstream tests](#new-upstream-tests) below. - watch for upstream **splitting a build target**. gyp resolves the new @@ -64,47 +77,36 @@ Also: ### `sha1 information is lacking or useless` -A patch that stops with - ``` error: sha1 information is lacking or useless (README.md). error: could not build fake ancestor ``` -has not conflicted. `--3way` needs the *pre-image* blob the patch header -records, and `prepare.sh` clones `--depth 1`, so `out/` holds only the new -tag's blobs — for any file upstream touched since the old base, git has -nothing to merge against. Fetch the base you are moving off to supply them: - -```sh -git -C out fetch --depth 1 origin tag v24.19.0 -``` - -Retry the patch and it either applies or fails as an ordinary content -conflict you can resolve. Do this before editing anything by hand: with the -old blobs present, `git am --3way` absorbs most upstream drift on its own. +is not a conflict. `--3way` needs the *pre-image* blob the patch header +records, and `prepare.sh` clones `--depth 1`, so the clone has nothing to +merge against for files upstream touched since the old base. The scripts +fetch the old base for you when it is derivable — the committed +`upstream-base.txt`, while your bump is still uncommitted — or from +`OLD_BASE=`. If you see this error, set `OLD_BASE` and re-run +`scripts/apply-series.sh out`. -### Finishing the series by hand +### Resuming after a conflict -`prepare.sh` refuses to run when `out/` exists, so it cannot be re-run to -resume. Once you have resolved the conflict, land it and apply the rest of -`patches/series` yourself with the same invocation the script uses: +`prepare.sh` refuses to run when `out/` exists, so it cannot resume. Once +you have resolved a conflict and run `git -C out am --continue`: ```sh -git -C out am --continue -git -C out am --3way --keep-cr --whitespace=nowarn patches/.patch +scripts/apply-series.sh out ``` -Then redo step 3, the `mobile-src/` overlay, which never ran. It is not -optional — without that commit `out/` is upstream plus patches, and -`regenerate-patches.py` reads every fork-only file as deleted: +It skips every patch whose subject is already in `out/`'s history (or +starts at `--from `), applies the rest, commits the `mobile-src/` +overlay if it hasn't been, and compares the tree hash — informational +here, since the hash moves with the upgrade. The overlay is not optional: +without it, `regenerate-patches.py` reads every fork-only file as deleted. -```sh -( cd mobile-src && git ls-files -z --cached --others --exclude-standard . ) > /tmp/manifest -( cd mobile-src && tar cf - --null -T /tmp/manifest ) | ( cd out && tar xf - ) -git -C out add -f --pathspec-from-file=/tmp/manifest --pathspec-file-nul -git -C out commit -m "mobile: fork-only files (mobile-src overlay)" -``` +Work with `git -C out …` rather than `cd out`, and give `git -C out am` +absolute patch paths — it resolves relative ones against `out/`. ### Regenerate and commit @@ -128,10 +130,17 @@ the moment the base moves — and the suite gates the merge. Work out what needs skipping *before* pushing rather than reading it off a red run: ```sh -git -C out diff --name-status v24.19.0 v24.20.0 -- test/parallel test/sequential | grep '^A' +scripts/triage-new-tests.py out v24.20.0 v24.22.0 ``` -A minor release can add hundreds. Most need nothing. Triage by cause: +It covers added tests and the added lines of modified ones — an existing +test can gain a subtest that spawns a child or a `// Flags:` entry iOS +rejects (v24.22.0 did both, to `test-fs-glob` and +`test-internal-webidl-buffer-source`). It drops tests the platform's +`.status` block already matches and prints one line per suspect with its +category, the platforms still open, the matching source line, and any +top-level `common.skip()` guard. A minor release can add hundreds of tests; +most need nothing. Read each suspect against these causes: - **Spawns a child node process** — cannot pass on Android, where `process.execPath` is `app_process64`, so the child SIGABRTs (its stderr @@ -147,6 +156,9 @@ A minor release can add hundreds. Most need nothing. Triage by cause: `// Flags:` header, and watch for a test that starts with the permission and calls `process.permission.drop('fs.write')` (or `'fs'`) partway through. The verdict write is denied either way. Skip. +- **V8 flags iOS doesn't have** — iOS builds V8 in lite mode, which compiles + out WebAssembly, so a `// Flags: --experimental-wasm-*` header fails with + `bad option` before the test runs. Skip on iOS only. - **Self-skipping** — a test that ends at `common.skip()` scores PASS, so it needs no entry. This is why v24.20.0's ~250 new QUIC tests are absent from `parallel.status`: the mobile build doesn't pass `--experimental-quic`, so @@ -155,6 +167,14 @@ A minor release can add hundreds. Most need nothing. Triage by cause: `test-eslint-*` and `test-child-process-*` are skipped wholesale on both platforms; `test-debugger-probe-*` is a glob on Android but an itemized list on iOS, so a new member of that family needs an iOS entry only. +- **Execs a non-node binary** (`mkfifo`, `sh`) — a judgement call. Android + ships toybox, so it may work; a real iOS device can't spawn at all. + Existing `mkfifo` tests are skipped; say which way you went in the PR. +- **Expects a libstdc++ errno** — mobile links libc++, like macOS, so code + built on `std::filesystem` reports macOS's errno: `rmSync` on a read-only + directory gives `ENOTEMPTY`, not `EACCES`. When the test already + special-cases `common.isMacOS`, extend that condition with an anchored + `common.isAndroid || common.isIOS` guard rather than skipping the file. - **Everything else** — leave it to run. The suite is the measurement. A child-process spawner fails the Android suite outright, and on the iOS @@ -178,6 +198,25 @@ confirms them. ## Open the PR +Unlike the repo's usual short-prose PRs, an upgrade PR uses headings, so a +reviewer can find the test decisions and tell routine ones from the ones +that need thought: + +- **Conflicts** — each one and how it was resolved. +- **Upstream changes absorbed** — mobile hunks dropped because upstream now + covers them, and the chain that proves it; new or removed workflows and + `files.map` lines; new build targets. +- **Test changes**, in three groups: + - *Clear-cut skips* — spawns a child node, permission sandbox, iOS + lite-mode flags. + - *Judgement calls* — external binaries, platform errno differences, an + anchored guard chosen over a skip (or the reverse). + - *Failures that may indicate a mobile issue* — anything the suite + surfaced that none of the above explains; say what was investigated + before it was skipped, or why it was not. +- **Verification** — reconstruction, audits, what was built locally. +- **Manual steps** — the `upstream-base` push below. + CI re-runs the reconstruction against a fresh upstream clone, validates each patch individually, and — once merged — builds the full matrix. The version bump and release are a separate step (below), so an diff --git a/scripts/apply-series.sh b/scripts/apply-series.sh new file mode 100755 index 0000000000..9d7dae72ab --- /dev/null +++ b/scripts/apply-series.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Apply the patch series to an upstream clone, overlay the fork-only files and +# compare the result against expected-tree.txt. prepare.sh runs this on a +# fresh clone; run it yourself to resume after resolving a conflict, since +# prepare.sh refuses an existing out/. +# +# Usage: +# scripts/apply-series.sh [--from ] [out_dir] # default out/ +# +# Without --from, it resumes at the first patch in patches/series whose +# subject is not already in out_dir's history, and skips the overlay if +# that commit is already there. +# +# Env: +# OLD_BASE upstream tag being moved off; its blobs are fetched so +# --3way can merge files upstream changed since (default: +# the committed upstream-base.txt, when the working copy +# bumps it) +# STRICT_VERIFY=1 exit non-zero on an expected-tree mismatch +set -euo pipefail + +HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +FROM= +if [ "${1:-}" = "--from" ]; then FROM=${2:?--from needs a patch name}; shift 2; fi +OUT=$(cd -- "${1:-"${HERE}/out"}" && pwd) +GIT_DIR_OUT=$(git -C "${OUT}" rev-parse --absolute-git-dir) + +read_base() { grep -Ev '^\s*(#|$)' | head -n1 | tr -d '[:space:]'; } +BASE=$(read_base < "${HERE}/upstream-base.txt") + +if [ -d "${GIT_DIR_OUT}/rebase-apply" ]; then + echo "error: a 'git am' is in progress in ${OUT}; resolve it and run" >&2 + echo " git -C ${OUT} am --continue (or --skip)" >&2 + echo "then re-run this script to apply the rest." >&2 + exit 1 +fi + +if [ -z "${OLD_BASE:-}" ]; then + OLD_BASE=$(git -C "${HERE}" show HEAD:upstream-base.txt 2>/dev/null | read_base || true) +fi +if [ -n "${OLD_BASE}" ] && [ "${OLD_BASE}" != "${BASE}" ] \ + && ! git -C "${OUT}" rev-parse -q --verify "refs/tags/${OLD_BASE}" >/dev/null; then + echo "Fetching old base ${OLD_BASE} so --3way has the pre-image blobs..." + git -C "${OUT}" fetch -q --depth 1 origin tag "${OLD_BASE}" \ + || echo "warning: could not fetch ${OLD_BASE}; patches on files upstream changed may fail with 'sha1 information is lacking'" >&2 +fi + +# Unfolds RFC 2822 continuation lines, as `git log %s` does. +patch_subject() { + awk '/^Subject: /{sub(/^Subject: (\[PATCH[^]]*\] )?/, ""); s=$0; on=1; next} + on && /^ /{s=s $0; next} on{print s; exit}' "$1" +} + +conflict_summary() { + local status md content + status=$(git -C "${OUT}" status --porcelain) + md=$(grep -cE '^(DU|UD) ' <<<"${status}" || true) + content=$(grep -E '^(UU|AA|AU|UA|DD) ' <<<"${status}" | cut -c4- || true) + [ "${md}" -gt 0 ] && echo " ${md} modify/delete (upstream edited files this patch deletes) — intent is deletion:" \ + "git -C ${OUT} diff --name-only --diff-filter=U -z | git -C ${OUT} rm -q --pathspec-from-file=- --pathspec-file-nul" + [ -n "${content}" ] && { echo " content conflicts:"; sed 's/^/ /' <<<"${content}"; } + [ "${md}" -eq 0 ] && [ -z "${content}" ] && echo " no unmerged paths — see 'git -C ${OUT} am --show-current-patch=diff'" + return 0 +} + +APPLIED=$(git -C "${OUT}" log --format=%s) +PATCHES=() +while IFS= read -r p || [ -n "${p}" ]; do + [ -z "${p}" ] && continue + case "${p}" in \#*) continue ;; esac + PATCHES+=("${p}") +done < "${HERE}/patches/series" + +start=0 +if [ -n "${FROM}" ]; then + while [ "${start}" -lt "${#PATCHES[@]}" ] && [ "${PATCHES[${start}]}" != "${FROM}" ]; do start=$((start + 1)); done + [ "${start}" -lt "${#PATCHES[@]}" ] || { echo "error: ${FROM} is not in patches/series" >&2; exit 1; } +else + while [ "${start}" -lt "${#PATCHES[@]}" ] \ + && grep -qxF -- "$(patch_subject "${HERE}/patches/${PATCHES[${start}]}")" <<<"${APPLIED}"; do + start=$((start + 1)) + done +fi + +echo "2/4 Applying $((${#PATCHES[@]} - start)) of ${#PATCHES[@]} patches..." +for p in "${PATCHES[@]:${start}}"; do + # --3way: merge via embedded blob context when upstream drifted; real + # conflicts still fail loudly. --whitespace=nowarn: byte-exact apply + # regardless of the caller's apply.whitespace config. + if ! git -C "${OUT}" am --3way --keep-cr --whitespace=nowarn "${HERE}/patches/${p}" > "${GIT_DIR_OUT}/apply-series.log" 2>&1; then + if grep -q 'sha1 information is lacking' "${GIT_DIR_OUT}/apply-series.log"; then + echo "error: patch failed: ${p} — no pre-image blobs; set OLD_BASE= and re-run" >&2 + git -C "${OUT}" am --abort + else + echo "error: patch failed: ${p}" >&2 + conflict_summary >&2 + echo "Resolve in ${OUT}, 'git -C ${OUT} am --continue', then re-run scripts/apply-series.sh ${OUT}" >&2 + fi + exit 1 + fi +done + +LEFT=$(git -C "${OUT}" ls-files .github/workflows) +if [ -n "${LEFT}" ]; then + echo "warning: upstream workflows the removal patch doesn't cover yet — 'git -C ${OUT} rm' them" >&2 + echo "and add each to patches/files.map against ci-remove-upstream-only-workflows-and-config.patch:" >&2 + sed 's/^/ /' <<<"${LEFT}" >&2 +fi + +OVERLAY_SUBJECT="mobile: fork-only files (mobile-src overlay)" +if grep -qxF -- "${OVERLAY_SUBJECT}" <<<"${APPLIED}"; then + echo "3/4 Overlay already committed; skipping." +else + echo "3/4 Overlaying mobile-src/ (fork-only files)..." + # Enumerate via git, not the raw working dir: an untracked-and-ignored file + # (.DS_Store, editor droppings) must not enter the product tree — it would + # change the tree hash to one CI's clean checkout can never reproduce. + # --cached picks up tracked files (working-tree content), --others + # --exclude-standard picks up new not-yet-committed files from the + # regenerate loop while honoring the gitignores. + # Kept in the clone's git dir rather than mktemp, which sandboxed agents can't reach. + MANIFEST="${GIT_DIR_OUT}/mobile-src-manifest" + trap 'rm -f "${MANIFEST}"' EXIT + ( cd "${HERE}/mobile-src" && git ls-files -z --cached --others --exclude-standard . ) > "${MANIFEST}" + ( cd "${HERE}/mobile-src" && tar cf - --null -T "${MANIFEST}" ) | ( cd "${OUT}" && tar xf - ) + # Stage exactly what the overlay wrote, never `add -A`: a Finder-created + # .DS_Store (or any other droppings in the clone) would otherwise be committed + # into the product tree, whose hash CI's clean checkout can never reproduce. + # -f because some tracked fork files (Xcode project internals) match the tree's + # own .gitignore patterns; the overlay must land them all regardless. + git -C "${OUT}" add -f --pathspec-from-file="${MANIFEST}" --pathspec-file-nul + git -C "${OUT}" commit -q -m "${OVERLAY_SUBJECT} + +Generated by scripts/prepare.sh from the recipe branch. Fork-only files +have no upstream counterpart; they are tracked as plain files in +mobile-src/, not as patches." +fi + +echo "4/4 Verifying tree against expected-tree.txt..." +GOT=$(git -C "${OUT}" rev-parse 'HEAD^{tree}') +WANT=$(read_base < "${HERE}/expected-tree.txt") +if [ "${GOT}" = "${WANT}" ]; then + echo "OK: reconstructed tree ${GOT} matches expected-tree.txt" +else + echo "MISMATCH: reconstructed ${GOT} != expected ${WANT}" >&2 + echo "If the difference is intentional (you changed patches/ or mobile-src/)," >&2 + echo "update expected-tree.txt to ${GOT}." >&2 + [ "${STRICT_VERIFY:-0}" = "1" ] && exit 1 +fi diff --git a/scripts/prepare.sh b/scripts/prepare.sh index cc47969d35..a40de1068d 100755 --- a/scripts/prepare.sh +++ b/scripts/prepare.sh @@ -11,6 +11,10 @@ # UPSTREAM_REPO git URL or local path (default https://github.com/nodejs/node.git) # UPSTREAM_REF tag/branch to clone (default: first line of upstream-base.txt) # SKIP_VERIFY=1 don't fail on an expected-tree mismatch (prints both hashes) +# OLD_BASE tag being upgraded from; see scripts/apply-series.sh +# +# On a conflict, resolve in out_dir, `git -C out_dir am --continue`, then +# resume with scripts/apply-series.sh out_dir. set -euo pipefail HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) @@ -31,51 +35,9 @@ git clone --depth 1 --branch "${UPSTREAM_REF}" "${UPSTREAM_REPO}" "${OUT}" git -C "${OUT}" config user.name "${GIT_AUTHOR_NAME:-nodejs-mobile}" git -C "${OUT}" config user.email "${GIT_AUTHOR_EMAIL:-nodejs-mobile@invalid}" -echo "2/4 Applying $(wc -l < "${HERE}/patches/series" | tr -d ' ') patches..." -while IFS= read -r p || [ -n "${p}" ]; do - [ -z "${p}" ] && continue - case "${p}" in \#*) continue ;; esac - # --3way: merge via embedded blob context when upstream drifted; real - # conflicts still fail loudly. --whitespace=nowarn: byte-exact apply - # regardless of the caller's apply.whitespace config. - git -C "${OUT}" am --3way --keep-cr --whitespace=nowarn "${HERE}/patches/${p}" \ - || { echo "error: patch failed: ${p} (resolve in ${OUT}, then 'git am --continue')" >&2; exit 1; } -done < "${HERE}/patches/series" - -echo "3/4 Overlaying mobile-src/ (fork-only files)..." -# Enumerate via git, not the raw working dir: an untracked-and-ignored file -# (.DS_Store, editor droppings) must not enter the product tree — it would -# change the tree hash to one CI's clean checkout can never reproduce. -# --cached picks up tracked files (working-tree content), --others -# --exclude-standard picks up new not-yet-committed files from the -# regenerate loop while honoring the gitignores. -MANIFEST=$(mktemp) -trap 'rm -f "${MANIFEST}"' EXIT -( cd "${HERE}/mobile-src" && git ls-files -z --cached --others --exclude-standard . ) > "${MANIFEST}" -( cd "${HERE}/mobile-src" && tar cf - --null -T "${MANIFEST}" ) | ( cd "${OUT}" && tar xf - ) -# Stage exactly what the overlay wrote, never `add -A`: a Finder-created -# .DS_Store (or any other droppings in the clone) would otherwise be committed -# into the product tree, whose hash CI's clean checkout can never reproduce. -# -f because some tracked fork files (Xcode project internals) match the tree's -# own .gitignore patterns; the overlay must land them all regardless. -git -C "${OUT}" add -f --pathspec-from-file="${MANIFEST}" --pathspec-file-nul -git -C "${OUT}" commit -q -m "mobile: fork-only files (mobile-src overlay) - -Generated by scripts/prepare.sh from the recipe branch. Fork-only files -have no upstream counterpart; they are tracked as plain files in -mobile-src/, not as patches." - -echo "4/4 Verifying tree against expected-tree.txt..." -GOT=$(git -C "${OUT}" rev-parse 'HEAD^{tree}') -WANT=$(grep -Ev '^\s*(#|$)' "${HERE}/expected-tree.txt" | head -n1 | tr -d '[:space:]') -if [ "${GOT}" = "${WANT}" ]; then - echo "OK: reconstructed tree ${GOT} matches expected-tree.txt" -else - echo "MISMATCH: reconstructed ${GOT} != expected ${WANT}" >&2 - echo "If the difference is intentional (you changed patches/ or mobile-src/)," >&2 - echo "update expected-tree.txt to ${GOT}." >&2 - [ "${SKIP_VERIFY:-0}" = "1" ] || exit 1 -fi +# Steps 2-4 live in apply-series.sh so a conflicted run can be resumed. +STRICT_VERIFY=$([ "${SKIP_VERIFY:-0}" = "1" ] && echo 0 || echo 1) \ + "${HERE}/scripts/apply-series.sh" "${OUT}" echo "Done: ${OUT} is a full nodejs-mobile source tree (base ${BASE})." echo "Build it as usual, e.g.: cd ${OUT} && ./tools/android_build.sh ..." diff --git a/scripts/triage-new-tests.py b/scripts/triage-new-tests.py new file mode 100755 index 0000000000..5f5ad866ff --- /dev/null +++ b/scripts/triage-new-tests.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""List the tests an upstream bump adds or changes that likely need a .status +skip on mobile, one line per suspect. + +Usage: + scripts/triage-new-tests.py [--all] + +Scans test/parallel and test/sequential. Added tests are checked whole; +modified tests only on their added lines, since an existing test can gain a +child-process subtest or a new // Flags: entry. A test already matched (by +name or glob) in the platform's .status block is dropped for that platform +unless --all is given. Categories, in the order they are reported: + + SPAWN-NODE spawns a child node: cannot pass on Android, flaky on the + iOS simulator -- skip both + PERMISSION --permission without --allow-fs-write, or drops fs/fs.write: + the harness's verdict write is denied -- skip both + IOS-FLAGS V8 wasm flags iOS lite mode doesn't know ("bad option") -- + skip iOS + SPAWN-EXTERNAL execs a non-node binary (mkfifo, sh, ...) -- judgement call + +A trailing [skips: ...] shows a top-level common.skip() guard; if it always +fires on mobile the test scores PASS and needs no entry. +See docs/UPGRADING.md, "New upstream tests". +""" +import fnmatch +import os +import re +import subprocess +import sys + +DIRS = ("test/parallel", "test/sequential") +PLATFORMS = ("android", "ios") +TEST_RE = re.compile(r"^test/(parallel|sequential)/(test-[^/]+)\.(js|mjs|cjs)$") + +SPAWN_NODE = re.compile( + r"process\.execPath|spawnPromisified|isolation:\s*['\"]process|\bfork\(|" + r"buildSnapshot\(|spawnSyncAnd\w+\(|startCLI\(") +SPAWN_EXTERNAL = re.compile( + r"(? skip patterns; each suite's .status covers only its own dir.""" + entries = {} + for d in DIRS: + suite = os.path.basename(d) + path = os.path.join(out, d, os.path.basename(d) + ".status") + if not os.path.exists(path): + continue + applies = () + for line in open(path, encoding="utf-8"): + line = line.split("#", 1)[0].strip() + if line.startswith("["): + applies = tuple(p for p in PLATFORMS + if line == "[true]" or f"$system=={p}" in line) + elif ":" in line: + for p in applies: + entries.setdefault((suite, p), []).append(line.split(":", 1)[0].strip()) + return entries + + +def classify(lines, flags_lines): + """[(category, line)] for lines (iterable of source lines).""" + hits = {} + for raw in flags_lines: + m = FLAGS.match(raw) + if not m: + continue + flags = m.group(1) + if "--permission" in flags and "--allow-fs-write" not in flags: + hits.setdefault("PERMISSION", raw) + if IOS_FLAG.search(flags): + hits.setdefault("IOS-FLAGS", raw) + for raw in lines: + s = raw.strip() + if s.startswith("//"): + continue + if SPAWN_NODE.search(s): + hits.setdefault("SPAWN-NODE", s) + elif SPAWN_EXTERNAL.search(s): + hits.setdefault("SPAWN-EXTERNAL", s) + if DROP_FS.search(s): + hits.setdefault("PERMISSION", s) + order = ("SPAWN-NODE", "PERMISSION", "IOS-FLAGS", "SPAWN-EXTERNAL") + return [(c, hits[c]) for c in order if c in hits] + + +def skip_guards(lines): + guards = [] + for i, raw in enumerate(lines): + if raw.startswith("common.skip(") or raw.startswith(" common.skip("): + cond = next((SKIP_GUARD.match(lines[j]).group(1) + for j in range(i, max(i - 3, -1), -1) + if SKIP_GUARD.match(lines[j])), None) + guards.append(cond or "?") + return guards + + +def main(argv): + show_all = "--all" in argv + args = [a for a in argv if a != "--all"] + if len(args) != 3: + sys.exit(__doc__) + out, old, new = args + for t in (old, new): + ensure_tag(out, t) + covered = {} if show_all else status_entries(out) + + changed = git(out, "diff", "--name-status", old, new, "--", *DIRS) + rows = [] + for line in changed.splitlines(): + kind, *paths = line.split("\t") + path = paths[-1] + m = TEST_RE.match(path) + if not m or kind[0] not in "AMR": + continue + name = m.group(2) + src = git(out, "show", f"{new}:{path}").splitlines() + if kind[0] == "A": + hits = classify(src, src[:5]) + else: + added = [l[1:] for l in git(out, "diff", old, new, "--", path).splitlines() + if l.startswith("+") and not l.startswith("+++")] + hits = classify(added, [l for l in added if FLAGS.match(l)]) + if not hits: + continue + category, evidence = hits[0] + targets = ("ios",) if category == "IOS-FLAGS" else PLATFORMS + open_on = [p for p in targets + if not any(fnmatch.fnmatchcase(name, pat) + for pat in covered.get((m.group(1), p), []))] + if not open_on: + continue + extra = "+" + ",".join(c for c, _ in hits[1:]) if hits[1:] else "" + guards = skip_guards(src) + note = f" [skips: {'; '.join(guards)}]" if guards else "" + rows.append(f"{category + extra:<16} {kind[0]} {name} ({','.join(open_on)})" + f" {evidence[:100]}{note}") + print("\n".join(sorted(rows)) or "no suspects") + + +if __name__ == "__main__": + main(sys.argv[1:])