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
66 changes: 66 additions & 0 deletions packages/runtime-playground/src/mount-materialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ interface HostMountFileMaterializationResponse {
skipped?: number
}

interface HostMountFileVerificationResponse {
schema?: string
repaired?: number
skipped?: number
}

const HOST_MOUNT_FILE_BATCH_SIZE = 100
const HOST_MOUNT_DIRECTORY_BATCH_SIZE = 500

Expand Down Expand Up @@ -200,6 +206,7 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
let skipped = 0
const directoryBatch: string[] = []
const fileBatch: HostMountFilePayload[] = []
const verificationBatch: HostMountFilePayload[] = []

const flushDirectories = async () => {
if (directoryBatch.length === 0) {
Expand All @@ -224,6 +231,13 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
created += result.created
skipped += result.skipped
}
const flushVerificationBatch = async () => {
if (verificationBatch.length === 0) {
return
}
const result = await verifyHostMountFilesWithPhp(server, verificationBatch.splice(0, verificationBatch.length))
skipped += result.skipped
}
const writeFilePayload = async (payload: HostMountFilePayload) => {
if (!server.playground.writeFile) {
fileBatch.push(payload)
Expand All @@ -249,6 +263,10 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
try {
await server.playground.writeFile(target, text)
materialized++
verificationBatch.push(payload)
if (verificationBatch.length >= HOST_MOUNT_FILE_BATCH_SIZE) {
await flushVerificationBatch()
}
} catch {
const fallback = await materializeHostMountFilesWithPhp(server, [payload], [])
materialized += fallback.materialized
Expand All @@ -271,6 +289,7 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
}
await flushDirectories()
await flushFileBatch()
await flushVerificationBatch()
return { materialized, created, skipped }
}

Expand Down Expand Up @@ -366,6 +385,19 @@ async function materializeHostMountFilesWithPhp(server: PlaygroundCliServer, fil
}
}

async function verifyHostMountFilesWithPhp(server: PlaygroundCliServer, files: HostMountFilePayload[]): Promise<{ repaired: number; skipped: number }> {
const response = await server.playground.run({ code: hostMountVerifyPhp(files) })
assertPlaygroundResponseOk("playground-staged-input-verify", response)
const parsed = parseMaterializationJson<HostMountFileVerificationResponse>(response.text, "wp-codebox/host-mount-verification/v1", "playground-staged-input-verify")
if ((parsed.skipped ?? 0) > 0) {
throw new Error(`playground-staged-input-verify could not preserve ${parsed.skipped} staged input file(s)`)
}
return {
repaired: parsed.repaired ?? 0,
skipped: parsed.skipped ?? 0,
}
}

function parseMaterializationJson<T extends { schema?: string }>(text: string, schema: string, command: string): T {
let parsed: unknown
try {
Expand Down Expand Up @@ -493,6 +525,7 @@ foreach (($payload['directories'] ?? array()) as $directory) {
}
$skipped++;
}

foreach (($payload['files'] ?? array()) as $file) {
$target = (string) ($file['target'] ?? '');
$contents = (string) ($file['contentsBase64'] ?? '');
Expand All @@ -516,6 +549,39 @@ echo json_encode(array('schema' => 'wp-codebox/host-mount-materialization/v1', '
`
}

function hostMountVerifyPhp(files: HostMountFilePayload[]): string {
const payload = JSON.stringify(JSON.stringify({ files }))
return `<?php
$payload = json_decode(${payload}, true);
$repaired = 0;
$skipped = 0;
foreach (($payload['files'] ?? array()) as $file) {
$target = (string) ($file['target'] ?? '');
$contents = base64_decode((string) ($file['contentsBase64'] ?? ''), true);
if ('' === $target || str_contains($target, "\0") || false === $contents) {
$skipped++;
continue;
}
$target_hash = is_file($target) ? hash_file('sha256', $target) : false;
if (is_string($target_hash) && hash_equals(hash('sha256', $contents), $target_hash)) {
continue;
}
$directory = dirname($target);
if ((!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) || false === file_put_contents($target, $contents)) {
$skipped++;
continue;
}
$repaired_hash = hash_file('sha256', $target);
if (!is_string($repaired_hash) || !hash_equals(hash('sha256', $contents), $repaired_hash)) {
$skipped++;
continue;
}
$repaired++;
}
echo json_encode(array('schema' => 'wp-codebox/host-mount-verification/v1', 'repaired' => $repaired, 'skipped' => $skipped), JSON_UNESCAPED_SLASHES);
`
}

function hostMountMkdirPhp(directories: string[]): string {
const payload = JSON.stringify(JSON.stringify({ directories }))
return `<?php
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/phpunit-playground-harness/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"yoast/phpunit-polyfills": "3.1.2"
},
"config": {
"classmap-authoritative": true,
"optimize-autoloader": true,
"platform": {
"php": "8.3.0"
},
Expand Down
53 changes: 52 additions & 1 deletion tests/mount-materialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ try {

const result = await materializePlaygroundStagedInputs({
playground: {
async run() { return { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 1, skipped: 0 }) } },
async run({ code }: { code: string }) {
return code.includes("wp-codebox/host-mount-verification/v1")
? { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: 0 }) }
: { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 1, skipped: 0 }) }
},
async writeFile(target: string, contents: string) { writes[target] = contents },
},
} as never, [{
Expand Down Expand Up @@ -45,6 +49,9 @@ try {
playground: {
async run({ code }: { code: string }) {
const payload = materializationPayload(code)
if (code.includes("wp-codebox/host-mount-verification/v1")) {
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: 0 }) }
}
for (const directory of payload.directories ?? []) {
readableDirectories.add(directory)
}
Expand Down Expand Up @@ -108,6 +115,50 @@ try {
await rm(fallbackSource, { recursive: true, force: true })
}

const silentlyDroppedSource = await mkdtemp(join(tmpdir(), "wp-codebox-silently-dropped-materialization-"))
const verifiedFiles: Record<string, string> = {}

try {
await mkdir(join(silentlyDroppedSource, "composer"), { recursive: true })
await writeFile(join(silentlyDroppedSource, "composer", "autoload_real.php"), "<?php require __DIR__ . '/autoload_static.php';")
await writeFile(join(silentlyDroppedSource, "composer", "autoload_static.php"), "<?php class ComposerStaticInitFixture {}")

const result = await materializePlaygroundStagedInputs({
playground: {
async run({ code }: { code: string }) {
const payload = materializationPayload(code)
if (code.includes("wp-codebox/host-mount-verification/v1")) {
let repaired = 0
for (const file of payload.files ?? []) {
const contents = Buffer.from(file.contentsBase64, "base64").toString("utf8")
if (verifiedFiles[file.target] !== contents) {
verifiedFiles[file.target] = contents
repaired++
}
}
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired, skipped: 0 }) }
}
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: payload.directories?.length ?? 0, skipped: 0 }) }
},
async writeFile(target: string, contents: string) {
if (!target.endsWith("autoload_static.php")) {
verifiedFiles[target] = contents
}
},
},
} as never, [{
type: "directory",
source: silentlyDroppedSource,
target: "/wp-codebox-vendor",
mode: "readonly",
}])

