Skip to content

Commit 660d31c

Browse files
authored
Merge pull request #806 from Automattic/fix/fixture87-booking-form-loss
AI assistance: openai/gpt-5.6-sol via OpenCode was used to diagnose, implement, format, test, rebase, and verify the provider dependency lifecycle. Chris Huber remains responsible for every line.
2 parents b4ae5f8 + 6e54d2b commit 660d31c

17 files changed

Lines changed: 878 additions & 111 deletions

bench/static-site-fixture-matrix.bench.mjs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import fs from 'node:fs';
77
import path from 'node:path';
8+
import { createHash } from 'node:crypto';
89
import { fileURLToPath } from 'node:url';
910
import { createRequire } from 'node:module';
1011

@@ -403,6 +404,15 @@ export async function runFixtureMatrixBatch({ fixtures, batchIndex, matrix, outp
403404
entrypoint: matrix.entrypoint,
404405
fixtures,
405406
});
407+
// Discovery is deliberately a separate, short-lived Codebox runtime. It only
408+
// asks SSI for its registry-derived plan; package resolution happens on the
409+
// host while assembling the following fresh import runtime.
410+
const dependencyPlan = options.hostDependencyOrchestration
411+
? await discoverFixtureDependencyPlan({ fixtures, outputDirectory, staticSiteImporterPath, options, batchSuffix })
412+
: undefined;
413+
const resolvedDependencyPlan = dependencyPlan
414+
? await resolveHostDependencyPlan(dependencyPlan, path.join(outputDirectory, 'dependency-cache'))
415+
: undefined;
406416
const batchRecipe = buildFixtureMatrixRecipe({
407417
matrix: batchMatrix,
408418
runId: batchMatrix.id,
@@ -413,6 +423,7 @@ export async function runFixtureMatrixBatch({ fixtures, batchIndex, matrix, outp
413423
staticSiteImporterPath,
414424
staticSiteImporterPlugin: options.staticSiteImporterPlugin,
415425
staticSiteImporterSlug: options.staticSiteImporterSlug,
426+
dependencyPlan: resolvedDependencyPlan,
416427
dependencyOverrides: prepareDependencyOverrides(options),
417428
svgFontEvidence: true,
418429
...fixtureMatrixRecipeInput(normalizeFixtureMatrixRunConfig(Object.fromEntries(Object.keys(FIXTURE_MATRIX_RUN_FIELDS).map((key) => [key, options[key]])))),
@@ -560,6 +571,104 @@ export async function runFixtureMatrixBatch({ fixtures, batchIndex, matrix, outp
560571
};
561572
}
562573

574+
export async function resolveHostDependencyPlan(plan, cacheDirectory, fetcher = fetch) {
575+
const entries = [];
576+
for (const entry of plan.entries) {
577+
if (entry.source_kind !== 'wordpress.org-plugin' || !/^[a-z0-9][a-z0-9-_]*$/i.test(entry.slug || '')) throw new Error('Host dependency resolver received an invalid plugin declaration.');
578+
const infoUrl = new URL('https://api.wordpress.org/plugins/info/1.2/');
579+
infoUrl.searchParams.set('action', 'plugin_information');
580+
infoUrl.searchParams.set('request[slug]', entry.slug);
581+
const infoResponse = await fetcher(infoUrl, { redirect: 'error' });
582+
if (!infoResponse.ok || !/^application\/json\b/i.test(infoResponse.headers.get('content-type') || '')) throw new Error(`WordPress.org plugin info failed for ${entry.slug}.`);
583+
const info = await infoResponse.json();
584+
const version = String(info?.version || '');
585+
const source = String(info?.download_link || '');
586+
const url = new URL(source);
587+
if (!/^https:$/.test(url.protocol) || url.hostname !== 'downloads.wordpress.org' || !version || !/^\d[0-9A-Za-z._+-]*$/.test(version)) throw new Error(`WordPress.org plugin info returned an invalid immutable package for ${entry.slug}.`);
588+
const cacheKey = `${entry.slug}-${version}`;
589+
const cachePath = path.join(cacheDirectory, cacheKey, 'package.zip');
590+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
591+
let bytes;
592+
if (fs.existsSync(cachePath)) bytes = fs.readFileSync(cachePath);
593+
else {
594+
const download = await fetcher(url, { redirect: 'error' });
595+
const contentLength = Number(download.headers.get('content-length') || 0);
596+
if (!download.ok || !/^application\/(zip|octet-stream)\b/i.test(download.headers.get('content-type') || '') || (contentLength && contentLength > 100 * 1024 * 1024)) throw new Error(`WordPress.org package download failed policy validation for ${entry.slug}.`);
597+
bytes = Buffer.from(await download.arrayBuffer());
598+
if (!bytes.length || bytes.length > 100 * 1024 * 1024) throw new Error(`WordPress.org package exceeds host size policy for ${entry.slug}.`);
599+
fs.writeFileSync(cachePath, bytes);
600+
}
601+
const sha256 = createHash('sha256').update(bytes).digest('hex');
602+
entries.push({ ...entry, host_resolution: { schema: 'static-site-importer/host-package-resolution/v1', slug: entry.slug, version, source_url: url.toString(), archive_sha256: sha256, archive_path: cachePath } });
603+
}
604+
return { ...plan, entries };
605+
}
606+
607+
async function discoverFixtureDependencyPlan({ fixtures, outputDirectory, staticSiteImporterPath, options, batchSuffix }) {
608+
const plans = [];
609+
for (const fixture of fixtures) {
610+
const fixtureDirectory = path.join(outputDirectory, fixture.id);
611+
const runtimeDirectory = `/wordpress/wp-content/uploads/static-site-importer-fixture-matrix/${fixture.id}`;
612+
const planName = `dependency-plan-${batchSuffix}.json`;
613+
const artifactsDir = path.join(outputDirectory, 'dependency-discovery', `${batchSuffix}-${fixture.id}`);
614+
const recipeFile = path.join(artifactsDir, 'recipe.json');
615+
const outputFile = path.join(artifactsDir, 'output.json');
616+
fs.mkdirSync(artifactsDir, { recursive: true });
617+
const recipe = {
618+
schema: 'wp-codebox/workspace-recipe/v1',
619+
runtime: { wp: options.wordpressVersion || 'latest', blueprint: {} },
620+
inputs: {
621+
stagedFiles: [{ source: path.join(fixtureDirectory, 'artifact.json'), target: path.join(runtimeDirectory, 'artifact.json') }],
622+
extra_plugins: [{ source: staticSiteImporterPath, slug: options.staticSiteImporterSlug || 'static-site-importer', activate: true }],
623+
},
624+
workflow: { steps: [
625+
{ command: 'wordpress.wp-cli', args: [`command=plugin activate ${(options.staticSiteImporterPlugin || 'static-site-importer/static-site-importer.php')}`] },
626+
{ command: 'wordpress.wp-cli', args: [`command=static-site-importer plan-artifact-dependencies --artifact=${path.join(runtimeDirectory, 'artifact.json')} --slug=${fixture.id} --name=${JSON.stringify(fixture.label)} --output=${path.join(runtimeDirectory, planName)}`] },
627+
] },
628+
artifacts: { directory: artifactsDir, typed: [{ name: 'dependency-plan', type: 'static-site-importer/runtime-dependency-plan', path: path.join(runtimeDirectory, planName), required: true, parseJson: true, contentType: 'application/json', payloadSchema: 'static-site-importer/runtime-dependency-plan/v1' }] },
629+
};
630+
writeJsonArtifact(recipeFile, recipe);
631+
const discovery = await runWpCodeboxRecipe({ recipeFile, artifactsDir, outputFile, wpCodeboxBin: options.wpCodeboxBin, inactivityTimeoutMs: batchInactivityTimeoutMs(options) });
632+
const plan = findDependencyPlan(artifactsDir);
633+
if (!plan) throw new Error(`Dependency discovery did not persist a valid plan for fixture ${fixture.id}.`);
634+
// Discovery only mounts SSI. Provider packages are resolved and activated by
635+
// the final recipe's extra_plugins setup, before its workflow begins; asking
636+
// this runtime for a provider receipt would incorrectly require Jetpack/Woo
637+
// to be installed during planning.
638+
plans.push(plan);
639+
}
640+
const entries = new Map();
641+
for (let index = 0; index < plans.length; index += 1) {
642+
const fixtureId = fixtures[index].id;
643+
for (const entry of plans[index].entries) {
644+
const key = `${entry.source_kind}:${entry.slug}:${entry.plugin_entrypoint}`;
645+
const existing = entries.get(key);
646+
entries.set(key, {
647+
...(existing || entry),
648+
fixture_ids: [...new Set([...(existing?.fixture_ids || []), fixtureId])].sort(),
649+
});
650+
}
651+
}
652+
return { schema: 'static-site-importer/runtime-dependency-plan/v1', artifact_sha256: plans.map((plan) => plan.artifact_sha256).sort().join(','), entries: [...entries.values()] };
653+
}
654+
655+
function findDependencyPlan(directory) {
656+
const visit = (current) => {
657+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
658+
const child = path.join(current, entry.name);
659+
if (entry.isDirectory()) {
660+
const found = visit(child);
661+
if (found) return found;
662+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
663+
const parsed = parseJsonText(fs.readFileSync(child, 'utf8'));
664+
if (parsed?.schema === 'static-site-importer/runtime-dependency-plan/v1' && Array.isArray(parsed.entries)) return parsed;
665+
}
666+
}
667+
return null;
668+
};
669+
return visit(directory);
670+
}
671+
563672
function createFixtureMatrixProgress(matrix, options) {
564673
const complete = new Set();
565674
const write = typeof options.progress === 'function'

homeboy-test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"schema": "homeboy/test-manifest/v1",
33
"tests": {
44
"tests/form-materializer-smoke.php": { "environment": "standalone-php" },
5+
"tests/provider-adapter-runtime-smoke.php": { "environment": "standalone-php" },
56
"tests/smoke-ability-error-report-summary.php": { "environment": "standalone-php" },
67
"tests/smoke-ability-import-success-diagnostics.php": { "environment": "standalone-php" },
78
"tests/smoke-ability-registration-idempotent.php": { "environment": "standalone-php" },

includes/class-static-site-importer-entity-materializer-registry.php

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,67 @@ public static function materialize_plugin_dependencies( array $adapter ): array
299299
return $reports;
300300
}
301301

302+
/**
303+
* Project prepared runtime declarations to transport-neutral package artifacts.
304+
*
305+
* This is deliberately a registry projection: callers do not map providers to
306+
* packages. A host runtime may resolve these entries before a network-denied
307+
* WordPress process starts.
308+
*
309+
* @param array<string,mixed> $lifecycle Prepared runtime lifecycle.
310+
* @param string $artifact_sha256 Canonical artifact digest.
311+
* @return array<string,mixed>
312+
*/
313+
public static function dependency_plan( array $lifecycle, string $artifact_sha256 ): array {
314+
$entries = array();
315+
foreach ( $lifecycle['dependencies'] ?? array() as $declaration_id => $prepared ) {
316+
if ( ! is_array( $prepared ) || empty( $prepared['required'] ) || ! isset( $prepared['adapter'] ) || ! is_array( $prepared['adapter'] ) ) {
317+
continue;
318+
}
319+
$adapter = $prepared['adapter'];
320+
foreach ( self::plugin_dependencies( $adapter ) as $dependency ) {
321+
$slug = (string) ( $dependency['slug'] ?? '' );
322+
$plugin_file = (string) ( $dependency['plugin_file'] ?? '' );
323+
if ( '' === $slug || '' === $plugin_file ) {
324+
continue;
325+
}
326+
$key = 'wp-org:' . $slug;
327+
if ( ! isset( $entries[ $key ] ) ) {
328+
$entries[ $key ] = array(
329+
'source_kind' => 'wordpress.org-plugin',
330+
'package' => $slug,
331+
'slug' => $slug,
332+
'version_policy' => 'wordpress.org-latest-stable',
333+
'reference_policy' => 'resolver-recorded-immutable-package-digest',
334+
'plugin_entrypoint' => $plugin_file,
335+
'activation' => 'required',
336+
'integrity' => array(
337+
'entrypoint_sha256' => '',
338+
'provenance' => 'registry-declared',
339+
),
340+
'provenance' => array(
341+
'adapter_id' => (string) ( $adapter['id'] ?? '' ),
342+
'provider' => (string) ( $adapter['provider'] ?? '' ),
343+
'entity_type' => (string) ( $adapter['entity_type'] ?? '' ),
344+
'declaration_ids' => array(),
345+
),
346+
'provider_readiness' => array_merge(
347+
$dependency['provider_readiness'] ?? array(),
348+
array( 'preparation_callback' => $dependency['preparation_callback'] ?? null )
349+
),
350+
);
351+
}
352+
$entries[ $key ]['provenance']['declaration_ids'][] = (string) $declaration_id;
353+
}
354+
}
355+
ksort( $entries, SORT_STRING );
356+
return array(
357+
'schema' => 'static-site-importer/runtime-dependency-plan/v1',
358+
'artifact_sha256' => $artifact_sha256,
359+
'entries' => array_values( $entries ),
360+
);
361+
}
362+
302363
/**
303364
* Build a generated companion-plugin dependency definition from a payload.
304365
*
@@ -521,7 +582,27 @@ private static function adapters(): array {
521582
'plugin_file' => 'jetpack/jetpack.php',
522583
'availability_callback' => array( 'Static_Site_Importer_Form_Seeder', 'jetpack_forms_available' ),
523584
'preparation_callback' => array( 'Static_Site_Importer_Form_Seeder', 'prepare_jetpack_forms_runtime' ),
524-
'missing_apis' => array( 'Automattic\\Jetpack\\Forms\\ContactForm\\Contact_Form', 'jetpack/contact-form', 'jetpack/field-text' ),
585+
'provider_readiness' => array(
586+
'required_block_types' => Static_Site_Importer_Form_Seeder::required_block_types(),
587+
'required_classes' => Static_Site_Importer_Form_Seeder::required_runtime_apis(),
588+
),
589+
'missing_apis' => array(
590+
'Automattic\\Jetpack\\Forms\\ContactForm\\Contact_Form',
591+
'jetpack/contact-form',
592+
'jetpack/field-text',
593+
'jetpack/field-number',
594+
'jetpack/field-email',
595+
'jetpack/field-url',
596+
'jetpack/field-date',
597+
'jetpack/field-textarea',
598+
'jetpack/field-select',
599+
'jetpack/field-checkbox',
600+
'jetpack/field-radio',
601+
'jetpack/label',
602+
'jetpack/input',
603+
'jetpack/options',
604+
'jetpack/option',
605+
),
525606
),
526607
),
527608
),

0 commit comments

Comments
 (0)