Skip to content
111 changes: 111 additions & 0 deletions packages/php-wasm/node/src/test/unzip-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
zipDirectory,
RecommendedPHPVersion,
} from '@wp-playground/common';
import type { UnzipProgress } from '@wp-playground/common';
import { phpVars } from '@php-wasm/util';
import { PHP } from '@php-wasm/universal';
import { loadNodeRuntime } from '../lib';
Expand Down Expand Up @@ -78,6 +79,26 @@ describe('unzipFile – concurrent calls avoid conflicts', () => {
expect(php.readFileAsText('/dst/absolute.txt')).toBe('absolute');
});

it('keeps normalized entries inside the target while reporting progress', async () => {
const zip = await createZipBuffer(php, {
'../escape.txt': 'escape',
'/absolute.txt': 'absolute',
});

await unzipFile(
php,
new File([zip], 'normalized-progress.zip'),
'/dst',
true,
() => {}
);

expect(php.fileExists('/escape.txt')).toBe(false);
expect(php.fileExists('/absolute.txt')).toBe(false);
expect(php.readFileAsText('/dst/escape.txt')).toBe('escape');
expect(php.readFileAsText('/dst/absolute.txt')).toBe('absolute');
});

it('preserves existing files when overwriteFiles is false', async () => {
php.mkdir('/dst');
php.writeFile('/dst/existing.txt', 'old');
Expand All @@ -96,6 +117,74 @@ describe('unzipFile – concurrent calls avoid conflicts', () => {
expect(php.readFileAsText('/dst/existing.txt')).toBe('old');
expect(php.readFileAsText('/dst/fresh.txt')).toBe('fresh');
});

it('reports progress after every 100 files', async () => {
const zip = await createZipBufferWithFileSizes(
php,
new Array(201).fill(1)
);
const updates: UnzipProgress[] = [];

await unzipFile(
php,
new File([zip], 'many-files.zip'),
'/dst',
true,
(progress) => updates.push(progress)
);

expect(updates.map(({ filesProcessed }) => filesProcessed)).toEqual([
100, 200, 201,
]);
expect(updates.at(-1)).toEqual({
filesProcessed: 201,
totalFiles: 201,
uncompressedBytesProcessed: 201,
totalUncompressedBytes: 201,
});
expect(php.readFileAsText('/dst/file-200.txt')).toBe('x');
});

it('reports progress after every 400 KiB of uncompressed data', async () => {
const zip = await createZipBufferWithFileSizes(
php,
new Array(5).fill(200 * 1024)
);
const processedBytes: number[] = [];

await unzipFile(
php,
new File([zip], 'large-files.zip'),
'/dst',
true,
({ uncompressedBytesProcessed }) =>
processedBytes.push(uncompressedBytesProcessed)
);

expect(processedBytes).toEqual([400 * 1024, 800 * 1024, 1000 * 1024]);
});

it('reports progress on PHP 5.2', async () => {
const php52 = new PHP(await loadNodeRuntime('5.2'));
try {
const zip = await createZipBufferWithFileSizes(php52, [400 * 1024]);
const updates: UnzipProgress[] = [];

await unzipFile(
php52,
new File([zip], 'php-52.zip'),
'/dst-php-52',
true,
(progress) => updates.push(progress)
);

expect(updates).toHaveLength(1);
expect(updates[0].uncompressedBytesProcessed).toBe(400 * 1024);
expect(php52.fileExists('/dst-php-52/file-0.txt')).toBe(true);
} finally {
php52.exit();
}
});
});

