Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/browser-runtime-dependency-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Packaging coverage is intentional:
1. `npm run build` compiles the runtime packages and CLI package.
2. `npm pack --workspace @automattic/wp-codebox-cli --dry-run --json` proves the CLI package includes compiled `dist` files and omits TypeScript source.
3. `npm run package:wordpress-plugin` builds the plugin zip from source files, checked-in plugin assets, and the staged CLI release bundle.
4. `npm run smoke -- --group package` asserts the CLI pack shape and plugin zip shape, including the browser runtime asset and vendored CLI/Node runtime path.
4. `npm run check` asserts the CLI pack shape and plugin zip shape, including the browser runtime asset and vendored CLI/Node runtime path.
6. `npm run smoke -- --command wordpress-plugin-smoke` verifies the browser Playground session ability and runtime dependency metadata shape.

## Provenance And Transfer Review
Expand Down
2 changes: 1 addition & 1 deletion docs/transfer-namespace-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ After the target npm scope and repository URL are confirmed:
- Rebuild generated `dist` output with `npm run build` so compiled imports match
the source package names.
- Rebuild the WordPress plugin zip with `npm run package:wordpress-plugin`.
- Run `npm run smoke -- --group package` to verify npm packing and plugin zip
- Run `npm run check` to verify npm packing and plugin zip
contents after the rename.
- Run `npm run check` before publishing or tagging a release candidate.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@
"test:wp-cli-recipe-result": "tsx tests/wp-cli-recipe-result.test.ts",
"test:browser-sdk-facade": "tsx tests/browser-sdk-facade.test.ts",
"test:browser-viewport-replay": "node ./node_modules/typescript/bin/tsc -b packages/runtime-core --force && node ./node_modules/typescript/bin/tsc -b packages/runtime-playground packages/cli --force && node scripts/ensure-cli-bin-executable.mjs && tsx tests/browser-viewport-replay.test.ts",
"check": "npm run test:production-boundary-enforcement && npm run smoke -- --group=check"
"check": "npm run smoke -- --group=check"
},
"workspaces": [
"packages/*"
Expand Down
121 changes: 114 additions & 7 deletions scripts/run-smoke.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"
import { smokeGroups, smokeManifest, type SmokeCommand } from "./smoke-manifest.ts"
import { discoveredParallelCommands, discoveredSerialCommands } from "./smoke-discovery.ts"

const repositoryRoot = fileURLToPath(new URL("..", import.meta.url))
const DEFAULT_CONCURRENCY = 8

type ResolvedGroup = {
name: string
Expand All @@ -20,15 +25,21 @@ function usage(): string {
].join("\n")
}

function parseArgs(args: string[]): { group?: string; command?: string; all: boolean; list: boolean } {
function parseArgs(args: string[]): { group?: string; command?: string; all: boolean; list: boolean; concurrency: number } {
let group: string | undefined
let command: string | undefined
let all = false
let list = false
let concurrency = DEFAULT_CONCURRENCY

for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === "--all") {
if (arg.startsWith("--concurrency=")) {
concurrency = Number(arg.slice("--concurrency=".length))
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new Error("--concurrency requires a positive integer")
}
} else if (arg === "--all") {
all = true
} else if (arg === "--list") {
list = true
Expand All @@ -55,7 +66,7 @@ function parseArgs(args: string[]): { group?: string; command?: string; all: boo
}
}

return { group, command, all, list }
return { group, command, all, list, concurrency }
}

function resolveGroup(groupName: string): ResolvedGroup {
Expand Down Expand Up @@ -108,6 +119,63 @@ function runCommand(command: SmokeCommand): Promise<void> {
})
}

type CommandOutcome = { command: SmokeCommand; error?: Error; output: string }

/*
* Discovered files run as independent processes, so the parallel phase captures
* output per command and reports every failure instead of stopping at the first.
*/
function runCapturedCommand(command: SmokeCommand): Promise<CommandOutcome> {
return new Promise((resolve) => {
let output = ""
const child = spawn(command.command, command.args, {
cwd: new URL("..", import.meta.url),
stdio: ["ignore", "pipe", "pipe"],
shell: process.platform === "win32",
})

child.stdout?.on("data", (chunk) => { output += String(chunk) })
child.stderr?.on("data", (chunk) => { output += String(chunk) })
child.on("error", (error) => resolve({ command, error, output }))
child.on("exit", (code, signal) => {
if (code === 0) {
resolve({ command, output })
return
}
resolve({
command,
error: new Error(`${command.name} failed${signal ? ` with signal ${signal}` : ` with exit code ${code ?? "unknown"}`}`),
output,
})
})
})
}

