diff --git a/.gitignore b/.gitignore index ffb13102..4d296a28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,24 @@ -purge_cache -qi_config.cfg -boards/* -settings/* -cache/* -sources/* -vendor/* -tests/vendor/* -build/* -.qi/* -customisations/* -*~ -.idea -node_modules -/.agents -/agent +# Runtime data +/boards/ +/cache/ +/customisations/ +/settings/ +/sources/ +/.qi/ + +# Dependencies and build output +/vendor/ +/tests/vendor/ +/node_modules/ +/build/ + +# Local development tooling +/.idea/ +/.agents/ +/agent/ +/AGENTS.md /skills-lock.json + +# OS and editor files +.DS_Store +*~ diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..eaa12f5e --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,25 @@ +# Third-Party Notices + +## Lucide Icons + +The Dashboard UI includes selected icons from Lucide. + +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +The Terminal icon is derived from Feather Icons: + +The MIT License (MIT) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/public/assets/sandbox-ui.css b/public/assets/sandbox-ui.css index 4393fc1e..2d5fc19e 100644 --- a/public/assets/sandbox-ui.css +++ b/public/assets/sandbox-ui.css @@ -202,13 +202,26 @@ button:disabled { opacity: .65; cursor: wait; } .brand-lockup small { color: var(--sidebar-muted); font-size: 12px; } .sidebar nav { display: grid; gap: 2px; } .sidebar a { - display: block; + display: flex; + align-items: center; + gap: 9px; padding: 7px 10px; border-radius: var(--radius); color: var(--sidebar-text); font-weight: 500; } .sidebar a:hover { background: var(--sidebar-hover); text-decoration: none; } +.icon-sprite { display: none; } +.icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} .main { min-width: 0; } .topbar { display: flex; @@ -314,6 +327,8 @@ button:disabled { opacity: .65; cursor: wait; } margin-bottom: 16px; } .section-head p:not(.eyebrow) { margin-top: 4px; color: var(--muted); max-width: 760px; } +.section-head h2 { display: flex; align-items: center; gap: 8px; } +.section-head h2 .icon { color: var(--accent); } .board-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 16px; } .board { padding: 0; } .card-head { @@ -500,6 +515,12 @@ button:disabled { opacity: .65; cursor: wait; } border-radius: var(--radius); background: var(--panel); } +#sources .joined-form { border-radius: var(--radius) var(--radius) 0 0; } +#sources .joined-form + .table-wrap, +#sources .joined-form + .empty { + border-top: 0; + border-radius: 0 0 var(--radius) var(--radius); +} table { width: 100%; table-layout: fixed; @@ -587,6 +608,7 @@ tr:last-child td { border-bottom: 0; } } .footer > * + *:before { content: "/"; + display: inline-block; margin-right: 12px; color: var(--subtle); font-weight: 400; diff --git a/public/assets/sandbox-ui.js b/public/assets/sandbox-ui.js index 29516ba4..1ff61152 100644 --- a/public/assets/sandbox-ui.js +++ b/public/assets/sandbox-ui.js @@ -1,11 +1,124 @@ const dashboard = document.getElementById('dashboard'); const busy = document.getElementById('busy'); +const pendingActions = new Map(); +const activityEntries = []; +const maxActivityEntries = 50; +let nextActionId = 1; -function setProcessing(active) { +function updateProcessingState() { + const active = pendingActions.size > 0; busy.classList.toggle('is-active', active); document.documentElement.classList.toggle('is-processing', active); } +function sameAction(left, right) { + return left.action === right.action + && left.board === right.board + && left.section === right.section + && left.name === right.name + && left.source === right.source; +} + +/** Reapplies pending state after AJAX replaces dashboard markup. */ +function syncPendingActions() { + pendingActions.forEach((pending) => { + const form = Array.from(dashboard.querySelectorAll('form[data-ajax]')) + .find((candidate) => sameAction(actionContext(candidate), pending.context)); + const submitter = form ? form.querySelector('button[type="submit"], button:not([type]), input[type="submit"]') : null; + if (!submitter) { + return; + } + + pending.submitter = submitter; + submitter.disabled = true; + submitter.textContent = 'Working...'; + }); + + updateProcessingState(); + updateActivityStates(); +} + +function actionLabel(context) { + const action = context.action.replaceAll('_', ' ').replace(/^./, (character) => character.toUpperCase()); + const target = context.board || context.name || context.source; + return target ? action + ' — ' + target : action; +} + +function addActivityEntry(actionId, context) { + activityEntries.push({ + id: actionId, + context, + status: 'queued', + submittedAt: new Date(), + completedAt: null, + message: '', + output: '', + }); + trimActivityEntries(); +} + +function completeActivityEntry(actionId, data) { + const entry = activityEntries.find((candidate) => candidate.id === actionId); + if (!entry) { + return; + } + + entry.status = data.error ? 'error' : 'success'; + entry.completedAt = new Date(); + entry.message = data.error || data.notice || ''; + entry.output = data.output || ''; + trimActivityEntries(); + renderActivityLog(); +} + +function updateActivityStates() { + let runningAssigned = false; + activityEntries.forEach((entry) => { + if (!pendingActions.has(entry.id) || entry.status === 'success' || entry.status === 'error') { + return; + } + + entry.status = runningAssigned ? 'queued' : 'running'; + runningAssigned = true; + }); + renderActivityLog(); +} + +function trimActivityEntries() { + while (activityEntries.length > maxActivityEntries) { + const completedIndex = activityEntries.findIndex((entry) => !pendingActions.has(entry.id)); + if (completedIndex === -1) { + return; + } + activityEntries.splice(completedIndex, 1); + } +} + +function renderActivityLog() { + const log = document.getElementById('activity-log'); + if (!log) { + return; + } + + if (activityEntries.length === 0) { + log.textContent = 'No actions recorded yet.'; + return; + } + + log.textContent = activityEntries.map((entry) => { + const timestamp = (entry.completedAt || entry.submittedAt).toLocaleTimeString(); + const lines = [ '[' + timestamp + '] ' + entry.status.toUpperCase() + ' ' + actionLabel(entry.context) ]; + if (entry.message) { + lines.push(entry.message); + } + if (entry.output.trim()) { + lines.push(entry.output.trimEnd()); + } + return lines.join('\n'); + }).join('\n\n'); + log.scrollTop = log.scrollHeight; +} + function bindAjax() { bindUpdateBanner(); @@ -23,13 +136,12 @@ function bindAjax() { const context = actionContext(form); const submitter = event.submitter; - const original = submitter ? submitter.textContent : ''; - if (submitter) { - submitter.disabled = true; - submitter.textContent = 'Working...'; - } + const original = submitter ? submitter.innerHTML : ''; + const actionId = nextActionId++; + pendingActions.set(actionId, { context, submitter, original }); + addActivityEntry(actionId, context); - setProcessing(true); + syncPendingActions(); try { const response = await fetch(location.href, { method: 'POST', @@ -53,20 +165,25 @@ function bindAjax() { if (!data || typeof data !== 'object') { throw new Error('QuickInstall returned an invalid response object.'); } + completeActivityEntry(actionId, data); if (typeof data.html === 'string' && data.html !== '') { dashboard.innerHTML = data.html; bindAjax(); + syncPendingActions(); } showActionResult(data, context); scrollLog(); } catch (error) { + completeActivityEntry(actionId, { error: 'Request failed: ' + error.message, output: '' }); alert('Request failed: ' + error.message); } finally { - setProcessing(false); - if (submitter) { - submitter.disabled = false; - submitter.textContent = original; + const pending = pendingActions.get(actionId); + pendingActions.delete(actionId); + if (pending && pending.submitter) { + pending.submitter.disabled = false; + pending.submitter.innerHTML = pending.original; } + syncPendingActions(); } }); }); @@ -78,12 +195,16 @@ function bindAjax() { button.dataset.bound = '1'; button.addEventListener('click', () => { - const log = document.getElementById('activity-log'); - if (log) { - log.textContent = 'No command output yet.'; + for (let index = activityEntries.length - 1; index >= 0; index--) { + if (!pendingActions.has(activityEntries[index].id)) { + activityEntries.splice(index, 1); + } } + renderActivityLog(); }); }); + + renderActivityLog(); } function bindUpdateBanner() { @@ -121,7 +242,9 @@ function actionContext(form) { return { action: formData.get('action') || '', board: board ? board.dataset.board : '', + name: formData.get('name') || '', section: section ? section.id : '', + source: formData.get('source') || '', }; } diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 2842442c..b1c7f7c1 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -18,12 +18,14 @@ class Application { private Project $project; + private BrowserLauncher $browserLauncher; private $stderr; private $stdin; - public function __construct(string $root, $stderr = null, $stdin = null) + public function __construct(string $root, $stderr = null, $stdin = null, ?BrowserLauncher $browserLauncher = null) { $this->project = new Project($root); + $this->browserLauncher = $browserLauncher ?: new BrowserLauncher(); $this->stderr = $stderr ?: (defined('STDERR') ? STDERR : fopen('php://stderr', 'wb')); $this->stdin = $stdin ?: (defined('STDIN') ? STDIN : null); } @@ -438,8 +440,8 @@ private function doctor(): int foreach ($checks as $check) { $status = $check['ok'] - ? $this->style("\u{2714}", '1;32') - : $this->style('!', '1;31'); + ? $this->style('[OK]', '1;32') + : $this->style('[FAIL]', '1;31'); echo "$status {$check['name']}: {$check['detail']}\n"; $failed = $failed || !$check['ok']; } @@ -482,10 +484,16 @@ private function supportsAnsi(): bool private function boardStart(array $args): int { - $name = $this->boardName($args, 'Usage: qi board:start '); + $cli = CommandLine::parse($args); + $name = $cli->argument(0); + if ($name === null) + { + throw new InvalidArgumentException('Usage: qi board:start [--no-open]'); + } $board = (new BoardService($this->project, $this->sandboxOutput()))->start($name); echo "Started board: $name\n"; echo "URL: {$board['url']}\n"; + $this->openBrowser($board['url'], $cli); return 0; } @@ -625,6 +633,7 @@ private function uiStart(array $args): int if ($result['status'] === 'already_running') { echo "QuickInstall Dashboard UI is already running: {$state['url']}\n"; + $this->openBrowser($state['url'], $cli); return 0; } @@ -632,10 +641,27 @@ private function uiStart(array $args): int echo "PID: {$state['pid']}\n"; echo "Log: {$state['log']}\n"; echo "Stop it with: php bin/qi ui:stop\n"; + $this->openBrowser($state['url'], $cli); return 0; } + private function openBrowser(string $url, CommandLine $cli): void + { + if ($cli->has('no-open')) + { + return; + } + + if ($this->browserLauncher->open($url)) + { + echo "Opened in your default browser.\n"; + return; + } + + $this->writeError("Unable to open the default browser. Open this URL manually: $url\n"); + } + private function uiStop(): int { $result = (new UiServerService($this->project))->stop(); @@ -991,12 +1017,15 @@ private function helpCommands(): array ], 'board:start' => [ 'title' => 'board:start', - 'usage' => 'board:start ', + 'usage' => 'board:start [--no-open]', 'summary' => 'Start the board containers and install the board if needed.', 'description' => 'Starts the board containers with Docker Compose, runs phpBB install on first start, applies the configured populate preset once, and waits for the board URL to respond. Docker must already be running.', 'arguments' => [ '' => 'Required board name.', ], + 'options' => [ + '--no-open' => 'Do not open the board URL in the default browser.', + ], 'examples' => [ 'board:start demo', ], @@ -1149,12 +1178,13 @@ private function helpCommands(): array 'UI commands' => [ 'ui:start' => [ 'title' => 'ui:start', - 'usage' => 'ui:start [--host 127.0.0.1] [--port 8079]', + 'usage' => 'ui:start [--host 127.0.0.1] [--port 8079] [--no-open]', 'summary' => 'Start the local QuickInstall Dashboard UI.', 'description' => 'Starts PHP built-in server in the background on a loopback address and serves the QuickInstall Dashboard UI.', 'options' => [ '--host HOST' => 'Loopback host. One of: 127.0.0.1, localhost, ::1. Default: 127.0.0.1.', '--port PORT' => 'Local UI port. Default: 8079.', + '--no-open' => 'Do not open the Dashboard URL in the default browser.', ], 'examples' => [ 'ui:start', @@ -1172,12 +1202,13 @@ private function helpCommands(): array ], 'ui:restart' => [ 'title' => 'ui:restart', - 'usage' => 'ui:restart [--host 127.0.0.1] [--port 8079]', + 'usage' => 'ui:restart [--host 127.0.0.1] [--port 8079] [--no-open]', 'summary' => 'Restart the local QuickInstall Dashboard UI.', 'description' => 'Stops the tracked Dashboard UI server if present, then starts a fresh local Dashboard UI server.', 'options' => [ '--host HOST' => 'Loopback host. One of: 127.0.0.1, localhost, ::1. Default: 127.0.0.1.', '--port PORT' => 'Local UI port. Default: 8079.', + '--no-open' => 'Do not open the Dashboard URL in the default browser.', ], 'examples' => [ 'ui:restart', diff --git a/src/QuickInstall/Sandbox/BrowserLauncher.php b/src/QuickInstall/Sandbox/BrowserLauncher.php new file mode 100644 index 00000000..313644d7 --- /dev/null +++ b/src/QuickInstall/Sandbox/BrowserLauncher.php @@ -0,0 +1,70 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +/** Opens a URL in the operating system's default browser. */ +class BrowserLauncher +{ + private string $osFamily; + private $executor; + + public function __construct(?string $osFamily = null, ?callable $executor = null) + { + $this->osFamily = $osFamily ?: PHP_OS_FAMILY; + $this->executor = $executor; + } + + public function open(string $url): bool + { + $command = $this->command($url); + if ($command === null) + { + return false; + } + + if ($this->executor !== null) + { + return (bool) call_user_func($this->executor, $command); + } + + $nullDevice = $this->osFamily === 'Windows' ? 'NUL' : '/dev/null'; + $descriptor = [ + 0 => ['file', $nullDevice, 'r'], + 1 => ['file', $nullDevice, 'w'], + 2 => ['file', $nullDevice, 'w'], + ]; + $process = @proc_open($command, $descriptor, $pipes); + if (!is_resource($process)) + { + return false; + } + + return proc_close($process) === 0; + } + + private function command(string $url): ?array + { + switch ($this->osFamily) + { + case 'Darwin': + return ['open', $url]; + + case 'Windows': + return ['cmd.exe', '/d', '/s', '/c', 'start', '', $url]; + + case 'Linux': + case 'BSD': + return ['xdg-open', $url]; + } + + return null; + } +} diff --git a/src/QuickInstall/Sandbox/DoctorService.php b/src/QuickInstall/Sandbox/DoctorService.php index 29d8e4de..fee85dc4 100644 --- a/src/QuickInstall/Sandbox/DoctorService.php +++ b/src/QuickInstall/Sandbox/DoctorService.php @@ -28,7 +28,7 @@ public function checks(): array $projectWritable = $this->projectWritable(); $checks = [ $this->check('PHP 8+', PHP_VERSION_ID >= 80000, PHP_VERSION), - $this->check('PHP CLI', PHP_SAPI === 'cli', PHP_SAPI), + $this->check('PHP CLI', in_array(PHP_SAPI, ['cli', 'cli-server'], true), PHP_SAPI), $this->check('PHP configuration', true, $iniPath !== false ? $iniPath : 'no php.ini loaded; using built-in defaults'), $this->extensionCheck('JSON', 'json'), $this->extensionCheck('OpenSSL', 'openssl', 'extension=openssl'), diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php index ad7d5a73..7c4356c2 100644 --- a/src/QuickInstall/Sandbox/Web/Application.php +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -16,6 +16,7 @@ use QuickInstall\Sandbox\BoardService; use QuickInstall\Sandbox\BufferedOutput; use QuickInstall\Sandbox\CustomisationMountService; +use QuickInstall\Sandbox\DoctorService; use QuickInstall\Sandbox\ExtensionManager; use QuickInstall\Sandbox\Project; use QuickInstall\Sandbox\SourceService; @@ -185,6 +186,10 @@ private function handlePost(): void $this->notice = $created ? 'Workspace initialized.' : 'Workspace already initialized.'; break; + case 'doctor': + $this->runDoctor(); + break; + case 'source_fetch': $version = $this->required('version'); (new SourceService($this->project, $this->output))->fetch($version, $this->checked('git'), $this->optional('url'), $this->checked('allow_external')); @@ -286,6 +291,27 @@ private function handlePost(): void } } + private function runDoctor(): void + { + $checks = (new DoctorService($this->project))->checks(); + $failed = 0; + $this->output->write("QuickInstall requirements\n"); + foreach ($checks as $check) + { + $status = $check['ok'] ? '[OK]' : '[FAIL]'; + $this->output->write("$status {$check['name']}: {$check['detail']}\n"); + $failed += $check['ok'] ? 0 : 1; + } + + if ($failed === 0) + { + $this->notice = 'Doctor found no requirement problems.'; + return; + } + + $this->error = "Doctor found $failed requirement " . ($failed === 1 ? 'problem.' : 'problems.') . ' View the Activity Log below for details.'; + } + private function disableExecutionTimeLimit(): void { if ((int) ini_get('max_execution_time') === 0) diff --git a/src/QuickInstall/Sandbox/Web/templates/dashboard.php b/src/QuickInstall/Sandbox/Web/templates/dashboard.php index 4be60f50..99b3cfc7 100644 --- a/src/QuickInstall/Sandbox/Web/templates/dashboard.php +++ b/src/QuickInstall/Sandbox/Web/templates/dashboard.php @@ -56,7 +56,7 @@
-

Boards

+

Boards

Start, stop, seed, and inspect local Docker-backed phpBB installs.

@@ -152,7 +152,7 @@
-

Create board

+

Create board

Choose the phpBB source, runtime, port, and optional seed preset.

@@ -163,7 +163,7 @@ - + @@ -174,7 +174,7 @@
-

Customisations

+

Customisations

Bind or copy local extension and style work into a board.

@@ -202,11 +202,11 @@
-

Sources

+

Sources

Fetch and inspect phpBB sources used by boards.

-
+ @@ -257,14 +257,14 @@
-

Activity log

-

Docker, Composer, and seed output from the latest action.

+

Activity log

+

Queued and completed actions from this browser session.

quickinstall
-
escape($output) ?>
+
escape($output) ?>
diff --git a/src/QuickInstall/Sandbox/Web/templates/layout.php b/src/QuickInstall/Sandbox/Web/templates/layout.php index d47a24a3..7346e426 100644 --- a/src/QuickInstall/Sandbox/Web/templates/layout.php +++ b/src/QuickInstall/Sandbox/Web/templates/layout.php @@ -16,6 +16,15 @@ +
@@ -39,11 +48,18 @@

QuickInstall Dashboard

Manage disposable phpBB boards backed by the same Docker services as the CLI.

- - - - - +
+
+ + + +
+
+ + + +
+
diff --git a/src/QuickInstall/Sandbox/bootstrap.php b/src/QuickInstall/Sandbox/bootstrap.php index 70de116c..eb3716a9 100644 --- a/src/QuickInstall/Sandbox/bootstrap.php +++ b/src/QuickInstall/Sandbox/bootstrap.php @@ -14,6 +14,7 @@ require_once __DIR__ . '/BufferedOutput.php'; require_once __DIR__ . '/ProcessRunner.php'; require_once __DIR__ . '/CommandLine.php'; +require_once __DIR__ . '/BrowserLauncher.php'; require_once __DIR__ . '/Project.php'; require_once __DIR__ . '/DoctorService.php'; require_once __DIR__ . '/VersionMatrix.php'; diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index 6b2bc97a..edddc201 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -152,6 +152,16 @@ public function testUiStartHelpIsExposed(): void self::assertStringContainsString('Usage:', $result['output']); self::assertStringContainsString('qi ui:start', $result['output']); self::assertStringContainsString('built-in server', $result['output']); + self::assertStringContainsString('--no-open', $result['output']); + } + + public function testBoardStartHelpDocumentsBrowserOptOut(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'board:start', '--help']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('qi board:start [--no-open]', $result['output']); + self::assertStringContainsString('default browser', $result['output']); } public function testUiLifecycleCommandsAreExposed(): void diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php index 1b3d5726..77b49a43 100644 --- a/tests/Integration/WebApplicationTest.php +++ b/tests/Integration/WebApplicationTest.php @@ -60,6 +60,10 @@ public function testRenderShowsCoreSandboxWorkflows(): void self::assertStringContainsString('QuickInstall Dashboard', $html); self::assertStringContainsString('QuickInstall + Docker', $html); self::assertStringContainsString('Create board', $html); + self::assertStringContainsString('Run Doctor', $html); + self::assertStringContainsString('id="icon-boards"', $html); + self::assertStringContainsString('href="#icon-activity"', $html); + self::assertStringContainsString('class="icon" aria-hidden="true"', $html); self::assertStringContainsString('Sources', $html); self::assertStringContainsString('Mount extension', $html); self::assertStringContainsString('Mount style', $html); @@ -78,6 +82,60 @@ public function testRenderShowsCoreSandboxWorkflows(): void self::assertStringNotContainsString('