/**
Expand Down Expand Up @@ -125,3 +214,25 @@ async function createZipBuffer(php: PHP, entries: Record<string, string>) {
await php.unlink(zipPath);
return zip;
}

async function createZipBufferWithFileSizes(php: PHP, fileSizes: number[]) {
const zipPath = `/tmp/source-${Math.random()}.zip`;
const js = phpVars({ fileSizes, zipPath });
await php.run({
code: `<?php
$fileSizes = ${js.fileSizes};
$zip = new ZipArchive;
$res = $zip->open(${js.zipPath}, ZipArchive::CREATE);
if ($res !== TRUE) {
throw new Exception('Failed to create ZIP: ' . $res);
}
foreach ($fileSizes as $index => $size) {
$zip->addFromString("file-$index.txt", str_repeat('x', $size));
}
$zip->close();
`,
});
const zip = await php.readFileAsBuffer(zipPath);
await php.unlink(zipPath);
return zip;
}
37 changes: 37 additions & 0 deletions packages/php-wasm/universal/src/lib/php-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type LimitedPHPApi = Pick<
| 'fileExists'
| 'chdir'
| 'run'
| 'runStream'
| 'onMessage'
> & {
documentRoot: PHP['documentRoot'];
Expand Down Expand Up @@ -217,6 +218,42 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable {
}
}

/**
* Starts a PHP request and returns its output streams before PHP exits.
*
* A pooled PHP instance remains checked out until `response.finished`
* settles. If the request fails before returning a response, the instance
* is released immediately. A non-zero PHP exit is exposed through
* `response.exitCode` instead of making this method reject.
*
* @param request - PHP code or script path, request metadata, and environment.
* @returns A streamed response whose output can be consumed incrementally.
* @throws When a PHP instance cannot be acquired or the request cannot start.
*/
async runStream(request: PHPRunOptions): Promise<StreamedPHPResponse> {
const state = _private.get(this)!;
const primaryPhp = state.php;
if (
!state.requestHandler &&
!primaryPhp?.requestHandler &&
primaryPhp
) {
return await primaryPhp.runStream(request);
}
const { php, reap } = await this.acquirePHPInstance();
let response: StreamedPHPResponse;
try {
response = await php.runStream(request);
} catch (error) {
reap();
throw error;
}
// The caller still owns the response streams. Keep this PHP instance
// checked out until the process behind those streams has finished.
void response.finished.finally(reap);
return response;
Comment thread
adamziel marked this conversation as resolved.
}

/** @inheritDoc @php-wasm/universal!/PHP.cli */
async cli(
argv: string[],
Expand Down
37 changes: 37 additions & 0 deletions packages/php-wasm/universal/src/test/php-worker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PHPWorker } from '../lib/php-worker';
import { describe, expect, test, vi } from 'vitest';
import type { PHP } from '../lib/php';
import type { PHPRequestHandler } from '../lib/php-request-handler';
import type { StreamedPHPResponse } from '../lib/php-response';

type PhpEvent = { type: string; [key: string]: unknown };
type PhpEventListener = (event: PhpEvent) => void | Promise<void>;
Expand Down Expand Up @@ -216,6 +217,42 @@ describe('PlaygroundWorkerEndpoint', () => {
);
});

test('keeps a pooled PHP instance alive until runStream finishes', async () => {
let finish!: () => void;
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
const response = { finished } as StreamedPHPResponse;
const reap = vi.fn();
const streamedPhp = {
chdir: vi.fn(),
runStream: vi.fn().mockResolvedValue(response),
addEventListener: vi.fn(),
onMessage: vi.fn(),
};
const requestHandler = {
absoluteUrl: 'http://127.0.0.1/',
documentRoot: '/wordpress',
instanceManager: {
acquirePHPInstance: vi.fn().mockResolvedValue({
php: streamedPhp,
reap,
}),
},
};
const endpoint = new TestEndpoint(
requestHandler as unknown as PHPRequestHandler
);
const request = { code: "<?php echo 'hi!';" };

await expect(endpoint.runStream(request)).resolves.toBe(response);
expect(streamedPhp.runStream).toHaveBeenCalledWith(request);
expect(reap).not.toHaveBeenCalled();

finish();
await vi.waitFor(() => expect(reap).toHaveBeenCalledOnce());
});