async function runInParallel(commands: SmokeCommand[], concurrency: number): Promise<Error[]> {
const failures: Error[] = []
let cursor = 0
let finished = 0

async function worker(): Promise<void> {
while (cursor < commands.length) {
const command = commands[cursor]
cursor += 1
const outcome = await runCapturedCommand(command)
finished += 1
if (outcome.error) {
failures.push(outcome.error)
console.log(`\n[smoke] FAIL (${finished}/${commands.length}) ${command.name}`)
console.log(outcome.output.trimEnd())
} else if (finished % 25 === 0) {
console.log(`[smoke] ${finished}/${commands.length} complete`)
}
}
}

await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker))
return failures
}

async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2))

Expand All @@ -121,12 +189,51 @@ async function main(): Promise<void> {
throw new Error("Use only one of --all, --group, or --command.")
}

const group = options.command ? resolveCommand(options.command) : resolveGroup(options.all ? "check" : options.group ?? "check")
console.log(`[smoke] Running ${group.commands.length} command(s) from ${group.name}`)
if (options.command) {
const group = resolveCommand(options.command)
for (const command of group.commands) await runCommand(command)
return
}

const name = options.all ? "check" : options.group ?? "check"

if (name !== "check") {
const group = resolveGroup(name)
console.log(`[smoke] Running ${group.commands.length} command(s) from ${group.name}`)
for (const command of group.commands) await runCommand(command)
return
}

// The aggregate: declared commands first (they build artifacts the discovered
// files rely on), then discovered files in parallel, then the serial tail.
const declared = resolveGroup("check")
const parallel = discoveredParallelCommands(repositoryRoot)
const serial = discoveredSerialCommands(repositoryRoot)

console.log(
`[smoke] check: ${declared.commands.length} declared, ${parallel.length} discovered (concurrency ${options.concurrency}), ${serial.length} serial`,
)

for (const command of declared.commands) await runCommand(command)

console.log(`\n[smoke] discovered phase: ${parallel.length} files at concurrency ${options.concurrency}`)
const failures = await runInParallel(parallel, options.concurrency)

for (const command of group.commands) {
await runCommand(command)
console.log(`\n[smoke] serial phase: ${serial.length} files`)
for (const command of serial) {
const outcome = await runCapturedCommand(command)
if (outcome.error) {
failures.push(outcome.error)
console.log(`\n[smoke] FAIL ${command.name}`)
console.log(outcome.output.trimEnd())
}
}

if (failures.length > 0) {
throw new Error(`${failures.length} smoke command(s) failed:\n` + failures.map((f) => ` - ${f.message}`).join("\n"))
}

console.log(`\n[smoke] check passed: ${declared.commands.length + parallel.length + serial.length} command(s)`)
}

