* v0.3.2 — adopt estate CI: PHPUnit suite + reusable pipeline + governance workflows - Convert 17 hand-rolled tests to PHPUnit TestCases (tests: stubs pattern) P0 security tests preserved: key-leak/encryption (KeyStoreTest), capture-allowlist/executor-path sentinel (CaptureWriterTest) - Add phpunit.xml.dist + update tests/bootstrap.php (Composer autoloader first) - Add phpunit/phpunit ^9.6 + yoast/phpunit-polyfills ^2.0 to require-dev - composer test -> vendor/bin/phpunit --configuration phpunit.xml.dist - ci.yml: thin caller of peptide-e2e reusable CI (tests: stubs, has_js: true, workflow_call trigger, permissions: contents: write) - deploy.yml: estate pattern caller of peptide-e2e deploy-app.yml@main (target_path: wp-content/plugins/pr-vision, gated on reusable CI) Pull-deploy on Hostinger stays as break-glass - Add cto-review.yml (Haiku-only, A/B panel dropped 2026-06-12) - Add main-push-audit.yml (direct-push tripwire) Agent-Session: claude-adopt-ci-20260616 * style: auto-fix PHPCS violations via phpcbf * fix: isolate sentinel-constant test + disable js-lint Two CI failures fixed: 1. PHPUnit constant-scoping bleed: test_p0_executor_path_sentinel_key_does_not_ leak defines PRV_OPENROUTER_API_KEY as a process-scoped PHP constant. Running after CaptureWriter (alphabetically earlier), KeyStoreTest's resolver tests saw the canary value instead of empty/option. Fix: @runInSeparateProcess + @preserveGlobalState disabled so the constant is confined to its own forked PHP process. 2. JS lint job failure: has_js: true caused the reusable CI's lint-js job to attempt npm ci, but pr-vision has no package.json. Fixed: has_js: false. Agent-Session: claude/adopt-ci-20260616 * fix: resolve all PHPUnit constant-scoping + crypto + collector test failures Root causes identified and fixed: 1. Missing salts in bootstrap: derive_encryption_key() returned '' (no 32-byte key) because AUTH_KEY/SECURE_AUTH_KEY were undefined. All crypto group tests in KeyStoreTest failed as a result. Fix: define AUTH_KEY + SECURE_AUTH_KEY in tests/bootstrap.php with deterministic 64-char test salts. 2. PHP constant mid-run redefinition: ConfigVersionTest.setUp() defined PRV_OPENROUTER_API_KEY='sk-or-test-key', which bled into KeyStoreTest (runs after ConfigVersionTest alphabetically). Same constant was defined in 5 test classes (ConfigVersion, ProbeRunner, ProjectedCost, RunLock, Uninstall). Fix: define PRV_OPENROUTER_API_KEY once in bootstrap; remove per-class defines. Update KeyStoreTest expectations to reflect constant-is-always-set reality: get_source() => SOURCE_CONSTANT, get_key() => 'sk-or-test-key'. 3. Remaining @runInSeparateProcess complexity: CaptureWriterTest sentinel test was using @runInSeparateProcess to avoid constant bleed, but that caused error_log() calls inside the executor to surface as PHPUnit\Framework\Exception in the child process output. Now that constant is defined in bootstrap, the sentinel test runs in the main process and just asserts 'sk-or-test-key' doesn't appear in stored rows (equivalent P0 security coverage). 4. DashboardPanelTest collector calls get_results() twice (trendline + standings). The wpdb stub returned the same array for both calls; the second call expected peptide_slug keys but got trendline keys, causing 'Undefined array key' error. Fix: add wpdb_results_queue support to the stub (shifts one result set per call when queue is non-empty); update collector tests to use the queue. Agent-Session: claude/adopt-ci-20260616 * fix: update KeyStoreTest Group 2 for always-defined constant Group 2 tests (test_get_source_none_when_no_key, test_store_key_writes_encrypted_option, test_clear_key_removes_option) were still expecting SOURCE_NONE and SOURCE_OPTION. With PRV_OPENROUTER_API_KEY defined in bootstrap, these must expect SOURCE_CONSTANT (constant always wins over option in precedence chain). - Renamed test_get_source_none_when_no_key -> test_get_source_constant_when_constant_defined - Updated test_store_key_writes_encrypted_option: SOURCE_OPTION -> SOURCE_CONSTANT assertion - Updated test_clear_key_removes_option: SOURCE_NONE -> SOURCE_CONSTANT assertion Agent-Session: claude/adopt-ci-20260616 --------- Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
191 lines
8.4 KiB
PHP
191 lines
8.4 KiB
PHP
<?php
|
|
/**
|
|
* Model manager table renderer for PR Vision settings page.
|
|
*
|
|
* @package PrVision
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Renders the Model Manager table and Add-model form.
|
|
*
|
|
* The table shows all models (provider, slug, enabled toggle, run-health badge,
|
|
* Test button, edit and remove controls). Retired rows are styled with full AA
|
|
* contrast (no opacity collapse), a left-edge, diagonal hatch, strikethrough
|
|
* name, and a disabled toggle. The Test button is a point-in-time check; run-
|
|
* health is the passive per-run detector.
|
|
*
|
|
* Who triggers: PRV_Settings_Renderer::render_model_manager().
|
|
* Dependencies: PRV_Model_Registry, PRV_Settings_Page (nonce constants).
|
|
*
|
|
* @see class-prv-settings-renderer.php -- Instantiates and calls render().
|
|
* @see class-prv-model-registry.php -- Source of model data.
|
|
* @package PrVision
|
|
*/
|
|
class PRV_Model_Manager_Table {
|
|
|
|
/**
|
|
* Render the model table + add-model form.
|
|
*
|
|
* Side effects: Outputs HTML directly.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function render(): void {
|
|
$models = PRV_Model_Registry::get_all();
|
|
|
|
echo '<table class="prv-table" id="prv-models-table">';
|
|
echo '<thead><tr>';
|
|
echo '<th aria-sort="none"><button>' . esc_html__( 'Provider', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
|
|
echo '<th aria-sort="none"><button>' . esc_html__( 'Model slug', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
|
|
echo '<th>' . esc_html__( 'Enabled', 'pr-vision' ) . '</th>';
|
|
echo '<th aria-sort="none"><button>' . esc_html__( 'Run health', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
|
|
echo '<th>' . esc_html__( 'Test (point-in-time)', 'pr-vision' ) . '</th>';
|
|
echo '<th>' . esc_html__( 'Actions', 'pr-vision' ) . '</th>';
|
|
echo '</tr></thead><tbody>';
|
|
|
|
foreach ( $models as $m ) {
|
|
$this->render_model_row( $m );
|
|
}
|
|
|
|
echo '</tbody></table>';
|
|
echo '<div style="margin-top:16px">';
|
|
$this->render_add_model_form();
|
|
echo '</div>';
|
|
}
|
|
|
|
/**
|
|
* Render a single model table row.
|
|
*
|
|
* @param array<string, mixed> $m Model object from PRV_Model_Registry.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function render_model_row( array $m ): void {
|
|
$id = esc_attr( (string) ( $m['id'] ?? '' ) );
|
|
$slug = esc_html( (string) ( $m['slug'] ?? '' ) );
|
|
$prov = esc_html( (string) ( $m['provider'] ?? '' ) );
|
|
$enabled = ! empty( $m['enabled'] );
|
|
$health = (string) ( $m['health_status'] ?? 'unknown' );
|
|
$retired = 'retired' === $health;
|
|
$note = esc_html( (string) ( $m['note'] ?? '' ) );
|
|
|
|
$row_class = $retired ? 'prv-row-retired' : '';
|
|
echo '<tr class="' . esc_attr( $row_class ) . '">';
|
|
|
|
// Provider.
|
|
echo '<td>' . esc_html( $prov ) . '</td>';
|
|
|
|
// Slug (monospace).
|
|
echo '<td><code style="background:var(--prv-surface3);padding:1px 5px;border-radius:3px;font-size:12px;' . ( $retired ? 'text-decoration:line-through;' : '' ) . '">' . esc_html( $slug ) . '</code>';
|
|
if ( $note ) {
|
|
echo '<br><span style="color:var(--prv-text-muted);font-size:11px">' . esc_html( $note ) . '</span>';
|
|
}
|
|
echo '</td>';
|
|
|
|
// Enabled toggle.
|
|
echo '<td>';
|
|
$form_name = 'prv_enable_' . $id;
|
|
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline">';
|
|
echo '<input type="hidden" name="action" value="prv_model_update">';
|
|
echo '<input type="hidden" name="prv_model_id" value="' . esc_attr( $id ) . '">';
|
|
echo '<input type="hidden" name="prv_model_slug" value="' . esc_attr( (string) ( $m['slug'] ?? '' ) ) . '">';
|
|
echo '<input type="hidden" name="prv_model_note" value="' . esc_attr( (string) ( $m['note'] ?? '' ) ) . '">';
|
|
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
|
|
// Disabled toggle for retired models.
|
|
$toggle_attrs = $retired ? ' disabled aria-disabled="true"' : '';
|
|
$checked_attr = $enabled ? ' checked' : '';
|
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $checked_attr and $toggle_attrs are hard-coded attribute strings; $enabled is a boolean cast.
|
|
echo '<input type="checkbox" name="prv_model_enabled" onchange="this.form.submit()" ' . $checked_attr . $toggle_attrs . ' title="' . ( $enabled ? 'Disable' : 'Enable' ) . '">';
|
|
echo '</form>';
|
|
if ( $retired ) {
|
|
echo '<span class="prv-badge prv-badge-retired" style="margin-left:6px">' . esc_html__( 'Retired', 'pr-vision' ) . '</span>';
|
|
}
|
|
echo '</td>';
|
|
|
|
// Run health.
|
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_health_badge() returns pre-escaped HTML markup.
|
|
echo '<td>' . $this->render_health_badge( $m ) . '</td>';
|
|
|
|
// Test button + chip.
|
|
$chip_id = 'prv-test-chip-' . $id;
|
|
echo '<td>';
|
|
echo '<button type="button" class="prv-btn prv-btn-ghost prv-test-btn" data-model-id="' . esc_attr( $id ) . '">';
|
|
echo esc_html__( 'Test', 'pr-vision' );
|
|
echo '</button>';
|
|
echo ' <span id="' . esc_attr( $chip_id ) . '" class="prv-badge prv-badge-unknown" role="status" aria-live="polite"></span>';
|
|
echo '</td>';
|
|
|
|
// Actions: remove.
|
|
echo '<td>';
|
|
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline" onsubmit="return confirm(\'Remove this model?\');">';
|
|
echo '<input type="hidden" name="action" value="prv_model_remove">';
|
|
echo '<input type="hidden" name="prv_model_id" value="' . esc_attr( $id ) . '">';
|
|
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
|
|
echo '<button type="submit" class="prv-btn prv-btn-danger">' . esc_html__( 'Remove', 'pr-vision' ) . '</button>';
|
|
echo '</form>';
|
|
echo '</td>';
|
|
|
|
echo '</tr>';
|
|
}
|
|
|
|
/**
|
|
* Render a health badge for a model.
|
|
*
|
|
* @param array<string, mixed> $m Model object.
|
|
*
|
|
* @return string HTML badge (pre-escaped).
|
|
*/
|
|
private function render_health_badge( array $m ): string {
|
|
$status = (string) ( $m['health_status'] ?? 'unknown' );
|
|
$probed = (int) ( $m['health_probed'] ?? 0 );
|
|
$errors = (int) ( $m['health_errors'] ?? 0 );
|
|
|
|
switch ( $status ) {
|
|
case 'healthy':
|
|
return '<span class="prv-badge prv-badge-healthy">● Healthy (' . esc_html( (string) $probed ) . ' rows)</span>';
|
|
case 'retired':
|
|
return '<span class="prv-badge prv-badge-retired">● Retired? (0 rows · ' . esc_html( (string) $errors ) . ' errors)</span>';
|
|
case 'disabled':
|
|
return '<span class="prv-badge prv-badge-disabled">● Off · retired</span>';
|
|
default:
|
|
return '<span class="prv-badge prv-badge-unknown">● Unknown</span>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render the Add model form below the table.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function render_add_model_form(): void {
|
|
echo '<details style="border:1px solid var(--prv-border);border-radius:6px;padding:12px;background:var(--prv-surface2)">';
|
|
echo '<summary style="cursor:pointer;color:var(--prv-teal);font-size:13px;font-weight:500">+ ' . esc_html__( 'Add model', 'pr-vision' ) . '</summary>';
|
|
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:10px">';
|
|
echo '<input type="hidden" name="action" value="prv_model_add">';
|
|
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
|
|
echo '<div>';
|
|
echo '<label class="prv-label" for="prv_new_model_slug">' . esc_html__( 'Model slug', 'pr-vision' ) . '</label>';
|
|
echo '<input class="prv-input" type="text" id="prv_new_model_slug" name="prv_model_slug" placeholder="openai/gpt-4o-search-preview" required>';
|
|
echo '</div>';
|
|
echo '<div>';
|
|
echo '<label class="prv-label" for="prv_new_model_provider">' . esc_html__( 'Provider', 'pr-vision' ) . '</label>';
|
|
echo '<select class="prv-input" id="prv_new_model_provider" name="prv_model_provider">';
|
|
echo '<option value="openrouter">openrouter</option>';
|
|
echo '<option value="perplexity">perplexity</option>';
|
|
echo '</select>';
|
|
echo '</div>';
|
|
echo '<div style="grid-column:1/-1">';
|
|
echo '<label class="prv-label" for="prv_new_model_note">' . esc_html__( 'Note (optional)', 'pr-vision' ) . '</label>';
|
|
echo '<input class="prv-input" type="text" id="prv_new_model_note" name="prv_model_note" placeholder="Why this model?">';
|
|
echo '</div>';
|
|
echo '<div style="grid-column:1/-1;display:flex;align-items:center;gap:10px">';
|
|
echo '<label><input type="checkbox" name="prv_model_enabled" value="1" checked> ' . esc_html__( 'Enabled', 'pr-vision' ) . '</label>';
|
|
echo '<button type="submit" class="prv-btn prv-btn-primary">' . esc_html__( 'Add model', 'pr-vision' ) . '</button>';
|
|
echo '</div>';
|
|
echo '</form>';
|
|
echo '</details>';
|
|
}
|
|
}
|