test('uses the primary PHP instance before resolving a missing request handler', async () => {
const endpoint = new EndpointWithoutRequestHandler();
const cliResponse = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { StepHandler } from '.';
import { unzip } from './unzip';
import { logger } from '@php-wasm/logger';
import {
dirname,
Expand All @@ -9,6 +8,8 @@ import {
randomFilename,
} from '@php-wasm/util';
import type { UniversalPHP } from '@php-wasm/universal';
import type { UnzipProgress } from '@wp-playground/common';
import { unzipFile } from '@wp-playground/common';
import { ensureWpConfig } from '@wp-playground/wordpress';
import { getLegacyPlaygroundRuntimeWpContentPaths } from '../utils/legacy-playground-runtime-wp-content-paths';
import { wpContentPathsExcludedFromLegacyExports } from '../utils/legacy-wp-content-paths-excluded-from-exports';
Expand Down Expand Up @@ -78,10 +79,33 @@ export const importWordPressFiles: StepHandler<
let oldSiteUrl: string | null = null;
try {
await playground.mkdir(unzipRoot);
await unzip(playground, {
zipFile: wordPressFilesZip,
extractToPath: unzipRoot,
});
// Unpacking owns the first 30% of the import. Bytes account for unequal
// entry sizes; file counts handle archives containing only empty files.
let reportUnzipProgress:
| ((progress: UnzipProgress) => void)
| undefined;
if (progress) {
reportUnzipProgress = ({
filesProcessed,
totalFiles,
uncompressedBytesProcessed,
totalUncompressedBytes,
}) => {
let fraction = filesProcessed / Math.max(totalFiles, 1);
if (totalUncompressedBytes > 0) {
fraction =
uncompressedBytesProcessed / totalUncompressedBytes;
}
progress.tracker.set(fraction * 30);
};
}
await unzipFile(
playground,
wordPressFilesZip,
unzipRoot,
true,
reportUnzipProgress
);
let importPath = joinPaths(unzipRoot, pathInZip);
importPath =
(await findWordPressFilesRoot(playground, importPath)) ||
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { PHP, PHPRequestHandler } from '@php-wasm/universal';
import { ProgressTracker } from '@php-wasm/progress';
import type { ProgressTrackerEvent } from '@php-wasm/progress';
import { RecommendedPHPVersion } from '@wp-playground/common';
import { importWordPressFiles } from '../../lib/steps/import-wordpress-files';
import { zipWpContent } from '../../lib/steps/zip-wp-content';
Expand Down Expand Up @@ -80,6 +82,43 @@ describe('Blueprint step importWordPressFiles', () => {
expect(manifest.siteUrl).toContain(`scope:${sourceScope}`);
});

it('should report progress while unpacking the archive', async () => {
const zipPath = joinPaths('/tmp', `${randomFilename()}.zip`);
await targetPHP.run({
code: `<?php
$zip = new ZipArchive();
$zip->open(${phpVar(zipPath)}, ZipArchive::CREATE);
for ($i = 0; $i < 5; $i++) {
$zip->addFromString(
"wp-content/uploads/progress-$i.txt",
str_repeat('x', 200 * 1024)
);
}
$zip->close();
`,
});
const tracker = new ProgressTracker();
const reportedProgress: number[] = [];
tracker.addEventListener('progress', (event: ProgressTrackerEvent) => {
reportedProgress.push(event.detail.progress);
});

await importWordPressFiles(
targetPHP,
{
wordPressFilesZip: new File(
[await targetPHP.readFileAsBuffer(zipPath)],
'progress.zip'
),
},
{ tracker }
);

expect(
reportedProgress.some((progress) => progress > 0 && progress < 30)
).toBe(true);
});

it('should export all user-owned wp-content files and wp-config.php', async () => {
const documentRoot = await sourcePHP.documentRoot;
const customizedThemeFile = joinPaths(
Expand Down
Loading
Loading