Resolve "Enable Drupal GitLab CI pipeline and add Unit/Kernel/Functional test coverage"
What this MR does
Enables the standard Drupal GitLab CI pipeline for the project and adds the first PHPUnit test coverage (Unit, Kernel, Functional) for both agui and the agui_mercure sub-module. Everything the enabled pipeline jobs check was pre-cleaned, so the first run should be green.
CI pipeline (.gitlab-ci.yml)
- Standard
gitlab_templatesinclude: composer validation, PHP parallel-lint, PHPCS, PHPStan, CSpell, and PHPUnit run on every MR. - PHPCS and CSpell are blocking (
allow_failure: false) — the code base is clean for both, so there's no reason to let them drift. PHPCS uses a newphpcs.xml(Drupal + DrupalPractice, same extension set as theaimodule; compiledcomponents/*/public/typesandexamples/excluded). CSpell uses a new.cspell-project-words.txt; built artifacts, source maps, andpackage-lock.jsonare ignored via_CSPELL_IGNORE_PATHS. - PHPStan is advisory for now (template default) — level 1 passes clean locally; suggest flipping it to blocking in a follow-up once a few pipelines have run.
- ESLint/Stylelint are skipped: the chat component is TypeScript with its own esbuild/tsc toolchain, and the committed CSS is compiled from SCSS — neither follows core's lint configs.
OPT_IN_TEST_NEXT_MINORis on. Previous-major (D10/PHP 8.1) is deliberately not opted in: the code uses PHP 8.3 syntax (typed class constants, e.g.AguiTokenController::DEFAULT_EXPIRY), so the effective PHP floor is 8.3. Worth deciding separately whether to drop the typed constants or adjustcore_version_requirement.
Test coverage (56 tests / 204 assertions)
Unit
ToolResultBuffer: add/pop/popAll/clear semantics, per-id isolation, overwrite behavior.AguiErrorCode: data provider over all nineDrupal\ai\Exception\*mappings, GuzzleConnectException→timeout, exception-chain walking (outermost match wins),unknownfallback.
Kernel
AguiTokenController: token parsed and signature-verified with lcobucci; anonymous vs authenticated claims (sub,drupal_uid,drupal_roles);anonymous_idpassthrough;mercure.subscribeclaim iffthreadId;agui_token_expirysetting; missing-secret 500; theagui_token_payloadalter hook (via a new hiddenagui_testfixture module).AguiChatController: 400/404 validation paths, query-stringagentIdfallback, demo-agent response metadata (status,text/event-stream,X-Accel-Buffering). SSE body assertions intentionally live in the Functional suite — the stream callbacks disable all output buffering, which is incompatible with in-process PHPUnit capture.AgentToolResultSubscriber: dispatches a realAgentToolFinishedExecutionEventwith mocked tools; buffering, empty-tool-id skip.
Functional (real HTTP, real streaming)
- Chat endpoint: permission matrix (anonymous / no-permission /
use agui chat), full demo-agent SSE sequence (RUN_STARTED → TEXT_MESSAGE_* → RUN_FINISHED with reassembled deltas and consistent run/thread ids), an echoai-backed assistant flow end-to-end, and the RUN_ERROR terminal contract (code: response_error, no RUN_FINISHED after) using the echo provider'stest_exceptionmodel. - Token endpoint: 403 / 500-without-secret / verified JWT after
writeSettings. - Demo page: access control + form elements render.
agui_mercure
- New hidden
agui_mercure_testfixture module registering aSpyHubunder the exactmercure.hub.aguiservice id, so tests observe publishedUpdates without a real hub. AguiAgentStatusSubscriber(unit, 8 tests): exact topics (agui/{threadId}/runs/{runId}), private updates, RUN_STARTED only on loop 0, message triplet with stablemessageId, tool-call events suppressing TEXT_MESSAGE_END, TOOL_CALL_END/RESULT, RUN_FINISHED, and the null-hub / null-threadId no-op guards.MercureChatController(kernel): all missing-parameter 400s, unknown agent 400, and rejection of class-based agent plugins.
Bug fix surfaced by the tests
AiAssistantApiRunner::process() returns a plain array whenever the LLM response decodes as JSON (its PromptJsonDecoder extracts any {...}/[...] from the reply). AguiChatController called ->getNormalized() on it and crashed the stream with RUN_ERROR: Call to a member function getNormalized() on array. Any provider replying with JSON-looking content would hit this. The controller now normalizes an array result into an assistant ChatMessage (re-encoded JSON) so the stream completes normally. Covered by the echoai functional test.
Also included
agui.info.ymlnow declaresai:ai_assistant_api— the chat controller hard-requiresai_assistant_api.runnerand the demo form needs theai_assistantentity type, so the module was broken without it.composer.json:require-devondrupal/ai_agents(^1.3@rc) anddrupal/mercure(^1.0) so CI can install and run theagui_mercuretests (and PHPStan can resolve those namespaces), plus thelicensefield thatcomposer validate --strictrequires.- README section documenting how to run the suites locally.
Running locally
vendor/bin/phpunit -c web/core web/modules/contrib/agui
cd web/modules/contrib/agui && ../../../../vendor/bin/phpcs -p .(With DDEV, prefix with ddev exec; SIMPLETEST_DB/SIMPLETEST_BASE_URL are provided.)
Closes #3590044 (closed)