main().catch((error: unknown) => {
Expand Down
135 changes: 135 additions & 0 deletions scripts/smoke-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { readdirSync, statSync } from "node:fs"
import { join } from "node:path"

import type { SmokeCommand } from "./smoke-manifest.js"

/*
* Test files are discovered by convention rather than registered by hand.
* Adding tests/<name>.test.ts or scripts/<name>-smoke.ts is enough to make it
* run. Anything that must not run has to be listed below with a reason, so the
* exclusions stay short, visible, and reviewable.
*/

export const DISCOVERY_PATTERNS = {
tests: /^[^/]+\.test\.(ts|mjs)$/,
scripts: /^[^/]+-smoke\.(ts|php)$/,
} as const

type Exclusion = { file: string; reason: string }

export const DISCOVERY_EXCLUSIONS: readonly Exclusion[] = [
{ file: "scripts/run-smoke.ts", reason: "the runner itself; discovering it would recurse" },

// Require an environment the aggregate does not provision.
{ file: "tests/mysqli-poll.integration.test.ts", reason: "requires Docker; runs in the agent-task-contracts workflow" },
{ file: "tests/runtime-sources-playground-integration.test.ts", reason: "exceeds the per-file budget; runs in the agent-task-contracts workflow" },
{ file: "tests/release-package-coverage.test.ts", reason: "needs the 427 MB plugin zip from package:wordpress-plugin; runs in the Homeboy gate" },
{
file: "tests/prepare-declaration-rebuild.test.ts",
reason:
"destructive to shared state: deletes packages/runtime-core/dist and runs npm install at the repository root, which breaks every concurrent import of @automattic/wp-codebox-core; runs in the Homeboy gate",
},

// Known-failing and unmaintained. Tracked for triage; see the discovery audit
// in issue #2402. These were added in June 2026, never wired to a gate, and
// have not been touched since.
{ file: "tests/artifact-reference-dtos.test.ts", reason: "failing and unmaintained; pending triage" },
{ file: "tests/browser-blueprint-ref-permission.test.ts", reason: "failing and unmaintained; pending triage" },
{ file: "tests/command-diagnostics.test.ts", reason: "failing and unmaintained; pending triage" },
{ file: "tests/docs-boundary-language.test.ts", reason: "failing and unmaintained; pending triage" },
{ file: "tests/performance-observation-contracts.test.ts", reason: "blocked on raw NUL in generated PHP; pending triage" },
{ file: "tests/rest-request-query-params.test.ts", reason: "blocked on raw NUL in generated PHP; pending triage" },
{ file: "tests/wordpress-crud-contracts.test.ts", reason: "blocked on raw NUL in generated PHP; pending triage" },
{ file: "tests/temp-runtime-cleanup.test.ts", reason: "failing and unmaintained; pending triage" },
{ file: "tests/wordpress-runtime-discovery-coverage-plan.test.ts", reason: "failing and unmaintained; pending triage" },
{ file: "scripts/agent-runtime-task-ability-smoke.ts", reason: "failing and unmaintained; pending triage" },
]

/*
* These contend on the shared Playground WordPress archive cache, or boot a
* full browser and WordPress runtime and time out when starved of CPU. They are
* correct in isolation, so they run in a serial phase after the parallel one
* rather than being excluded.
*/
export const DISCOVERY_SERIAL: readonly string[] = [
"scripts/doctor-command-smoke.ts",
"tests/bounded-recipe-plan.integration.test.ts",
"tests/phpunit-runtime-rejection.test.ts",
"tests/playground-readonly-mounts.test.ts",
"tests/playground-phpunit-bootstrap-failure.integration.test.ts",
"tests/browser-actions-navigation-capture.browser.test.ts",
"tests/editor-actions-save.integration.test.ts",
// Asserts cancellation timing, so it fails when starved rather than slowed.
"tests/browser-adaptive-exploration.test.ts",
]

/*
* Files owned by the declared chains in smoke-manifest.ts. The chains order
* them deliberately and some depend on an earlier member having run, so they are
* executed there rather than discovered independently.
*/
export const CHAIN_OWNED_FILES: readonly string[] = [
"tests/artifact-path-primitives.test.ts",
"tests/bench-command-step-behavior.test.ts",
"tests/browser-callback-materialization-contracts.test.ts",
"tests/browser-canonical-preview-origin.test.ts",
"tests/cloudflare-allocation-lifecycle.test.ts",
"tests/cloudflare-coordinator-site-partitioning.test.ts",
"tests/cloudflare-d1-operation-repository.test.ts",
"tests/cloudflare-d1-provisioner.test.mjs",
"tests/cloudflare-phase-trace.test.ts",
"tests/cloudflare-principal-credential-operator.test.mjs",
"tests/cloudflare-principal-credential-repository.test.ts",
"tests/cloudflare-provisioning-api.test.ts",
"tests/cloudflare-public-reader.test.ts",
"tests/cloudflare-queue-batch.test.ts",
"tests/cloudflare-remote-principal-credential-gate.test.mjs",
"tests/cloudflare-runtime.test.ts",
"tests/cloudflare-site-context.test.ts",
"tests/external-mysql-runtime-service.test.ts",
"tests/generic-ability-runtime-run.test.ts",
"tests/native-mariadb-runtime-service.test.ts",
"tests/runtime-services.test.ts",
"tests/source-package-compiler-primitives.test.ts",
]

const excluded = new Set([...DISCOVERY_EXCLUSIONS.map((entry) => entry.file), ...CHAIN_OWNED_FILES])
const serial = new Set(DISCOVERY_SERIAL)

function listFiles(root: string, directory: string, pattern: RegExp): string[] {
return readdirSync(join(root, directory))
.filter((entry) => pattern.test(entry))
.filter((entry) => statSync(join(root, directory, entry)).isFile())
.map((entry) => `${directory}/${entry}`)
}

export function discoverSmokeFiles(root = process.cwd()): string[] {
const files = [
...listFiles(root, "tests", DISCOVERY_PATTERNS.tests),
...listFiles(root, "scripts", DISCOVERY_PATTERNS.scripts),
]
return files.filter((file) => !excluded.has(file)).sort()
}

function toCommand(file: string): SmokeCommand {
return {
name: file,
command: file.endsWith(".php") ? "php" : file.endsWith(".mjs") ? "node" : "tsx",
args: [file],
}
}

export function discoveredCommands(root = process.cwd()): SmokeCommand[] {
return discoverSmokeFiles(root).map(toCommand)
}

/** Files safe to run concurrently. */
export function discoveredParallelCommands(root = process.cwd()): SmokeCommand[] {
return discoverSmokeFiles(root).filter((file) => !serial.has(file)).map(toCommand)
}

/** Files that must run one at a time, after the parallel phase. */
export function discoveredSerialCommands(root = process.cwd()): SmokeCommand[] {
const found = new Set(discoverSmokeFiles(root))
return DISCOVERY_SERIAL.filter((file) => found.has(file)).map(toCommand)
}
Loading
Loading