assert.equal(result.materialized, 2)
assert.equal(verifiedFiles["/wp-codebox-vendor/composer/autoload_static.php"], "<?php class ComposerStaticInitFixture {}", "verification repairs a direct writer that silently omits a generated companion file")
} finally {
await rm(silentlyDroppedSource, { recursive: true, force: true })
}

const unreadableTargetSource = await mkdtemp(join(tmpdir(), "wp-codebox-unreadable-target-"))

try {
Expand Down
5 changes: 5 additions & 0 deletions tests/playground-phpunit-readonly-cache.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const sentinel = Buffer.from([0, 255, 1, 2, 3, 127, 128])
try {
await cp("tests/fixtures/phpunit-playground-harness", harness, { recursive: true })
await execFileAsync("composer", ["install", "--no-interaction", "--prefer-dist"], { cwd: harness, timeout: 300_000, maxBuffer: 2 * 1024 * 1024 })
const autoloadReal = await readFile(join(harness, "vendor", "composer", "autoload_real.php"), "utf8")
const staticClass = autoloadReal.match(/ComposerStaticInit[\da-f]+/i)?.[0]
assert.ok(staticClass, "fixture uses Composer's optimized static autoloader")
assert.match(await readFile(join(harness, "vendor", "composer", "autoload_static.php"), "utf8"), new RegExp(`class ${staticClass}`), "generated Composer autoload files define the same static initializer")
await writeFixture()
const sourceDigest = await digestTree(plugin)

Expand Down Expand Up @@ -53,6 +57,7 @@ try {
await assert.rejects(readFile(join(plugin, ".phpunit.result.cache")), /ENOENT/, "PHPUnit must not create a host-source result cache")
const runtime = JSON.parse(await readFile(join(artifactsPath, "latest-runtime.json"), "utf8")) as { paths?: { runtimeDirectory?: string } }
const diagnostic = await readFile(join(artifactsPath, runtime.paths?.runtimeDirectory ?? "", "files/phpunit/.pg-test-result.txt"), "utf8")
assert.match(diagnostic, /^DISCOVERY: .* found=1$/m, "actual PHPUnit runner must discover the fixture test file")
assert.match(diagnostic, /^STAGE_BEGIN:run_tests/m, "actual PHPUnit runner must reach its test stage")
} finally {
await rm(root, { recursive: true, force: true })
Expand Down
9 changes: 7 additions & 2 deletions tests/staged-input-materialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ await withTempDir("wp-codebox-staged-input-materialization-", async (root) => {
const written = new Map<string, string>()
const server = {
playground: {
async run() {
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 2, skipped: 0 }) }
async run({ code }: { code: string }) {
return code.includes("wp-codebox/host-mount-verification/v1")
? { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: 0 }) }
: { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 2, skipped: 0 }) }
},
async writeFile(path: string, contents: string) {
written.set(path, contents)
Expand Down Expand Up @@ -50,6 +52,9 @@ await withTempDir("wp-codebox-staged-input-materialization-fallback-", async (ro
const server = {
playground: {
async run({ code }: { code: string }) {
if (code.includes("wp-codebox/host-mount-verification/v1")) {
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: 0 }) }
}
if (code.includes("wp-codebox/host-mount-materialization/v1")) {
fallbackWrites++
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-materialization/v1", materialized: 1, skipped: 0 }) }
Expand Down
Loading