feat: admin settings page UI/UX pass with WordPress-native patterns
- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs, aria-current on the active tab; own CSS/JS enqueued only on this screen. - Verbindung: ordered "So verbindest du Claude" steps, copy button (core clipboard.js + wp.a11y.speak), Site-Health-style status list, German details. - Zugriff: roles as form-table fieldset, connections with site date format, empty-state row, confirm on revoke; ConnectionUsage records last use via WP_Application_Passwords::record_application_password_usage (once a day). - Tools: plain-language legend, counter as inline notice, groups as core postboxes (postbox.js toggles, per-user closed state, summary when closed), fixed column widths with new "Art" column, submit above and below. - Protokoll: site-timezone times with UTC tooltip, result icons, filters with labels in the list table nav, retention link to Limits. - Limits: short labels with units and descriptions; snapshot count hidden but preserved. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,14 @@ if (! class_exists('WP_List_Table')) {
|
||||
{
|
||||
}
|
||||
|
||||
/** Like the real class: the top table nav (with extra_tablenav()) comes before the rows. */
|
||||
public function display(): void
|
||||
{
|
||||
$this->extra_tablenav('top');
|
||||
}
|
||||
|
||||
/** @param string $which */
|
||||
protected function extra_tablenav($which)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,21 +171,36 @@ final class AccessTabTest extends TestCase
|
||||
(new AccessTab(new Access(), $connections))->handleRevoke();
|
||||
}
|
||||
|
||||
public function testRenderShowsRolesAndConnections(): void
|
||||
/** @param mixed $roles value of the roles option */
|
||||
private function stubRender(mixed $roles): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn(['editor']);
|
||||
Functions\when('get_option')->alias(static fn (string $key, $default = false) => match ($key) {
|
||||
'lit_mcp_allowed_roles' => $roles,
|
||||
'date_format' => 'd.m.Y',
|
||||
'time_format' => 'H:i',
|
||||
default => $default,
|
||||
});
|
||||
Functions\when('translate_user_role')->returnArg();
|
||||
Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : '');
|
||||
Functions\when('settings_fields')->justReturn(null);
|
||||
Functions\when('submit_button')->alias(static function (): void {
|
||||
echo '<button>Speichern</button>';
|
||||
Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit', $wrap = true, $other = ''): void {
|
||||
$attrs = is_array($other) ? implode(' ', array_map(
|
||||
static fn ($k, $v): string => sprintf('%s="%s"', $k, $v),
|
||||
array_keys($other),
|
||||
$other
|
||||
)) : '';
|
||||
echo '<button ' . $attrs . '>' . ($text ?? 'Speichern') . '</button>';
|
||||
});
|
||||
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
|
||||
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
||||
});
|
||||
Functions\when('wp_date')->justReturn('25.09.2026 10:00');
|
||||
$connections = Mockery::mock(ConnectionRepository::class);
|
||||
$connections->allows('all')->andReturn([[
|
||||
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $overrides */
|
||||
private function connection(array $overrides = []): array
|
||||
{
|
||||
return $overrides + [
|
||||
'user_id' => 3,
|
||||
'user_login' => 'redaktion<b>',
|
||||
'uuid' => 'uuid-1',
|
||||
@@ -193,38 +208,73 @@ final class AccessTabTest extends TestCase
|
||||
'app_id' => 'https://claude.ai/x',
|
||||
'created' => 1790000000,
|
||||
'last_used' => null,
|
||||
]]);
|
||||
];
|
||||
}
|
||||
|
||||
private function renderTab(array $connections): string
|
||||
{
|
||||
$repository = Mockery::mock(ConnectionRepository::class);
|
||||
$repository->allows('all')->andReturn($connections);
|
||||
|
||||
ob_start();
|
||||
(new AccessTab(new Access(), $connections))->render();
|
||||
$html = (string) ob_get_clean();
|
||||
(new AccessTab(new Access(), $repository))->render();
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
public function testRenderShowsRolesAndConnections(): void
|
||||
{
|
||||
$this->stubRender(['editor']);
|
||||
|
||||
$html = $this->renderTab([$this->connection()]);
|
||||
|
||||
self::assertStringContainsString('value="editor" checked="checked"', $html);
|
||||
self::assertStringNotContainsString('value="administrator" checked', $html);
|
||||
self::assertStringContainsString('Claude <script>', $html);
|
||||
self::assertStringContainsString('<strong>Claude <script></strong>', $html);
|
||||
self::assertStringContainsString('redaktion<b>', $html);
|
||||
self::assertStringNotContainsString('<script>', $html);
|
||||
self::assertStringNotContainsString('<code>', $html);
|
||||
self::assertStringContainsString('value="uuid-1"', $html);
|
||||
self::assertStringContainsString('name="user_id" value="3"', $html);
|
||||
self::assertStringContainsString('value="lit_mcp_revoke"', $html);
|
||||
self::assertStringContainsString('nonce-lit_mcp_revoke_uuid-1', $html);
|
||||
self::assertStringContainsString('confirm(', $html);
|
||||
}
|
||||
|
||||
public function testRenderWithoutConnections(): void
|
||||
public function testRolesAreAFormTableFieldsetWithLegendAndDescription(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn(['administrator']);
|
||||
Functions\when('translate_user_role')->returnArg();
|
||||
Functions\when('checked')->justReturn('');
|
||||
Functions\when('settings_fields')->justReturn(null);
|
||||
Functions\when('submit_button')->justReturn(null);
|
||||
$connections = Mockery::mock(ConnectionRepository::class);
|
||||
$connections->allows('all')->andReturn([]);
|
||||
$this->stubRender(['editor']);
|
||||
|
||||
ob_start();
|
||||
(new AccessTab(new Access(), $connections))->render();
|
||||
$html = (string) ob_get_clean();
|
||||
$html = $this->renderTab([]);
|
||||
|
||||
self::assertStringContainsString('Keine aktiven Verbindungen.', $html);
|
||||
self::assertStringNotContainsString('<table', $html);
|
||||
self::assertStringContainsString('<table class="form-table" role="presentation">', $html);
|
||||
self::assertStringContainsString('<th scope="row">Rollen mit Zugriff</th>', $html);
|
||||
self::assertStringContainsString('<fieldset><legend class="screen-reader-text"><span>Rollen mit Zugriff</span></legend>', $html);
|
||||
self::assertStringContainsString('<p class="description">Nur Benutzer dieser Rollen können Claude verbinden.', $html);
|
||||
}
|
||||
|
||||
public function testDatesUseTheSiteDateAndTimeFormat(): void
|
||||
{
|
||||
$this->stubRender(['editor']);
|
||||
|
||||
$html = $this->renderTab([
|
||||
$this->connection(['uuid' => 'a', 'last_used' => null]),
|
||||
$this->connection(['uuid' => 'b', 'last_used' => 1790050000]),
|
||||
]);
|
||||
|
||||
self::assertStringContainsString('d.m.Y H:i@1790000000', $html);
|
||||
self::assertStringContainsString('d.m.Y@1790050000', $html);
|
||||
self::assertStringContainsString('<td class="column-last-used">–</td>', $html);
|
||||
}
|
||||
|
||||
public function testRenderWithoutConnectionsShowsAnEmptyStateRowLinkingToTheConnectionTab(): void
|
||||
{
|
||||
$this->stubRender(['administrator']);
|
||||
|
||||
$html = $this->renderTab([]);
|
||||
|
||||
self::assertStringContainsString('<tr class="no-items"><td class="colspanchange" colspan="5">', $html);
|
||||
self::assertStringContainsString('Noch keine Verbindung.', $html);
|
||||
self::assertStringContainsString('href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=verbindung"', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,28 @@ final class ConnectionStatusTest extends TestCase
|
||||
$byLabel = array_column($checks, null, 'label');
|
||||
self::assertFalse($byLabel['OAuth-Server']['ok']);
|
||||
self::assertFalse($byLabel['HTTPS']['ok']);
|
||||
self::assertNull($byLabel['Discovery (/.well-known/)']['ok']);
|
||||
self::assertNull($byLabel['Endpunkt ohne Token']['ok']);
|
||||
self::assertNull($byLabel['OAuth-Discovery']['ok']);
|
||||
self::assertNull($byLabel['MCP-Endpunkt']['ok']);
|
||||
}
|
||||
|
||||
public function testDiscoveryDetailIsGermanInsteadOfTheLibrarysEnglishLabel(): void
|
||||
{
|
||||
Functions\when('get_transient')->alias(static fn (string $key) => $key === 'lit_mcp_endpoint_check'
|
||||
? false
|
||||
: ['label' => 'MCP OAuth discovery documents', 'status' => 'good']);
|
||||
Functions\when('home_url')->justReturn('https://kunde.test');
|
||||
|
||||
$good = array_column((new ConnectionStatus(static fn (): bool => true))->checks(false), null, 'label');
|
||||
self::assertTrue($good['OAuth-Discovery']['ok']);
|
||||
self::assertSame('Discovery-Dokumente erreichbar', $good['OAuth-Discovery']['message']);
|
||||
|
||||
Functions\when('get_transient')->alias(static fn (string $key) => $key === 'lit_mcp_endpoint_check'
|
||||
? false
|
||||
: ['label' => 'MCP OAuth discovery documents', 'status' => 'critical']);
|
||||
$bad = array_column((new ConnectionStatus(static fn (): bool => true))->checks(false), null, 'label');
|
||||
self::assertFalse($bad['OAuth-Discovery']['ok']);
|
||||
self::assertStringContainsString('nicht erreichbar', $bad['OAuth-Discovery']['message']);
|
||||
self::assertStringNotContainsString('documents', $bad['OAuth-Discovery']['message']);
|
||||
}
|
||||
|
||||
public function testPermalinksPrettyReflectsThePermalinkStructureOption(): void
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Admin\ConnectionStatus;
|
||||
use LIT\MCP\Admin\ConnectionTab;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class ConnectionTabTest extends TestCase
|
||||
{
|
||||
/** @param list<array{label: string, ok: ?bool, message: string}> $checks */
|
||||
private function render(array $checks): string
|
||||
{
|
||||
Functions\stubEscapeFunctions();
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server');
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
|
||||
$status = Mockery::mock(ConnectionStatus::class);
|
||||
$status->allows('checks')->with(true)->andReturn($checks);
|
||||
|
||||
ob_start();
|
||||
(new ConnectionTab($status))->render();
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
public function testExplainsTheConnectionAsAnOrderedSequenceLinkingToTheAccessTab(): void
|
||||
{
|
||||
$html = $this->render([]);
|
||||
|
||||
self::assertStringContainsString('So verbindest du Claude', $html);
|
||||
self::assertSame(3, substr_count($html, '<li>'));
|
||||
self::assertStringContainsString('<ol', $html);
|
||||
self::assertStringContainsString('„Add custom connector“', $html);
|
||||
self::assertStringContainsString('href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=zugriff"', $html);
|
||||
}
|
||||
|
||||
public function testServerUrlIsReadonlyWithACopyButton(): void
|
||||
{
|
||||
$html = $this->render([]);
|
||||
|
||||
self::assertMatchesRegularExpression(
|
||||
'#<input[^>]+id="lit-mcp-url"[^>]+readonly[^>]+value="https://kunde.de/wp-json/mcp/mcp-oauth-server"#',
|
||||
$html
|
||||
);
|
||||
self::assertStringContainsString('<label for="lit-mcp-url"', $html);
|
||||
self::assertStringContainsString(
|
||||
'<button type="button" class="button lit-mcp-copy" data-clipboard-target="#lit-mcp-url">URL kopieren</button>',
|
||||
$html
|
||||
);
|
||||
self::assertStringContainsString('<span class="success hidden" aria-hidden="true">Kopiert!</span>', $html);
|
||||
}
|
||||
|
||||
public function testStatusIsASiteHealthStyleListWithIconsPerState(): void
|
||||
{
|
||||
$html = $this->render([
|
||||
['label' => 'HTTPS', 'ok' => true, 'message' => 'aktiv'],
|
||||
['label' => 'Discovery', 'ok' => null, 'message' => 'noch nicht geprüft'],
|
||||
['label' => 'OAuth <b>', 'ok' => false, 'message' => 'deaktiviert'],
|
||||
]);
|
||||
|
||||
self::assertStringNotContainsString('<table', $html);
|
||||
self::assertStringContainsString('<ul class="lit-mcp-status">', $html);
|
||||
self::assertMatchesRegularExpression('#dashicons-yes-alt[^<]*"[^>]*></span>.*?HTTPS#s', $html);
|
||||
self::assertMatchesRegularExpression('#dashicons-warning[^<]*"[^>]*></span>.*?Discovery#s', $html);
|
||||
self::assertMatchesRegularExpression('#dashicons-dismiss[^<]*"[^>]*></span>.*?OAuth <b>#s', $html);
|
||||
self::assertStringContainsString('<span class="screen-reader-text">In Ordnung:</span>', $html);
|
||||
self::assertStringContainsString('<span class="screen-reader-text">Problem:</span>', $html);
|
||||
self::assertStringContainsString('href="https://kunde.de/wp-admin/site-health.php"', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Admin\ConnectionUsage;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Error;
|
||||
use WP_REST_Request;
|
||||
|
||||
final class ConnectionUsageTest extends TestCase
|
||||
{
|
||||
/** @var list<array{int, string}> */
|
||||
private array $recorded = [];
|
||||
|
||||
private function usage(?array $claims): ConnectionUsage
|
||||
{
|
||||
return new ConnectionUsage(
|
||||
static fn (string $token): ?array => $token === 'valid-token' ? $claims : null,
|
||||
function (int $userId, string $uuid): void {
|
||||
$this->recorded[] = [$userId, $uuid];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private function request(string $route = '/mcp/mcp-oauth-server', string $auth = 'Bearer valid-token'): WP_REST_Request
|
||||
{
|
||||
return new WP_REST_Request($route, ['authorization' => $auth]);
|
||||
}
|
||||
|
||||
public function testRegistersAfterTheRouteCallback(): void
|
||||
{
|
||||
$usage = $this->usage([]);
|
||||
Functions\expect('add_filter')->once()->with('rest_request_after_callbacks', [$usage, 'record'], 10, 3);
|
||||
|
||||
$usage->register();
|
||||
}
|
||||
|
||||
public function testRecordsUsageOfTheTokensApplicationPasswordForTheAuthenticatedUser(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
$response = ['jsonrpc' => '2.0'];
|
||||
|
||||
$result = $this->usage(['sub' => 7, 'app_pass_id' => 'uuid-1'])->record($response, [], $this->request());
|
||||
|
||||
self::assertSame($response, $result);
|
||||
self::assertSame([[7, 'uuid-1']], $this->recorded);
|
||||
}
|
||||
|
||||
public function testIgnoresOtherRoutesFailedRequestsAndForeignOrInvalidTokens(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
$claims = ['sub' => 7, 'app_pass_id' => 'uuid-1'];
|
||||
|
||||
$this->usage($claims)->record([], [], $this->request('/wp/v2/posts'));
|
||||
$this->usage($claims)->record(new WP_Error('unauthorized', 'x'), [], $this->request());
|
||||
$this->usage($claims)->record([], [], $this->request('/mcp/mcp-oauth-server', 'Basic abc'));
|
||||
$this->usage($claims)->record([], [], $this->request('/mcp/mcp-oauth-server', 'Bearer forged'));
|
||||
$this->usage(['sub' => 8, 'app_pass_id' => 'uuid-1'])->record([], [], $this->request());
|
||||
$this->usage(['sub' => 7])->record([], [], $this->request());
|
||||
|
||||
self::assertSame([], $this->recorded);
|
||||
}
|
||||
|
||||
public function testNeverBreaksTheResponseWhenDecodingOrRecordingFails(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
$usage = new ConnectionUsage(
|
||||
static fn (): ?array => throw new \RuntimeException('bad secret'),
|
||||
static function (): void {
|
||||
}
|
||||
);
|
||||
|
||||
self::assertSame(['ok'], $usage->record(['ok'], [], $this->request()));
|
||||
}
|
||||
}
|
||||
@@ -24,24 +24,35 @@ final class LimitsTabTest extends TestCase
|
||||
(new LimitsTab())->registerSettings();
|
||||
}
|
||||
|
||||
public function testRendersThreeNumberFieldsWithCurrentValues(): void
|
||||
public function testRendersVisibleNumberFieldsWithShortLabelsUnitsAndDescriptions(): void
|
||||
{
|
||||
Functions\stubEscapeFunctions();
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\when('settings_fields')->justReturn(null);
|
||||
Functions\when('submit_button')->justReturn(null);
|
||||
Functions\when('get_option')->justReturn(['upload_mb' => 8]);
|
||||
Functions\when('get_option')->justReturn(['upload_mb' => 8, 'snapshot_count' => 42]);
|
||||
Functions\when('wp_max_upload_size')->justReturn(64 * 1024 * 1024);
|
||||
|
||||
ob_start();
|
||||
(new LimitsTab())->render();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertSame(3, substr_count($html, 'type="number"'));
|
||||
self::assertSame(2, substr_count($html, 'type="number"'));
|
||||
self::assertStringContainsString('<label for="lit-mcp-upload_mb">Maximale Dateigröße</label>', $html);
|
||||
self::assertStringContainsString('<label for="lit-mcp-log_retention_days">Protokoll aufbewahren</label>', $html);
|
||||
self::assertStringContainsString('class="small-text"', $html);
|
||||
self::assertStringContainsString('<span class="lit-mcp-unit">MB</span>', $html);
|
||||
self::assertStringContainsString('<span class="lit-mcp-unit">Tage</span>', $html);
|
||||
self::assertStringContainsString('aria-describedby="lit-mcp-upload_mb-description"', $html);
|
||||
self::assertStringContainsString('<p class="description" id="lit-mcp-upload_mb-description">Höchstens 32 MB; für Base64-Übertragungen höchstens 10 MB.</p>', $html);
|
||||
// The snapshot setting is hidden until its feature exists, but its saved value survives a save.
|
||||
self::assertStringNotContainsString('Designsystem', $html);
|
||||
self::assertStringNotContainsString('Etappe', $html);
|
||||
self::assertStringContainsString('<input type="hidden" name="lit_mcp_limits[snapshot_count]" value="42">', $html);
|
||||
self::assertStringContainsString('name="lit_mcp_limits[upload_mb]" value="8"', $html);
|
||||
self::assertStringContainsString('name="lit_mcp_limits[log_retention_days]" value="90"', $html);
|
||||
self::assertStringContainsString('min="1" max="32"', $html);
|
||||
self::assertStringContainsString('höchstens 32', $html);
|
||||
self::assertStringContainsString('Höchstens 32', $html);
|
||||
self::assertStringContainsString('10 MB', $html);
|
||||
self::assertStringContainsString('min="1" max="3650"', $html);
|
||||
}
|
||||
@@ -59,7 +70,7 @@ final class LimitsTabTest extends TestCase
|
||||
(new LimitsTab())->render();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('class="description"', $html);
|
||||
self::assertStringContainsString('notice-warning inline', $html);
|
||||
self::assertMatchesRegularExpression('/20 MB.*2 MB/s', $html);
|
||||
}
|
||||
|
||||
@@ -76,6 +87,6 @@ final class LimitsTabTest extends TestCase
|
||||
(new LimitsTab())->render();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringNotContainsString('class="description"', $html);
|
||||
self::assertStringNotContainsString('notice-warning', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,10 +191,72 @@ final class LogListTableTest extends TestCase
|
||||
$table->single_row(['result' => 'ok']);
|
||||
$ok = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('<tr style=', $partial);
|
||||
self::assertStringContainsString('<tr class="lit-mcp-partial">', $partial);
|
||||
self::assertSame('<tr>', substr($ok, 0, 4));
|
||||
}
|
||||
|
||||
public function testTimeColumnShowsSiteTimeInWordPressFormatWithUtcAsTitle(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static fn (string $key) => $key === 'date_format' ? 'd.m.Y' : 'H:i');
|
||||
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
self::assertSame('Zeit', $table->get_columns()['created_at']);
|
||||
$html = $this->column($table, ['created_at' => '2026-09-26 10:39:13'], 'created_at');
|
||||
|
||||
self::assertStringContainsString('d.m.Y H:i@' . gmmktime(10, 39, 13, 9, 26, 2026), $html);
|
||||
self::assertStringContainsString('title="UTC: 2026-09-26 10:39:13"', $html);
|
||||
self::assertSame('–', $this->column($table, ['created_at' => 'kaputt'], 'created_at'));
|
||||
}
|
||||
|
||||
/** @dataProvider results */
|
||||
public function testResultColumnShowsIconAndWord(string $result, string $icon, string $word): void
|
||||
{
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
$html = $this->column($table, ['result' => $result], 'result');
|
||||
|
||||
self::assertStringContainsString('dashicons-' . $icon, $html);
|
||||
self::assertStringContainsString($word, $html);
|
||||
}
|
||||
|
||||
/** @return list<list<string>> */
|
||||
public static function results(): array
|
||||
{
|
||||
return [
|
||||
['ok', 'yes-alt', 'erfolgreich'],
|
||||
['partial', 'warning', 'teilweise'],
|
||||
['error<b>', 'dismiss', 'error<b>'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testToolColumnIsMutedTextAndTopNavRendersTheGivenFilters(): void
|
||||
{
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0], static function (): void {
|
||||
echo '<select name="ability"></select>';
|
||||
});
|
||||
|
||||
self::assertSame(
|
||||
'<span class="lit-mcp-tool-name">lit/x<</span>',
|
||||
$this->column($table, ['ability' => 'lit/x<'], 'ability')
|
||||
);
|
||||
|
||||
ob_start();
|
||||
$table->display();
|
||||
self::assertStringContainsString('<div class="alignleft actions"><select name="ability"></select></div>', (string) ob_get_clean());
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item */
|
||||
private function column(LogListTable $table, array $item, string $column): string
|
||||
{
|
||||
$method = 'column_' . $column;
|
||||
if (method_exists($table, $method)) {
|
||||
return (string) (new \ReflectionMethod($table, $method))->invoke($table, $item);
|
||||
}
|
||||
|
||||
return $this->columnDefault($table, $item, $column);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item */
|
||||
private function columnDefault(LogListTable $table, array $item, string $column): string
|
||||
{
|
||||
|
||||
@@ -130,10 +130,11 @@ final class LogTabTest extends TestCase
|
||||
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
||||
});
|
||||
Functions\when('wp_dropdown_users')->alias(static function (array $args): void {
|
||||
echo '<select name="' . $args['name'] . '"><option>Alle</option></select>';
|
||||
echo '<select name="' . $args['name'] . '" id="' . $args['id'] . '"><option>Alle</option></select>';
|
||||
});
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||
Functions\when('get_option')->justReturn([]);
|
||||
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
|
||||
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->allows('abilities')->andReturn(['lit/content-update', 'lit/x"><script>']);
|
||||
@@ -151,5 +152,18 @@ final class LogTabTest extends TestCase
|
||||
self::assertStringContainsString('lit_mcp_clear_log', $html);
|
||||
self::assertStringContainsString('confirm(', $html);
|
||||
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
|
||||
|
||||
// One intro line; the retention days link to the Limits tab.
|
||||
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
|
||||
// Every filter control has a (screen-reader) label.
|
||||
foreach (['ability', 'user_id', 'result', 'date_from', 'date_to'] as $name) {
|
||||
self::assertMatchesRegularExpression(
|
||||
'#<label class="screen-reader-text" for="lit-mcp-filter-' . str_replace('_', '-', $name) . '">#',
|
||||
$html,
|
||||
'label for ' . $name
|
||||
);
|
||||
}
|
||||
self::assertStringContainsString('<option value="ok">erfolgreich</option>', $html);
|
||||
self::assertStringContainsString('<option value="partial">teilweise</option>', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ final class SettingsPageTest extends TestCase
|
||||
|
||||
self::assertStringContainsString('Inhalt zugriff', $html);
|
||||
self::assertStringNotContainsString('Inhalt verbindung', $html);
|
||||
self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active"/', $html);
|
||||
self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active" aria-current="page"/', $html);
|
||||
self::assertMatchesRegularExpression('/tab=verbindung" class="nav-tab">/', $html);
|
||||
self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs());
|
||||
}
|
||||
|
||||
@@ -70,8 +71,53 @@ final class SettingsPageTest extends TestCase
|
||||
$withoutLogo = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('<img src="https://example.test/logo.svg" alt="Loosen-IT Logo"', $withLogo);
|
||||
self::assertStringContainsString('right:0', $withLogo);
|
||||
self::assertStringNotContainsString('lit-mcp-logo', $withoutLogo);
|
||||
// Logo sits in the header next to the h1 (flex, no absolute positioning over the title).
|
||||
self::assertLessThan(strpos($withLogo, 'lit-mcp-logo'), strpos($withLogo, '<h1'));
|
||||
self::assertStringNotContainsString('position:absolute', $withLogo);
|
||||
}
|
||||
|
||||
public function testHeaderEndMarkerSitsBetweenTitleAndTabsWithNoticesAfterIt(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$_GET['lit_mcp_notice'] = 'revoked';
|
||||
|
||||
ob_start();
|
||||
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
$h1 = (int) strpos($html, '<h1');
|
||||
$end = strpos($html, '<hr class="wp-header-end">');
|
||||
self::assertNotFalse($end);
|
||||
self::assertLessThan($end, $h1);
|
||||
self::assertLessThan(strpos($html, 'notice-success'), $end);
|
||||
self::assertLessThan(strpos($html, 'nav-tab-wrapper'), (int) strpos($html, 'notice-success'));
|
||||
self::assertStringContainsString('<nav class="nav-tab-wrapper wp-clearfix" aria-label="', $html);
|
||||
}
|
||||
|
||||
public function testEnqueuesStylesAndCopyScriptOnlyOnItsOwnScreen(): void
|
||||
{
|
||||
$page = new SettingsPage([$this->tab('verbindung', 'Verbindung')], '', 'https://example.test/assets', '1.2.3');
|
||||
Functions\expect('wp_enqueue_style')->once()
|
||||
->with('lit-mcp-admin', 'https://example.test/assets/css/admin.css', [], '1.2.3');
|
||||
Functions\expect('wp_enqueue_script')->once()
|
||||
->with('lit-mcp-admin', 'https://example.test/assets/js/admin.js', ['clipboard', 'wp-a11y'], '1.2.3', true);
|
||||
Functions\when('wp_localize_script')->justReturn(true);
|
||||
|
||||
$page->enqueueAssets('settings_page_lit-mcp');
|
||||
$page->enqueueAssets('options-general.php');
|
||||
}
|
||||
|
||||
public function testToolsTabAlsoLoadsWordPressPostboxScriptForCollapsibleGroups(): void
|
||||
{
|
||||
$_GET['tab'] = 'tools';
|
||||
$page = new SettingsPage([$this->tab('verbindung', 'V'), $this->tab('tools', 'T')], '', 'https://example.test/assets/', '1');
|
||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||
Functions\expect('wp_enqueue_script')->once()
|
||||
->with('lit-mcp-admin', 'https://example.test/assets/js/admin.js', ['clipboard', 'wp-a11y', 'postbox'], '1', true);
|
||||
Functions\when('wp_localize_script')->justReturn(true);
|
||||
|
||||
$page->enqueueAssets('settings_page_lit-mcp');
|
||||
}
|
||||
|
||||
public function testUnknownTabFallsBackToTheFirstAndNoticesAreShown(): void
|
||||
@@ -108,6 +154,7 @@ final class SettingsPageTest extends TestCase
|
||||
$page = new SettingsPage([$tab]);
|
||||
Functions\expect('add_action')->once()->with('admin_menu', [$page, 'addMenu']);
|
||||
Functions\expect('add_action')->once()->with('admin_init', [$page, 'registerSettings']);
|
||||
Functions\expect('add_action')->once()->with('admin_enqueue_scripts', [$page, 'enqueueAssets']);
|
||||
|
||||
$page->register();
|
||||
}
|
||||
|
||||
@@ -15,16 +15,17 @@ use Mockery;
|
||||
|
||||
final class ToolsTabTest extends TestCase
|
||||
{
|
||||
/** @param list<array{string, bool, bool}> $abilities name, isDirect, readonly */
|
||||
/** @param list<array{0: string, 1: bool, 2: bool, 3?: bool}> $abilities name, isDirect, readonly, destructive */
|
||||
private function integration(string $id, array $abilities, bool $available = true): Integration
|
||||
{
|
||||
$mocks = [];
|
||||
foreach ($abilities as [$name, $direct, $readonly]) {
|
||||
foreach ($abilities as $spec) {
|
||||
[$name, $direct, $readonly] = $spec;
|
||||
$ability = Mockery::mock(Ability::class);
|
||||
$ability->allows('name')->andReturn($name);
|
||||
$ability->allows('label')->andReturn('Label ' . $name);
|
||||
$ability->allows('isDirect')->andReturn($direct);
|
||||
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
|
||||
$ability->allows('annotations')->andReturn(['readonly' => $readonly, 'destructive' => $spec[3] ?? false]);
|
||||
$mocks[] = $ability;
|
||||
}
|
||||
$integration = Mockery::mock(Integration::class);
|
||||
@@ -45,13 +46,24 @@ final class ToolsTabTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, string|bool> $option */
|
||||
private function render(array $option, ?IntegrationRegistry $registry = null): string
|
||||
/**
|
||||
* @param array<string, string|bool> $option
|
||||
* @param mixed $closedBoxes the user's saved closed postboxes (false = never saved)
|
||||
*/
|
||||
private function render(array $option, ?IntegrationRegistry $registry = null, mixed $closedBoxes = false): string
|
||||
{
|
||||
Functions\stubEscapeFunctions();
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\when('settings_fields')->justReturn(null);
|
||||
Functions\when('submit_button')->justReturn(null);
|
||||
Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit'): void {
|
||||
echo '<button name="' . $name . '">Speichern</button>';
|
||||
});
|
||||
Functions\when('wp_nonce_field')->alias(static function (string $action, string $name): string {
|
||||
return '<input type="hidden" id="' . $name . '" name="' . $name . '" value="nonce-' . $action . '">';
|
||||
});
|
||||
Functions\when('get_user_option')->alias(
|
||||
static fn (string $key) => $key === 'closedpostboxes_settings_page_lit-mcp' ? $closedBoxes : false
|
||||
);
|
||||
Functions\when('selected')->alias(
|
||||
static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : ''
|
||||
);
|
||||
@@ -76,21 +88,22 @@ final class ToolsTabTest extends TestCase
|
||||
/** The table row of one tool (from its <tr> to </tr>). */
|
||||
private function row(string $html, string $name): string
|
||||
{
|
||||
$at = strpos($html, '<code>' . $name . '</code>');
|
||||
$at = strpos($html, '<span class="lit-mcp-tool-name">' . $name . '</span>');
|
||||
self::assertNotFalse($at, 'No row for ' . $name);
|
||||
$start = (int) strrpos(substr($html, 0, $at), '<tr');
|
||||
|
||||
return substr($html, $start, (int) strpos($html, '</tr>', $at) - $start);
|
||||
}
|
||||
|
||||
/** The <details> block of one group. */
|
||||
/** The postbox of one group (from its opening div to the next group or the end). */
|
||||
private function group(string $html, string $label): string
|
||||
{
|
||||
$at = strpos($html, '<summary>' . $label);
|
||||
$at = strpos($html, '<h2 class="hndle">' . $label . ' ');
|
||||
self::assertNotFalse($at, 'No group ' . $label);
|
||||
$start = (int) strrpos(substr($html, 0, $at), '<details');
|
||||
$start = (int) strrpos(substr($html, 0, $at), '<div id="lit-mcp-group-');
|
||||
$end = strpos($html, '<div id="lit-mcp-group-', $at);
|
||||
|
||||
return substr($html, $start, (int) strpos($html, '</details>', $at) - $start);
|
||||
return substr($html, $start, ($end === false ? strlen($html) : $end) - $start);
|
||||
}
|
||||
|
||||
public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void
|
||||
@@ -125,10 +138,10 @@ final class ToolsTabTest extends TestCase
|
||||
{
|
||||
$html = $this->render(['lit/divi-update' => 'disabled']);
|
||||
|
||||
self::assertSame(3, substr_count($html, '<details open'));
|
||||
self::assertStringContainsString('<summary>Grundlagen (2 Tools)', $html);
|
||||
self::assertStringContainsString('<summary>Medien (2 Tools)', $html);
|
||||
self::assertStringContainsString('<summary>Divi (2 Tools)', $html);
|
||||
self::assertSame(3, substr_count($html, 'class="postbox lit-mcp-group'));
|
||||
self::assertStringContainsString('<h2 class="hndle">Grundlagen <span class="count">(2 Tools)</span>', $html);
|
||||
self::assertStringContainsString('<h2 class="hndle">Medien <span class="count">(2 Tools)</span>', $html);
|
||||
self::assertStringContainsString('<h2 class="hndle">Divi <span class="count">(2 Tools)</span>', $html);
|
||||
self::assertStringNotContainsString('pods', $html);
|
||||
self::assertStringContainsString('lit/divi-outline', $this->group($html, 'Divi'));
|
||||
self::assertStringNotContainsString('lit/guide', $this->group($html, 'Divi'));
|
||||
@@ -139,7 +152,10 @@ final class ToolsTabTest extends TestCase
|
||||
self::assertStringContainsString('value="' . $mode . '"', $groupSelect);
|
||||
}
|
||||
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1"', $html);
|
||||
self::assertStringContainsString('nur lesen (schreibende Tools dieser Gruppe aus)', $html);
|
||||
self::assertStringContainsString('<label for="lit-mcp-group-mode-divi">Ganze Gruppe</label>', $html);
|
||||
self::assertStringContainsString('id="lit-mcp-group-mode-divi"', $groupSelect);
|
||||
self::assertStringContainsString('Nur lesen', $this->group($html, 'Divi'));
|
||||
self::assertStringContainsString('<option value="disabled">aus</option>', $groupSelect);
|
||||
|
||||
$guide = $this->select($html, 'lit/guide');
|
||||
self::assertStringNotContainsString('value="disabled"', $guide);
|
||||
@@ -182,14 +198,14 @@ final class ToolsTabTest extends TestCase
|
||||
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html);
|
||||
$update = $this->row($html, 'lit/divi-update');
|
||||
self::assertStringContainsString('<select name="lit_mcp_exposure[lit/divi-update]" disabled', $update);
|
||||
self::assertStringContainsString('durch ‚nur lesen‘ aus', $update);
|
||||
self::assertStringContainsString('Durch „Nur lesen“ aus', $update);
|
||||
self::assertStringContainsString(
|
||||
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
|
||||
$update
|
||||
);
|
||||
$outline = $this->row($html, 'lit/divi-outline');
|
||||
self::assertStringNotContainsString('disabled>', $outline);
|
||||
self::assertStringNotContainsString('nur lesen', $outline);
|
||||
self::assertStringNotContainsString('Nur lesen', $outline);
|
||||
}
|
||||
|
||||
public function testCountsDirectToolsUnderTheSavedSettings(): void
|
||||
@@ -199,7 +215,8 @@ final class ToolsTabTest extends TestCase
|
||||
// guide, site-status, media-preview (divi disabled, media-upload indirect by default).
|
||||
self::assertStringContainsString('Direkt sichtbar: 3 Tools', $html);
|
||||
self::assertStringNotContainsString('Kontext', $html);
|
||||
self::assertLessThan(strpos($html, '<details'), strpos($html, 'Direkt sichtbar'));
|
||||
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'Direkt sichtbar'));
|
||||
self::assertStringContainsString('<div class="notice notice-info inline"><p><strong>Direkt sichtbar: 3 Tools', $html);
|
||||
}
|
||||
|
||||
public function testWarnsWhenMoreThan25ToolsAreDirect(): void
|
||||
@@ -215,7 +232,9 @@ final class ToolsTabTest extends TestCase
|
||||
|
||||
self::assertStringContainsString('Direkt sichtbar: 26 Tools', $warned);
|
||||
self::assertStringContainsString('Kontext', $warned);
|
||||
self::assertStringContainsString('<summary>WordPress (26 Tools)', $warned);
|
||||
self::assertStringContainsString('<div class="notice notice-warning inline">', $warned);
|
||||
self::assertStringNotContainsString('notice-warning', $notWarned);
|
||||
self::assertStringContainsString('<h2 class="hndle">WordPress <span class="count">(26 Tools)</span>', $warned);
|
||||
self::assertStringContainsString('Direkt sichtbar: 25 Tools', $notWarned);
|
||||
self::assertStringNotContainsString('Kontext', $notWarned);
|
||||
}
|
||||
@@ -226,8 +245,101 @@ final class ToolsTabTest extends TestCase
|
||||
|
||||
$html = $this->render([], $registry);
|
||||
|
||||
self::assertStringContainsString('<summary>shop (1 Tool)', $html);
|
||||
self::assertStringContainsString('<h2 class="hndle">shop <span class="count">(1 Tool)</span>', $html);
|
||||
self::assertStringNotContainsString('<b>', $html);
|
||||
self::assertStringContainsString('lit/shop-<b>', $html);
|
||||
}
|
||||
|
||||
public function testArtColumnSaysWhetherAToolReadsWritesOrDeletes(): void
|
||||
{
|
||||
$registry = new IntegrationRegistry($this->integration('media', [
|
||||
['lit/media-preview', true, true],
|
||||
['lit/media-upload', false, false],
|
||||
['lit/media-delete', false, false, true],
|
||||
]));
|
||||
|
||||
$html = $this->render([], $registry);
|
||||
|
||||
self::assertStringContainsString('<th scope="col" class="column-kind">Art</th>', $html);
|
||||
self::assertStringContainsString('liest', $this->row($html, 'lit/media-preview'));
|
||||
self::assertStringContainsString('schreibt', $this->row($html, 'lit/media-upload'));
|
||||
self::assertStringContainsString('löscht', $this->row($html, 'lit/media-delete'));
|
||||
self::assertStringNotContainsString('schreibt', $this->row($html, 'lit/media-delete'));
|
||||
}
|
||||
|
||||
public function testToolCellShowsLabelAndTheNameAsMutedTextWithALabelledSelect(): void
|
||||
{
|
||||
$html = $this->render([]);
|
||||
$row = $this->row($html, 'lit/guide');
|
||||
|
||||
self::assertStringContainsString('<strong>Label lit/guide</strong>', $row);
|
||||
self::assertStringNotContainsString('<code>', $row);
|
||||
self::assertMatchesRegularExpression('#<label class="screen-reader-text" for="([a-z0-9-]+)">[^<]*Label lit/guide</label>#', $row);
|
||||
preg_match('#for="([a-z0-9-]+)"#', $row, $m);
|
||||
self::assertStringContainsString('id="' . $m[1] . '"', $this->select($html, 'lit/guide'));
|
||||
}
|
||||
|
||||
public function testIntroUsesPlainLanguageAndMentionsDiviOnlyWhenDiviIsAvailable(): void
|
||||
{
|
||||
$withDivi = $this->render([]);
|
||||
$withoutDivi = $this->render([], new IntegrationRegistry($this->integration('core', [['lit/guide', true, true]])));
|
||||
|
||||
self::assertStringContainsString('Claude sieht das Tool sofort.', $withDivi);
|
||||
self::assertStringContainsString('Claude findet es bei Bedarf über die Tool-Suche.', $withDivi);
|
||||
self::assertStringNotContainsString('Meta-Tools', $withDivi);
|
||||
self::assertStringNotContainsString('discover', $withDivi);
|
||||
self::assertStringContainsString('Divi-Code-Modul', $withDivi);
|
||||
self::assertStringNotContainsString('Divi-Code-Modul', $withoutDivi);
|
||||
}
|
||||
|
||||
public function testHasASubmitButtonAboveAndBelowTheGroups(): void
|
||||
{
|
||||
$html = $this->render([]);
|
||||
|
||||
self::assertSame(2, substr_count($html, 'Speichern</button>'));
|
||||
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'name="submit-top"'));
|
||||
self::assertGreaterThan(strrpos($html, 'class="postbox'), strrpos($html, 'name="submit"'));
|
||||
}
|
||||
|
||||
public function testGroupsAreWordPressPostboxesWithAnAccessibleToggle(): void
|
||||
{
|
||||
$html = $this->render([]);
|
||||
$divi = $this->group($html, 'Divi');
|
||||
|
||||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $divi);
|
||||
self::assertStringContainsString('<div class="postbox-header">', $divi);
|
||||
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="true">', $divi);
|
||||
self::assertStringContainsString('<span class="screen-reader-text">Gruppe ein-/ausblenden: Divi</span>', $divi);
|
||||
self::assertStringContainsString('<span class="toggle-indicator" aria-hidden="true"></span>', $divi);
|
||||
self::assertStringContainsString('<div class="inside">', $divi);
|
||||
// The group controls live in the header but outside the clickable heading.
|
||||
$heading = substr($divi, (int) strpos($divi, '<h2'), (int) strpos($divi, '</h2>') - (int) strpos($divi, '<h2'));
|
||||
self::assertStringNotContainsString('<select', $heading);
|
||||
self::assertStringNotContainsString('<input', $heading);
|
||||
self::assertLessThan(strpos($divi, 'class="inside"'), strpos($divi, 'lit-mcp-group-mode-divi'));
|
||||
// Nonce for WordPress' own "closed-postboxes" state saving.
|
||||
self::assertStringContainsString('id="closedpostboxesnonce" name="closedpostboxesnonce" value="nonce-closedpostboxes"', $html);
|
||||
}
|
||||
|
||||
public function testHeaderSummaryCountsEffectiveModes(): void
|
||||
{
|
||||
$html = $this->render(['@readonly:divi' => true]);
|
||||
|
||||
self::assertStringContainsString('<span class="lit-mcp-group-summary">2 direkt · 0 indirekt · 0 aus</span>', $this->group($html, 'Grundlagen'));
|
||||
self::assertStringContainsString('<span class="lit-mcp-group-summary">1 direkt · 1 indirekt · 0 aus</span>', $this->group($html, 'Medien'));
|
||||
self::assertStringContainsString('<span class="lit-mcp-group-summary">nur lesen · 1 direkt · 0 indirekt · 1 aus</span>', $this->group($html, 'Divi'));
|
||||
}
|
||||
|
||||
public function testAGroupWithAllToolsOffStartsClosedUnlessTheUserSavedOtherwise(): void
|
||||
{
|
||||
$default = $this->render(['@group:divi' => 'disabled']);
|
||||
$saved = $this->render(['@group:divi' => 'disabled'], null, ['lit-mcp-group-media']);
|
||||
|
||||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group closed">', $default);
|
||||
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="false">', $this->group($default, 'Divi'));
|
||||
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group">', $default);
|
||||
|
||||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $saved);
|
||||
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed">', $saved);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user