ci: report the test code each pull request adds (#13149)

Nothing counted the test code a PR adds: the size rules count production
lines only. Lint & Static now writes each PR's test and production lines for
core, cli and web-shell to the job summary, measured from the true merge base,
and warns at 2,000 or more net test lines. It reports only; nothing blocks on
it.
This commit is contained in:
tanzhenxin authored and GitHub committed 2026-10-01 17:37:30 +00:00
1 parent 728c13de21
commit a7deb01bcb
3 files changed
+510 -2

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* Report how much test code a pull request adds, and flag large additions.
*
* Nothing else counts test lines: the size rules in AGENTS.md and the triage
* skill count production lines only. In September 2026, PRs added 1.84 test
* lines per production line in core, cli and web-shell, and the 8% of PRs
* adding 2,000 or more net test lines carried half of that growth (#13007
* removed 178,000 Core test lines, a few weeks of it). This step writes the
* numbers to the job summary on every PR and, at FLAG_THRESHOLD net test
* lines, emits a warning annotation so authors and reviewers see them. It
* reports only: nothing blocks on it.
*
* The diff is taken from the true merge base, which the compare API reports:
* the PR's base.sha is the base tip, and diffing against it would count the
* base's newer changes, reversed, on any branch that is behind. The lane's
* checkout is shallow, so the merge base is fetched at depth 1.
*
* Advisory only: every error is a warning and the step exits 0.
*/
import { execFile } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export const FLAG_THRESHOLD = 2000;
export const PACKAGES = ['core', 'cli', 'web-shell'];
// Test code: test and spec files, test helpers, fixtures, mocks, snapshots.
const TEST_PATTERNS = [
/\.(test|spec)\.[^/]+$/,
/\.test-helper\.[^/]+$/,
/\.fixtures\.json$/,
/\.snap$/,
/(^|\/)(test-setup|testUtils)\.ts$/,
/(^|\/)test-utils\.[^/]+$/,
/(^|\/)(__tests__|__mocks__|__fixtures__|__snapshots__|__e2e__|fixtures|test-utils|testUtils)\//,
/^packages\/web-shell\/client\/(e2e|test)\//,
];
const GENERATED = /(\.schema\.(ts|json)|\.generated\.ts)$|(^|\/)generated\//;
const SOURCE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
/** The package a path belongs to, or null outside the three packages. */
export function packageOf(path) {
const match = /^packages\/([^/]+)\//.exec(path);
return match && PACKAGES.includes(match[1]) ? match[1] : null;
}
/** 'test', 'production' or 'other' (data, docs, styles, generated code). */
export function classify(path) {
if (TEST_PATTERNS.some((pattern) => pattern.test(path))) return 'test';
if (GENERATED.test(path) || !SOURCE.test(path)) return 'other';
return 'production';
}
/** Parses `git diff --numstat`, skipping binary files ("-\t-\tpath"). */
export function parseNumstat(text) {
const entries = [];
for (const line of text.split('\n')) {
const match = /^(\d+)\t(\d+)\t(.+)$/.exec(line);
if (match) {
entries.push({ added: +match[1], deleted: +match[2], path: match[3] });
}
}
return entries;
}
/** Added and deleted test and production lines per package, plus a total. */
export function tally(entries) {
const zero = () => ({
test: { added: 0, deleted: 0 },
production: { added: 0, deleted: 0 },
});
const byPackage = Object.fromEntries(PACKAGES.map((name) => [name, zero()]));
const total = zero();
for (const { added, deleted, path } of entries) {
const name = packageOf(path);
const kind = classify(path);
if (!name || kind === 'other') continue;
for (const bucket of [byPackage[name][kind], total[kind]]) {
bucket.added += added;
bucket.deleted += deleted;
}
}
return { byPackage, total };
}
const net = ({ added, deleted }) => added - deleted;
const fmt = (n) => Math.abs(n).toLocaleString('en-US');
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '') + fmt(n);
const cell = (counts) =>
`+${fmt(counts.added)} / −${fmt(counts.deleted)} (net ${signed(net(counts))})`;
export function isFlagged({ total }) {
return net(total.test) >= FLAG_THRESHOLD;
}
export function renderSummary(tallied) {
const rows = PACKAGES.map(
(name) =>
`| ${name} | ${cell(tallied.byPackage[name].test)} | ` +
`${cell(tallied.byPackage[name].production)} |`,
);
const lines = [
'### Test growth',
'',
'| Package | Test lines | Production lines |',
'| --- | ---: | ---: |',
...rows,
`| **Total** | ${cell(tallied.total.test)} | ${cell(tallied.total.production)} |`,
'',
];
if (isFlagged(tallied)) {
lines.push(
`This PR adds ${fmt(net(tallied.total.test))} net test lines, at or ` +
`above the ${fmt(FLAG_THRESHOLD)}-line flag. Consider whether shared ` +
'fixtures, parameterized tables or whole-result expectations would ' +
'cover the same behavior in less code.',
'',
);
}
return lines.join('\n');
}
export function renderAnnotation(tallied) {
if (!isFlagged(tallied)) return null;
return (
`::warning title=Test growth::This PR adds ` +
`${fmt(net(tallied.total.test))} net test lines in core, cli and ` +
`web-shell (flag at ${fmt(FLAG_THRESHOLD)}). ` +
'Consider shared fixtures or parameterized tables; see the job summary.'
);
}
/**
* curl, not fetch: it honours HTTPS_PROXY on the ECS runners' egress, which
* node's global fetch does not (see check-lint-gate-freshness.mjs).
*/
export async function curlJson(path, token, run = execFileAsync) {
try {
const { stdout } = await run(
'curl',
[
'-sS',
'-fL',
'--max-time',
'30',
'-H',
`Authorization: Bearer ${token}`,
'-H',
'Accept: application/vnd.github+json',
'-H',
'X-GitHub-Api-Version: 2022-11-28',
`https://api.github.com${path}`,
],
{ maxBuffer: 64 * 1024 * 1024 },
);
return JSON.parse(stdout);
} catch (error) {
// execFile's message embeds the full argv, bearer token included, so
// rebuild the error from the exit code and curl's last stderr line.
const stderr = String(error.stderr ?? '')
.trim()
.split('\n')
.pop();
throw new Error(
`GET ${path} failed (curl exit ${error.code ?? '?'})${stderr ? `: ${stderr}` : ''}`,
);
}
}
async function git(...args) {
const { stdout } = await execFileAsync('git', args, {
maxBuffer: 64 * 1024 * 1024,
timeout: 120_000,
});
return stdout;
}
// For one command at a time: fetch the merge base's commit and trees only,
// and let `git diff` pull just the blobs it compares. A plain depth-1 fetch
// would transfer the whole tree again, about 60 MiB, on every PR.
const PARTIAL = [
'-c',
'extensions.partialClone=origin',
'-c',
'remote.origin.promisor=true',
];
/** Net test and production lines between the PR's merge base and its head. */
export async function measure({ fetchJson, runGit, repo, baseSha, headSha }) {
const compare = await fetchJson(
`/repos/${repo}/compare/${baseSha}...${headSha}?per_page=1`,
);
const mergeBase = compare?.merge_base_commit?.sha;
if (!mergeBase) throw new Error('compare API returned no merge base');
let partial = false;
try {
await runGit('cat-file', '-e', `${mergeBase}^{commit}`);
} catch {
const fetch = ['fetch', '--no-tags', '--depth=1'];
try {
await runGit(
...PARTIAL,
...fetch,
'--filter=blob:none',
'origin',
mergeBase,
);
partial = true;
} catch {
await runGit(...fetch, 'origin', mergeBase);
}
}
try {
const numstat = await runGit(
...(partial ? PARTIAL : []),
'diff',
'--numstat',
'--no-renames',
mergeBase,
headSha,
'--',
...PACKAGES.map((name) => `packages/${name}`),
);
return { mergeBase, tallied: tally(parseNumstat(numstat)) };
} finally {
// The filtered fetch records its filter on the remote; drop it so later
// fetches in this checkout stay ordinary.
if (partial) {
await runGit(
'config',
'--unset',
'remote.origin.partialclonefilter',
).catch(() => {});
}
}
}
async function main() {
const { GITHUB_TOKEN, BASE_SHA, HEAD_SHA, GITHUB_REPOSITORY } = process.env;
if (!GITHUB_TOKEN || !BASE_SHA || !HEAD_SHA || !GITHUB_REPOSITORY) {
console.log(
'::warning::test growth: BASE_SHA, HEAD_SHA, GITHUB_REPOSITORY or ' +
'GITHUB_TOKEN missing; skipping the check',
);
return;
}
try {
const { mergeBase, tallied } = await measure({
fetchJson: (path) => curlJson(path, GITHUB_TOKEN),
runGit: git,
repo: GITHUB_REPOSITORY,
baseSha: BASE_SHA,
headSha: HEAD_SHA,
});
const summary = renderSummary(tallied);
console.log(`Merge base ${mergeBase.slice(0, 12)}\n\n${summary}`);
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);
}
const annotation = renderAnnotation(tallied);
if (annotation) console.log(annotation);
} catch (error) {
// One line: an annotation ends at the first newline.
const reason = String(error.message).split('\n')[0];
console.log(
`::warning::test growth check could not run (${reason}); skipping it`,
);
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
await main();
}
+216
View File
@@ -0,0 +1,216 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
FLAG_THRESHOLD,
classify,
curlJson,
measure,
packageOf,
parseNumstat,
renderAnnotation,
renderSummary,
tally,
} from './check-test-growth.mjs';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
test('classifies test, production and other files', () => {
const cases = {
'packages/core/src/a.test.ts': 'test',
'packages/cli/src/ui/App.test.tsx': 'test',
'packages/core/src/a.spec.ts': 'test',
'packages/core/src/tools/shell.test-helper.ts': 'test',
'packages/core/src/x/case.fixtures.json': 'test',
'packages/core/src/utils/testUtils.ts': 'test',
'packages/core/test-setup.ts': 'test',
'packages/core/src/__tests__/a.ts': 'test',
'packages/cli/src/__snapshots__/App.test.tsx.snap': 'test',
'packages/core/src/test-utils/mock-tool.ts': 'test',
'packages/cli/src/commands/review/lib/test-utils.ts': 'test',
'packages/core/src/lsp/__e2e__/lsp-e2e-test.ts': 'test',
'packages/web-shell/client/e2e/utils/mockDaemon.ts': 'test',
'packages/web-shell/client/test/setup.ts': 'test',
'packages/core/src/core/client.ts': 'production',
'packages/web-shell/client/src/App.tsx': 'production',
'packages/cli/src/index.mjs': 'production',
'packages/core/src/config/settings.schema.ts': 'other',
'packages/core/src/generated/git-commit.ts': 'other',
'packages/core/src/x.generated.ts': 'other',
'packages/core/package.json': 'other',
'packages/web-shell/client/src/app.css': 'other',
'packages/core/README.md': 'other',
};
for (const [path, kind] of Object.entries(cases)) {
assert.equal(classify(path), kind, path);
}
});
test('counts only core, cli and web-shell', () => {
assert.equal(packageOf('packages/core/src/a.ts'), 'core');
assert.equal(packageOf('packages/web-shell/client/src/a.ts'), 'web-shell');
assert.equal(packageOf('packages/sdk-typescript/src/a.ts'), null);
assert.equal(packageOf('scripts/build.js'), null);
});
test('tallies numstat per package, skipping binary and other files', () => {
const numstat = [
'120\t20\tpackages/core/src/a.test.ts',
'30\t5\tpackages/core/src/a.ts',
'-\t-\tpackages/cli/assets/logo.png',
'40\t0\tpackages/cli/src/b.test.tsx',
'9\t1\tpackages/cli/package.json',
'7\t2\tpackages/sdk-typescript/src/c.test.ts',
'',
].join('\n');
const entries = parseNumstat(numstat);
assert.equal(entries.length, 5);
assert.deepEqual(tally(entries), {
byPackage: {
core: {
test: { added: 120, deleted: 20 },
production: { added: 30, deleted: 5 },
},
cli: {
test: { added: 40, deleted: 0 },
production: { added: 0, deleted: 0 },
},
'web-shell': {
test: { added: 0, deleted: 0 },
production: { added: 0, deleted: 0 },
},
},
total: {
test: { added: 160, deleted: 20 },
production: { added: 30, deleted: 5 },
},
});
});
test('flags at the threshold of net test lines, not below', () => {
const withNet = (n) =>
tally([
{ added: n + 100, deleted: 100, path: 'packages/core/src/a.test.ts' },
]);
assert.equal(FLAG_THRESHOLD, 2000);
const below = withNet(FLAG_THRESHOLD - 1);
assert.equal(renderAnnotation(below), null);
assert.doesNotMatch(renderSummary(below), /net test lines, at or above/);
const at = withNet(FLAG_THRESHOLD);
assert.match(
renderAnnotation(at),
/^::warning title=Test growth::This PR adds 2,000 net test lines/,
);
assert.match(renderSummary(at), /\| core \| \+2,100 \/ −100 \(net \+2,000\)/);
assert.match(
renderSummary(at),
/2,000 net test lines, at or above the 2,000-line flag/,
);
});
test('measures from the merge base, fetching it only when missing', async () => {
const [base, head, mergeBase] = ['b', 'h', 'm'].map((c) => c.repeat(40));
const run = async ({ present, partialFails = false }) => {
const calls = [];
const result = await measure({
repo: 'QwenLM/qwen-code',
baseSha: base,
headSha: head,
fetchJson: async (path) => {
assert.equal(
path,
`/repos/QwenLM/qwen-code/compare/${base}...${head}?per_page=1`,
);
return { merge_base_commit: { sha: mergeBase } };
},
runGit: async (...args) => {
calls.push(args.join(' '));
if (args[0] === 'cat-file' && !present) throw new Error('missing');
if (partialFails && args.includes('--filter=blob:none')) {
throw new Error('no partial clone');
}
// The diff must start at the merge base, not the base tip.
if (args.includes('diff')) return '3\t1\tpackages/core/src/a.test.ts\n';
return '';
},
});
assert.deepEqual(result.tallied.total.test, { added: 3, deleted: 1 });
return calls;
};
const PARTIAL =
'-c extensions.partialClone=origin -c remote.origin.promisor=true';
const DIFF = `diff --numstat --no-renames ${mergeBase} ${head} -- packages/core packages/cli packages/web-shell`;
assert.deepEqual(await run({ present: true }), [
`cat-file -e ${mergeBase}^{commit}`,
DIFF,
]);
assert.deepEqual(await run({ present: false }), [
`cat-file -e ${mergeBase}^{commit}`,
`${PARTIAL} fetch --no-tags --depth=1 --filter=blob:none origin ${mergeBase}`,
`${PARTIAL} ${DIFF}`,
'config --unset remote.origin.partialclonefilter',
]);
assert.deepEqual(
(await run({ present: false, partialFails: true })).slice(2),
[`fetch --no-tags --depth=1 origin ${mergeBase}`, DIFF],
);
});
test('ci.yml runs the check in lint_and_static on pull requests', () => {
const ci = readFileSync(join(repoRoot, '.github/workflows/ci.yml'), 'utf8');
const jobStart = ci.indexOf('\n lint_and_static:');
const jobEnd = ci.indexOf('\n test_macos:', jobStart);
assert.ok(jobStart > 0 && jobEnd > jobStart, 'lint_and_static job not found');
const job = ci.slice(jobStart, jobEnd);
const step = job.indexOf("- name: 'Report test growth'");
assert.ok(step > 0, 'test growth step missing');
const stepBlock = job.slice(step, job.indexOf('\n - name:', step + 1));
for (const expected of [
"github.event_name == 'pull_request'",
"skip_ci != 'true'",
'github.event.pull_request.base.sha',
'github.event.pull_request.head.sha',
'node .github/scripts/check-test-growth.mjs',
]) {
assert.ok(stepBlock.includes(expected), `step must include ${expected}`);
}
assert.ok(
step < job.indexOf("- name: 'Install dependencies'"),
'the check needs no dependencies and must run before the install',
);
for (const list of ['HELPER_TESTS_DEP_FREE', 'HELPER_TESTS']) {
const line = ci.split('\n').find((l) => l.trim().startsWith(`${list}:`));
assert.ok(
line?.includes('.github/scripts/check-test-growth.test.mjs'),
`${list} must run this test`,
);
}
});
test('API errors never carry the token', async () => {
const failing = async () => {
throw Object.assign(
new Error('Command failed: curl -H Authorization: Bearer secret-token'),
{
code: 22,
stderr: 'curl: (22) The requested URL returned error: 401\n',
},
);
};
await assert.rejects(
curlJson('/repos/o/r/compare/a...b', 'secret-token', failing),
(error) => {
assert.doesNotMatch(error.message, /secret-token/);
assert.match(error.message, /curl exit 22\): curl: \(22\).*401$/);
return true;
},
);
});
+13 -2
View File
@@ -87,8 +87,8 @@ env:
# re-runs every suite after `npm ci`, and a push to main always classifies
# `full` (classify_pr computes a profile only for pull_request), so what
# the fast lane skips is still re-checked post-merge.
HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/ecs-runner/review-scratch-cleanup.test.mjs'
HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/ecs-runner/review-scratch-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs'
# The growth ratchet and its vitest mirror compare each workflow against
# the PR's base commit to tell "this PR grew the file" apart from "the
# baseline went stale on main" (#9904). Wired once here so every lane
@@ -1097,6 +1097,17 @@ jobs:
HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
run: 'node .github/scripts/check-lint-gate-freshness.mjs'
# Advisory test-growth summary and flag; see the script header.
- name: 'Report test growth'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' }}"
continue-on-error: true
timeout-minutes: 5
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
BASE_SHA: '${{ github.event.pull_request.base.sha }}'
HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
run: 'node .github/scripts/check-test-growth.mjs'
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
# through the egress proxy; reuse the machine's Node instead.
- name: 'Set up Node.js 22.x (hosted)'