diff --git a/.github/linters/.editorconfig-checker.json b/.github/linters/.editorconfig-checker.json
index 944060a..4dc8df3 100644
--- a/.github/linters/.editorconfig-checker.json
+++ b/.github/linters/.editorconfig-checker.json
@@ -2,6 +2,7 @@
"Exclude": [
"^composer\\.lock$",
"^runtime/",
+ "^tests/Debug/fixtures/",
"^vendor/"
]
}
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 7b650ad..765cdfc 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -17,6 +17,3 @@ jobs:
codespell-config: .github/linters/.codespellrc
editorconfig-checker-config: .github/linters/.editorconfig-checker.json
markdownlint-config: .github/linters/.markdown-lint.yml
- prettier-config: .prettierrc.json
- prettier-ignore-path: .prettierignore
- yamllint-targets: .github
diff --git a/.prettierignore b/.prettierignore
deleted file mode 100644
index abb454d..0000000
--- a/.prettierignore
+++ /dev/null
@@ -1,12 +0,0 @@
-# Local editor and generated runtime state.
-.vscode/**
-runtime/**
-
-# Dependencies.
-vendor/**
-
-# Dependency lockfile ignored by git.
-composer.lock
-
-# License texts ship verbatim.
-**/LICENSE*
diff --git a/.prettierrc.json b/.prettierrc.json
deleted file mode 100644
index c9f33af..0000000
--- a/.prettierrc.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "tabWidth": 4,
- "useTabs": false,
- "singleQuote": false,
- "trailingComma": "all",
- "printWidth": 80,
- "overrides": [
- {
- "files": ["**/*.{yml,yaml}"],
- "options": {
- "tabWidth": 2
- }
- },
- {
- "files": "**/*.md",
- "options": {
- "embeddedLanguageFormatting": "off",
- "tabWidth": 2
- }
- }
- ]
-}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33325cd..4238c78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 0.2.2 Under development
+- feat: provide a declarative Vite debugger panel through `php-forge/debug`.
+
## 0.2.1 August 25, 2026
- feat: add `create()` factories for renderers and Vite configurations, and use them in examples and tests.
diff --git a/README.md b/README.md
index 0fd7761..8ccce9c 100644
--- a/README.md
+++ b/README.md
@@ -40,20 +40,8 @@
composer require php-forge/vite:^0.2
```
-HTML output is generated with [`ui-awesome/html`](https://github.com/ui-awesome/html) while asset resolution remains
-independent from its representation.
-
-The consuming application owns Vite and every JavaScript dependency. Configure Vite to write a build manifest:
-
-```js
-import {defineConfig} from 'vite';
-
-export default defineConfig({
- build: {
- manifest: true,
- },
-});
-```
+The consuming application owns Vite and every JavaScript dependency. Production resolution needs Vite's build
+manifest; see [Installation](docs/installation.md) for the `vite.config.js` settings and the resulting manifest path.
## Quick start
@@ -99,8 +87,36 @@ echo HtmlRenderer::create()->render($vite->resolve());
- [Manifest resolution](docs/manifest.md)
- [Usage examples](docs/examples.md)
- [Security and CSP](docs/security.md)
+- [Debugger integration](docs/debugging.md)
- [Testing guide](docs/testing.md)
+## Debugger integration
+
+`Vite::resolve()` emits `AssetsResolved` through an optional PSR-14 dispatcher. The asset resolution path never
+imports a debug contract or calls a collector, so installing the package does not activate a debugger and a call
+without a dispatcher emits no events. The collector and panel that do implement those contracts live apart, in
+`PHPForge\Vite\Debug`, owned by this package.
+
+See [Debugger integration](docs/debugging.md) for the Yii2 and Yii3 wiring. It remains an unreleased prototype.
+
+
+Yii2
+
+
+
+
+
+
+
+
+Yii3
+
+
+
+
+
+
+
## Package information
[](https://www.php.net/releases/8.3/en.php)
diff --git a/composer.json b/composer.json
index 8880e2c..30fa767 100644
--- a/composer.json
+++ b/composer.json
@@ -30,7 +30,9 @@
"prefer-stable": true,
"require": {
"php": ">=8.3",
- "ui-awesome/html": "^0.6"
+ "ui-awesome/html": "^0.6",
+ "php-forge/debug": "^0.1@dev",
+ "psr/event-dispatcher": "^1.0"
},
"require-dev": {
"infection/infection": "^0.35",
diff --git a/docs/configuration.md b/docs/configuration.md
index 60f562b..bd66590 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -59,15 +59,11 @@ $pageAssets = $vite->resolve('resources/js/admin.js');
$combinedAssets = $vite->resolve(['resources/js/app.js', 'resources/js/admin.js']);
```
-`Vite::create()` is an additive construction shortcut. The public constructor remains available for dependency-injection
-containers and accepts the same arguments.
+`Vite::create()` is a shortcut; the public constructor takes the same arguments and suits DI containers.
-Default entrypoints belong to the facade because they apply equally to development and production. Duplicate entrypoints
-are removed while preserving the first occurrence. At least one entrypoint must be available when `Vite::resolve()` is
-called.
-
-An explicit argument to `resolve()` replaces the facade's default entrypoints for that call. Entrypoint identifiers are
-Vite manifest keys or development source paths, not filesystem paths.
+An explicit argument to `resolve()` replaces the facade's default entrypoints for that call. Duplicates are removed
+keeping the first occurrence, and at least one entrypoint must be available. Identifiers are Vite manifest keys or
+development source paths, never filesystem paths.
## Manifest loading and cache
@@ -107,14 +103,8 @@ $html = HtmlRenderer::create()->render(
);
```
-`HtmlRenderOptions::create()` starts with the default policy. Use `withNonce()`, `withSeparator()`, the four per-asset
-attribute modifiers, and `withAttributeProvider()` to replace individual values. Every modifier returns a new policy and
-leaves the original instance unchanged.
-
-`HtmlRenderer` maps the neutral asset objects to `ui-awesome/html` `Script` and `Link` elements. Applications that consume
-`AssetCollection` directly do not depend on the renderer's markup structure.
-
-See [Security and CSP](security.md) for the attribute and nonce policy.
+Every `HtmlRenderOptions` modifier returns a new policy and leaves the original unchanged. See
+[Security and CSP](security.md) for the full attribute and nonce policy.
## Neutral asset collection
@@ -125,9 +115,8 @@ See [Security and CSP](security.md) for the attribute and nonce policy.
- `ModulePreload` with a public `url`;
- `InlineModule` with public application-owned JavaScript `source`.
-The collection is countable and iterable. Use `all()`, `moduleScripts()`, `stylesheets()`, `modulePreloads()`, or
-`inlineModules()` to obtain ordered lists. `append()` and `prepend()` return new deduplicated collections without mutating the
-original instance.
+The collection is countable and iterable. `all()`, `moduleScripts()`, `stylesheets()`, `modulePreloads()` and
+`inlineModules()` return ordered lists; `append()` and `prepend()` return new deduplicated collections.
## Exception hierarchy
@@ -148,10 +137,6 @@ ViteException
Configuration and rendering exceptions extend `InvalidArgumentException`. Manifest exceptions extend `RuntimeException`.
Callers can catch one specific failure, a category base class, or the common marker interface.
-## Next steps
+---
-- ๐ [Installation guide](installation.md)
-- ๐ฆ [Manifest resolution](manifest.md)
-- ๐ก [Usage examples](examples.md)
-- ๐ [Security and CSP](security.md)
-- ๐งช [Testing guide](testing.md)
+[โ Back to documentation](../README.md#documentation)
diff --git a/docs/debugging.md b/docs/debugging.md
new file mode 100644
index 0000000..53896c7
--- /dev/null
+++ b/docs/debugging.md
@@ -0,0 +1,99 @@
+# Vite diagnostics
+
+## PSR-14, and nothing else
+
+`Vite` emits `PHPForge\Vite\Event\AssetsResolved` through an optional `Psr\EventDispatcher\EventDispatcherInterface`.
+The application classes never import a debug contract or call a collector, and installing the contracts does not
+activate a debugger.
+
+The event carries the configuration, the **actual per-call entrypoints**, the returned assets, and the manifest a
+successful resolver already used. The collector never loads a manifest or runs an inline provider. Each successful
+resolution becomes a component row; repeated calls are recorded in order, not deduplicated into one. Failed resolutions
+do not invent completion events.
+
+## Development wiring
+
+The package ships one collector and one panel, `PHPForge\Vite\Debug\ViteCollector` and
+`PHPForge\Vite\Debug\VitePanel`; the host never reimplements collection or presentation. What differs between
+frameworks is only how the dispatcher reaches the `PHPForge\Vite\Vite` service.
+
+### Yii3, one flag and no application code
+
+`yii3/debug` registers the collector and panel behind a flag and attaches the collector as a listener; the container
+autowires `Psr\EventDispatcher\EventDispatcherInterface` into `Vite`, so the application adds nothing else.
+
+```php
+return [
+ 'yii3/debug' => [
+ 'extensions' => [
+ 'vite' => true,
+ ],
+ ],
+];
+```
+
+Enabling the flag without `php-forge/vite` installed fails with an explicit container error.
+
+### Yii2, one registration
+
+Yii2 has no framework-native PSR-14 dispatcher, and its DI container does not autowire optional constructor arguments.
+`Vite` emits exactly one event type, so `ViteCollector` is its own single-listener dispatcher and the application
+writes no PSR-14 code.
+
+Inside the existing `YII_DEBUG` configuration guard:
+
+```php
+use PHPForge\Vite\Debug\{ViteCollector, VitePanel};
+
+$viteCollector = new ViteCollector();
+
+// Keep the component ID already used by the application.
+$config['components']['vite']['__construct()']['eventDispatcher'] = $viteCollector;
+$config['modules']['debug']['collectors']['vite'] = $viteCollector;
+$config['modules']['debug']['panels']['vite'] = new VitePanel();
+```
+
+If the application already owns a real PSR-14 dispatcher, register the collector as a listener on it and inject that
+dispatcher instead; never replace a populated dispatcher with an empty one.
+
+`yii2-extensions/debug` no longer ships a Vite collector or panel, so the `vite` ID is free: the module wraps the
+portable objects in its generic adapters and groups them under Extensions. Retain the existing module bootstrap,
+routing, access rules, and asset configuration.
+
+Inject the dispatcher into the **actual** Vite service, not into a duplicate diagnostic-only one. Omitting it is safe:
+resolution behaves normally and the panel simply stays empty.
+
+## Lifecycle and errors
+
+Register the collector once and let the host drive it. `startup()` enables the listener without discarding current
+observations if called twice; `shutdown()` disables it and clears references. Events outside that window are ignored.
+
+An active but unused collector captures `['components' => []]`; disabled collection captures `null`.
+
+Attach the event only to trusted listeners: it carries real application data, so never point an unrestricted event
+dumper at it. Listener exceptions propagate per PSR-14; they are never swallowed and never retried. The supplied
+listener only buffers the event.
+
+## Compatibility
+
+Hosts previously shipped their own Vite collector and panel; both were removed, so the `vite` ID is free for the
+provider-owned objects an application registers itself. An extension now declares its own ID, icon and title. Captures
+written by the removed host collector still render, because the payload shape is unchanged.
+
+`eventDispatcher` is an optional trailing argument: calls without one behave normally and emit no events. The branch
+aliases still describe an unreleased linked prototype, not a published release.
+
+## How this is verified
+
+- The full PHPUnit suite runs through this package's own autoloader, without Debug Core installed.
+- `python3 tools/check-provider-consumer.py` (in `php-forge/debug`) exports the package and its locked production
+ dependencies into a temporary mirror, installs with Packagist and plugins disabled, then exercises real resolution,
+ cleanup and replay with no debugger or framework present.
+- `DEBUG_UI_SEED_FIXTURES=0 npx playwright test e2e/provider-events.spec.js` (in Debug Core) checks persisted values
+ after a changed request, including an empty Vite capture, accessibility, and both themes and viewport sizes.
+
+Reference: [PSR-14](https://www.php-fig.org/psr/psr-14/).
+
+---
+
+[โ Back to documentation](../README.md#documentation)
diff --git a/docs/examples.md b/docs/examples.md
index d1de6b8..bfaefa9 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -2,26 +2,9 @@
## Matching Vite build configuration
-The plain PHP, Yii2, and Yii3 production examples below target Vite 5 or later and assume this application-owned
-configuration:
-
-```js
-import {defineConfig} from 'vite';
-
-export default defineConfig({
- build: {
- outDir: 'public/build',
- manifest: '.vite/manifest.json',
- rollupOptions: {
- input: 'resources/js/app.js',
- },
- },
-});
-```
-
-Because `build.manifest` is relative to `build.outDir`, this configuration writes
-`/public/build/.vite/manifest.json`. Each PHP example resolves that same file through the path mechanism of
-its application or framework.
+Every example below targets Vite 5 or later and assumes the application-owned `vite.config.js` from
+[Installation](installation.md), which writes `/public/build/.vite/manifest.json`. Each example resolves
+that same file through the path mechanism of its application or framework.
## Plain PHP
@@ -75,43 +58,22 @@ The example methods belong to the consuming application; they are not package AP
## Yii2 integration
-Register the facade as an application component so Yii2 owns its lazy construction and lifecycle. The package does not
-access `Yii::getAlias()`, the service locator, or `yii\web\View`:
+Register the facade as a component so Yii2 owns its lazy construction. `__construct()` is Yii2 container syntax and its
+values reach the framework-independent constructor unchanged; it is the same `$configuration` built above. The package never
+touches `Yii::getAlias()`, the service locator, or `yii\web\View`:
```php
-use PHPForge\Vite\Configuration\DevelopmentConfiguration;
-use PHPForge\Vite\Configuration\ProductionConfiguration;
-use PHPForge\Vite\Html\HtmlRenderer;
-use PHPForge\Vite\Vite;
-
-$config = [
- 'components' => [
- 'vite' => [
- 'class' => Vite::class,
- '__construct()' => [
- 'configuration' => YII_ENV === 'dev'
- ? DevelopmentConfiguration::create(
- devServerUrl: 'http://localhost:5173',
- )
- : ProductionConfiguration::create(
- manifestPath: dirname(__DIR__) . '/public/build/.vite/manifest.json',
- assetBaseUrl: '/build',
- ),
- 'entrypoints' => ['resources/js/app.js'],
- ],
- ],
+$config['components']['vite'] = [
+ 'class' => PHPForge\Vite\Vite::class,
+ '__construct()' => [
+ 'configuration' => $configuration,
+ 'entrypoints' => ['resources/js/app.js'],
],
];
-/** @var Vite $vite */
-$vite = Yii::$app->get('vite');
-
-echo HtmlRenderer::create()->render($vite->resolve());
+echo HtmlRenderer::create()->render(Yii::$app->get('vite')->resolve());
```
-The `__construct()` entry is Yii2 container syntax. Its values are passed to the framework-independent constructor, and
-the concrete manifest path is resolved entirely by the consuming application.
-
## Yii3 integration
Resolve the framework aliases in the application's dependency-injection configuration, then inject the same `Vite` class:
@@ -173,15 +135,6 @@ $vite = Vite::create($configuration, entrypoints: ['resources/js/app.jsx']);
Providers run in their configured order before `@vite/client` and the entrypoint scripts. The application owns the provider
code and the matching Vite plugin dependency.
-## Optional Foxy usage
-
-[`php-forge/foxy`](https://github.com/php-forge/foxy) may be used independently by a consuming project to coordinate its
-Composer and JavaScript dependencies. It is not installed, invoked, or configured by this package.
-
-## Next steps
+---
-- ๐ [Installation guide](installation.md)
-- โ๏ธ [Configuration reference](configuration.md)
-- ๐ฆ [Manifest resolution](manifest.md)
-- ๐ [Security and CSP](security.md)
-- ๐งช [Testing guide](testing.md)
+[โ Back to documentation](../README.md#documentation)
diff --git a/docs/images/yii2-dark.png b/docs/images/yii2-dark.png
new file mode 100644
index 0000000..6a07335
Binary files /dev/null and b/docs/images/yii2-dark.png differ
diff --git a/docs/images/yii2-light.png b/docs/images/yii2-light.png
new file mode 100644
index 0000000..a5f9017
Binary files /dev/null and b/docs/images/yii2-light.png differ
diff --git a/docs/images/yii3-dark.png b/docs/images/yii3-dark.png
new file mode 100644
index 0000000..965b9d8
Binary files /dev/null and b/docs/images/yii3-dark.png differ
diff --git a/docs/images/yii3-light.png b/docs/images/yii3-light.png
new file mode 100644
index 0000000..faeabf0
Binary files /dev/null and b/docs/images/yii3-light.png differ
diff --git a/docs/installation.md b/docs/installation.md
index 071a2a3..9a5ac58 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -25,16 +25,16 @@ modifies `package.json`, installs Vite, or runs a package manager.
Production resolution requires Vite's client build manifest. Enable it in the application's Vite configuration:
```js
-import {defineConfig} from 'vite';
+import { defineConfig } from "vite";
export default defineConfig({
- build: {
- outDir: 'public/build',
- manifest: '.vite/manifest.json',
- rollupOptions: {
- input: 'resources/js/app.js',
- },
+ build: {
+ outDir: "public/build",
+ manifest: ".vite/manifest.json",
+ rollupOptions: {
+ input: "resources/js/app.js",
},
+ },
});
```
@@ -42,20 +42,12 @@ This configuration writes the manifest to `/public/build/.vite/man
the Vite project root, and a string `build.manifest` value is relative to `build.outDir`. Pass that resulting absolute
filesystem path to `ProductionConfiguration`.
-For supported Vite 5 and later releases, [`manifest: true`](https://vite.dev/config/build-options#build-manifest) also
-defaults to `.vite/manifest.json` inside `build.outDir`. [Vite 4](https://github.com/vitejs/vite/blob/v4.5.14/docs/config/build-options.md#buildmanifest)
-placed the default `manifest: true` output directly at `/manifest.json`; it is not part of the supported major
-range. Always resolve the concrete path produced by the consuming application's Vite configuration instead of assuming a
-universal output location.
+On Vite 5 and later, [`manifest: true`](https://vite.dev/config/build-options#build-manifest) defaults to the same
+`.vite/manifest.json` path. Always pass the concrete path your Vite configuration produces rather than assuming one.
-Vite injects its modulepreload polyfill into each HTML entry by default. A backend integration that uses a non-HTML custom
-entry must import `vite/modulepreload-polyfill` when the polyfill is needed. This package only renders modulepreload assets;
-it does not provide that JavaScript polyfill.
+Vite injects its modulepreload polyfill into each HTML entry by default. A non-HTML custom entry must import
+`vite/modulepreload-polyfill` itself: this package renders modulepreload assets but does not provide the polyfill.
-## Next steps
+---
-- โ๏ธ [Configuration reference](configuration.md)
-- ๐ฆ [Manifest resolution](manifest.md)
-- ๐ก [Usage examples](examples.md)
-- ๐ [Security and CSP](security.md)
-- ๐งช [Testing guide](testing.md)
+[โ Back to documentation](../README.md#documentation)
diff --git a/docs/manifest.md b/docs/manifest.md
index 8dc273c..b68bd21 100644
--- a/docs/manifest.md
+++ b/docs/manifest.md
@@ -24,10 +24,8 @@ Unknown chunk fields are accepted as forward-compatible input and ignored. Known
Every `file`, `css`, and `assets` value must be a safe relative build path. Every static or dynamic reference must identify
another manifest entry.
-Consumers constructing chunks directly can use `ManifestChunk::create($key, $file)` or its public two-argument constructor,
-then replace optional fields with `withSrc()`, `withCss()`, `withAssets()`, `withEntry()`, `withName()`,
-`withDynamicEntry()`, `withImports()`, and `withDynamicImports()`. Each modifier returns a new chunk. Optional values are
-read through the corresponding typed getters.
+To build chunks directly, start from `ManifestChunk::create($key, $file)` and set optional fields with the matching
+`with*()` modifiers, each returning a new chunk.
## Initial-page resolution
@@ -39,13 +37,11 @@ For each requested entrypoint, the resolver:
4. emits a module script for each non-CSS entrypoint;
5. optionally emits modulepreload assets for imported JavaScript chunks.
-Asset identities are deduplicated without sorting, so the first discovered location determines output order. A visited set
-prevents infinite recursion for malformed circular import graphs and prevents repeated work across multiple entrypoints.
-Selected entrypoint scripts are not also emitted as modulepreload assets.
+Assets are deduplicated without sorting, so the first discovered location fixes the output order, and circular import
+graphs terminate. Entrypoint scripts are never also emitted as modulepreload assets.
-`dynamicImports` are validated but are not placed in the initial page because the browser loads them when the application
-executes the corresponding dynamic import. The `assets` field is available through `ManifestChunk::assets()` for consumers
-inspecting a manifest, but generic HTML tags cannot be inferred safely from those files and are not emitted automatically.
+`dynamicImports` are validated but left out of the initial page: the browser loads them when the dynamic import runs.
+`assets` is readable through `ManifestChunk::assets()` but never emitted, since no safe HTML tag can be inferred from it.
## Failure behavior
@@ -66,10 +62,6 @@ There are no silent development fallbacks in production mode.
This package consumes Vite's client build manifest only. It does not consume the SSR manifest, transform HTML, implement
experimental import maps, or decide how arbitrary copied assets should be presented.
-## Next steps
+---
-- ๐ [Installation guide](installation.md)
-- โ๏ธ [Configuration reference](configuration.md)
-- ๐ก [Usage examples](examples.md)
-- ๐ [Security and CSP](security.md)
-- ๐งช [Testing guide](testing.md)
+[โ Back to documentation](../README.md#documentation)
diff --git a/docs/security.md b/docs/security.md
index d47fec0..800363c 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -29,14 +29,12 @@ $options = HtmlRenderOptions::create()
);
```
-Attribute names must begin with a letter or underscore and may otherwise contain letters, digits, underscores, or hyphens.
-Values may be strings, integers, floats, booleans, or `null`. Boolean `true` emits a valueless attribute; `false` and `null`
-omit it. Inline event-handler attributes, `style`, required renderer attributes, nonce overrides, and duplicate names are
+Names must start with a letter or underscore and continue with letters, digits, underscores or hyphens. Values may be
+strings, integers, floats, booleans or `null`: `true` emits a valueless attribute, `false` and `null` omit it. Inline
+event handlers, `style`, required renderer attributes, nonce overrides and duplicate names (case-insensitive) are
rejected.
-The callback receives a neutral `AssetInterface` instance and must return an attribute array. A callback value replaces a
-per-type value with the same exact key. Names that differ only by case are treated as duplicates and rejected by the
-renderer.
+A callback value replaces a per-type value with the same key.
## CSP nonce
@@ -57,17 +55,12 @@ $html = HtmlRenderer::create()->render(
);
```
-The renderer places the nonce on each generated script and link tag. The application remains responsible for the complete
-CSP header, response-specific nonce lifecycle, browser compatibility, and policy directives for other resources.
+The renderer places the nonce on every generated script and link tag. The complete CSP header, the per-response nonce
+lifecycle and every other directive stay with the application.
-Development servers commonly require additional `connect-src` origins for HTTP and WebSocket HMR connections and may
-serve resources from a separate origin. Configure those directives only in the application's development policy; this
-package does not weaken CSP automatically.
+Dev servers usually need extra `connect-src` origins for HTTP and WebSocket HMR. Add those to the application's
+development policy only; this package never weakens CSP on its own.
-## Next steps
+---
-- ๐ [Installation guide](installation.md)
-- โ๏ธ [Configuration reference](configuration.md)
-- ๐ฆ [Manifest resolution](manifest.md)
-- ๐ก [Usage examples](examples.md)
-- ๐งช [Testing guide](testing.md)
+[โ Back to documentation](../README.md#documentation)
diff --git a/docs/testing.md b/docs/testing.md
index 04636fc..55f2425 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -87,11 +87,6 @@ Run PHPStan with a different memory limit.
composer static -- --memory-limit=512M
```
-## Next steps
-
-- ๐ [Installation guide](installation.md)
-- โ๏ธ [Configuration reference](configuration.md)
-- ๐ฆ [Manifest resolution](manifest.md)
-- ๐ก [Usage examples](examples.md)
-- ๐ [Security and CSP](security.md)
-- ๐ [Readme](../README.md)
+---
+
+[โ Back to documentation](../README.md#documentation)
diff --git a/src/Debug/ViteCollector.php b/src/Debug/ViteCollector.php
new file mode 100644
index 0000000..8d7a985
--- /dev/null
+++ b/src/Debug/ViteCollector.php
@@ -0,0 +1,175 @@
+ Resolutions observed in the active cycle, in dispatch order.
+ */
+ private array $resolutions = [];
+ /**
+ * @var bool Whether the collector is observing the active cycle.
+ */
+ private bool $started = false;
+
+ /**
+ * Records the completed resolution while the collector observes the active cycle.
+ *
+ * @param AssetsResolved $event Completed resolution dispatched by the Vite integration.
+ */
+ public function __invoke(AssetsResolved $event): void
+ {
+ if ($this->started) {
+ $this->resolutions[] = $event;
+ }
+ }
+
+ /**
+ * Returns the captured components for every resolution observed in the active cycle.
+ *
+ * @return array|null Captured components, or `null` outside an active cycle.
+ */
+ public function capture(): array|null
+ {
+ if ($this->started === false) {
+ return null;
+ }
+
+ $components = [];
+
+ foreach ($this->resolutions as $index => $event) {
+ $components[] = $this->component($event, $index + 1);
+ }
+
+ return ['components' => $components];
+ }
+
+ /**
+ * Forwards a completed resolution to this collector.
+ *
+ * @param object $event Dispatched event; anything other than an {@see AssetsResolved} is returned untouched.
+ *
+ * @return object The dispatched event.
+ */
+ public function dispatch(object $event): object
+ {
+ if ($event instanceof AssetsResolved) {
+ $this($event);
+ }
+
+ return $event;
+ }
+
+ /**
+ * Returns the stable ID associating the capture with the Vite panel.
+ *
+ * @return string Stable collector ID.
+ */
+ public function id(): string
+ {
+ return 'vite';
+ }
+
+ /**
+ * Stops observing and discards the resolutions captured in the completed cycle.
+ */
+ public function shutdown(): void
+ {
+ $this->started = false;
+ $this->resolutions = [];
+ }
+
+ /**
+ * Starts observing resolutions for a new cycle.
+ */
+ public function startup(): void
+ {
+ $this->started = true;
+ }
+
+ /**
+ * Collects the production-manifest chunks observed in one resolution.
+ *
+ * @param AssetsResolved $event Completed resolution observed in the active cycle.
+ *
+ * @return list> Captured production-manifest chunks in manifest order.
+ */
+ private function chunks(AssetsResolved $event): array
+ {
+ $chunks = [];
+
+ foreach ($event->manifest?->chunks() ?? [] as $chunk) {
+ $chunks[] = [
+ 'name' => $chunk->key,
+ 'file' => $chunk->file,
+ 'cssCount' => count($chunk->css()),
+ 'imports' => count($chunk->imports()),
+ 'isEntry' => $chunk->isEntry(),
+ ];
+ }
+
+ return $chunks;
+ }
+
+ /**
+ * Describes one observed resolution as a portable component payload.
+ *
+ * @param AssetsResolved $event Completed resolution observed in the active cycle.
+ * @param int $number Position of the resolution in the active cycle, used to build the component ID.
+ *
+ * @return array Captured configuration and manifest values for the component.
+ */
+ private function component(AssetsResolved $event, int $number): array
+ {
+ $configuration = $event->configuration;
+
+ if ($configuration instanceof DevelopmentConfiguration) {
+ return [
+ 'id' => $this->id() . '-' . $number,
+ 'class' => Vite::class,
+ 'implementation' => 'modern',
+ 'inspectionAvailable' => true,
+ 'mode' => 'development',
+ 'entrypoints' => $event->entrypoints,
+ 'baseUrl' => '',
+ 'devServerUrl' => $configuration->devServerUrl,
+ 'manifestPath' => '',
+ 'includeViteClient' => $configuration->includeViteClient,
+ 'modulePreload' => null,
+ 'chunks' => [],
+ ];
+ }
+
+ return [
+ 'id' => $this->id() . '-' . $number,
+ 'class' => Vite::class,
+ 'implementation' => 'modern',
+ 'inspectionAvailable' => true,
+ 'mode' => 'production',
+ 'entrypoints' => $event->entrypoints,
+ 'baseUrl' => $configuration->assetBaseUrl,
+ 'devServerUrl' => null,
+ 'manifestPath' => $configuration->manifestPath,
+ 'includeViteClient' => null,
+ 'modulePreload' => $configuration->modulePreload,
+ 'chunks' => $this->chunks($event),
+ ];
+ }
+}
diff --git a/src/Debug/VitePanel.php b/src/Debug/VitePanel.php
new file mode 100644
index 0000000..fe005b1
--- /dev/null
+++ b/src/Debug/VitePanel.php
@@ -0,0 +1,331 @@
+ $data Decoded Vite diagnostics.
+ *
+ * @throws InvalidArgumentException If the capture carries no component list or a malformed component.
+ *
+ * @return PanelView Per-component groups, mode summary, and toolbar entry.
+ */
+ public function present(array $data): PanelView
+ {
+ $components = $data['components'] ?? null;
+
+ if (!is_array($components) || !array_is_list($components)) {
+ throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_COMPONENT_LIST_REQUIRED->getMessage(),
+ );
+ }
+
+ $view = PanelView::create();
+
+ $modes = [];
+
+ foreach ($components as $component) {
+ if (!is_array($component)) {
+ throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_COMPONENT_INVALID->getMessage(),
+ );
+ }
+
+ $modes[] = self::string($component, 'mode');
+
+ $view = $view->group('Vite component ' . self::string($component, 'id'), self::component($component));
+ }
+
+ $count = count($components);
+
+ $mode = $count === 0 ? 'Unknown' : self::mode($modes[0] ?? 'unknown');
+
+ foreach ($modes as $value) {
+ if ($value !== ($modes[0] ?? null)) {
+ $mode = 'Mixed';
+ }
+ }
+
+ $view = $view->summary($count === 1 ? ' component' : ' components', $count)->active($count !== 0);
+
+ if ($count === 0) {
+ return $view->emptyState(
+ 'No Vite integrations captured',
+ 'This request did not use an initialized Vite application component.',
+ );
+ }
+
+ return $view
+ ->summary('', $mode, emphasized: false)
+ ->toolbar('Vite mode', $count === 1 ? $mode : "{$count} components ยท {$mode}");
+ }
+
+ /**
+ * Renders the build-chunk table, or the explanation replacing it when no chunk was captured.
+ *
+ * @param PanelView $view View carrying the component overview already composed.
+ * @param array $data Captured component diagnostics.
+ * @param string $mode Resolution mode of the captured component.
+ *
+ * @throws InvalidArgumentException If the chunk diagnostics are missing or malformed.
+ *
+ * @return PanelView View completed with the chunk table or its replacement.
+ */
+ private static function chunks(PanelView $view, array $data, string $mode): PanelView
+ {
+ $chunks = $data['chunks'] ?? null;
+
+ if (!is_array($chunks) || !array_is_list($chunks)) {
+ throw new InvalidArgumentException(Message::DIAGNOSTICS_CHUNK_LIST_INVALID->getMessage());
+ }
+
+ if ($chunks === []) {
+ return $view->paragraph(
+ match ($mode) {
+ 'development' => 'Development mode resolves entry points through the dev server.',
+ 'production' => 'The Vite manifest is missing or empty; run the front-end build to populate it.',
+ default => 'No build chunks were available for inspection.',
+ }
+ );
+ }
+
+ $rows = [];
+
+ foreach ($chunks as $chunk) {
+ if (
+ !is_array($chunk)
+ || !is_int($chunk['cssCount'] ?? null)
+ || !is_int($chunk['imports'] ?? null)
+ || !is_bool($chunk['isEntry'] ?? null)
+ ) {
+ throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_CHUNK_INVALID->getMessage(),
+ );
+ }
+
+ $rows[] = [
+ count($rows) + 1,
+ PanelView::strong(self::string($chunk, 'name')),
+ self::nonEmpty(self::string($chunk, 'file')),
+ $chunk['cssCount'],
+ $chunk['imports'],
+ $chunk['isEntry'] ? PanelView::badge('entry', Tone::SUCCESS) : 'โ',
+ ];
+ }
+
+ return $view->table(
+ ['#', 'Chunk', 'Output', 'CSS', 'Imports', 'Entry'],
+ $rows,
+ styles: [
+ 1 => ColumnStyle::MONOSPACE,
+ 2 => ColumnStyle::MONOSPACE,
+ 3 => ColumnStyle::NUMBER,
+ 4 => ColumnStyle::NUMBER,
+ 5 => ColumnStyle::PILL,
+ ],
+ );
+ }
+
+ /**
+ * Composes the overview, inspection callout, and chunk table for one captured component.
+ *
+ * @param array $data Captured component diagnostics.
+ *
+ * @throws InvalidArgumentException If the component diagnostics are missing or malformed.
+ *
+ * @return PanelView Component overview followed by its build chunks.
+ */
+ private static function component(array $data): PanelView
+ {
+ $mode = self::string($data, 'mode');
+
+ $available = $data['inspectionAvailable'] ?? null;
+
+ if (!is_bool($available)) {
+ throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_INSPECTION_INVALID->getMessage(),
+ );
+ }
+
+ $entrypoints = $data['entrypoints'] ?? null;
+
+ if (!is_array($entrypoints) || !array_is_list($entrypoints)) {
+ throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_ENTRYPOINT_LIST_INVALID->getMessage(),
+ );
+ }
+
+ foreach ($entrypoints as $entrypoint) {
+ if (!is_string($entrypoint)) {
+ throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_ENTRYPOINT_TYPE_INVALID->getMessage(),
+ );
+ }
+ }
+
+ $view = PanelView::create()->overview(
+ [
+ 'Component ID' => self::string($data, 'id'),
+ 'Class' => self::string($data, 'class'),
+ 'Implementation' => self::string($data, 'implementation'),
+ 'Mode' => self::mode($mode),
+ 'Inspection' => PanelView::badge($available ? 'Available' : 'Unavailable', $available
+ ? Tone::SUCCESS
+ : Tone::WARNING),
+ 'Entry points' => $entrypoints === []
+ ? 'โ'
+ : implode(', ', $entrypoints),
+ 'Base URL' => self::nonEmpty(self::string($data, 'baseUrl')),
+ 'Dev server' => self::nullableString($data, 'devServerUrl') ?? 'โ',
+ 'Manifest' => self::nonEmpty(self::string($data, 'manifestPath')),
+ 'Vite client' => $mode === 'production'
+ ? 'Not applicable'
+ : self::flag($data['includeViteClient'] ?? null),
+ 'Module preload' => $mode === 'development'
+ ? 'Not applicable'
+ : self::flag($data['modulePreload'] ?? null),
+ ],
+ compact: true,
+ );
+
+ if (!$available) {
+ $view = $view->callout(
+ Tone::WARNING,
+ 'Runtime inspection is unavailable for this component. Its public configuration could not be read without changing application state.',
+ );
+ }
+
+ return self::chunks($view->heading('Build chunks', section: true), $data, $mode);
+ }
+
+ /**
+ * Describes a tri-state configuration flag for the overview.
+ *
+ * @param mixed $value Captured flag value.
+ *
+ * @throws InvalidArgumentException If the value is neither a boolean nor `null`.
+ *
+ * @return string Human-readable flag state.
+ */
+ private static function flag(mixed $value): string
+ {
+ return match ($value) {
+ true => 'Enabled', false => 'Disabled', null => 'Unknown',
+ default => throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_FLAG_INVALID->getMessage(),
+ ),
+ };
+ }
+
+ /**
+ * Returns the human-readable label for a captured resolution mode.
+ *
+ * @param string $mode Captured resolution mode.
+ *
+ * @throws InvalidArgumentException If the mode is not a known Vite diagnostics mode.
+ *
+ * @return string Human-readable mode label.
+ */
+ private static function mode(string $mode): string
+ {
+ return match ($mode) {
+ 'development' => 'Development', 'production' => 'Production', 'unknown' => 'Unknown',
+ default => throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_MODE_UNKNOWN->getMessage(),
+ ),
+ };
+ }
+
+ /**
+ * Returns the value, or a dash placeholder when the capture left it empty.
+ *
+ * @param string $value Captured value.
+ *
+ * @return string Captured value, or a dash placeholder when empty.
+ */
+ private static function nonEmpty(string $value): string
+ {
+ return $value === '' ? 'โ' : $value;
+ }
+
+ /**
+ * Returns the captured value for the key when it is a string or `null`.
+ *
+ * @param array $data Captured component diagnostics.
+ * @param string $key Diagnostics key to read.
+ *
+ * @throws InvalidArgumentException If the value is neither a string nor `null`.
+ *
+ * @return string|null Captured string, or `null` when the key holds no value.
+ */
+ private static function nullableString(array $data, string $key): string|null
+ {
+ $value = $data[$key] ?? null;
+
+ if ($value !== null && !is_string($value)) {
+ throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_VALUE_NOT_NULLABLE_STRING->getMessage($key),
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Returns the captured value for the key, rejecting any non-string type.
+ *
+ * @param array $data Captured component diagnostics.
+ * @param string $key Diagnostics key to read.
+ *
+ * @throws InvalidArgumentException If the value is not a string.
+ *
+ * @return string Captured string.
+ */
+ private static function string(array $data, string $key): string
+ {
+ $value = $data[$key] ?? null;
+
+ if (!is_string($value)) {
+ throw new InvalidArgumentException(
+ Message::DIAGNOSTICS_VALUE_NOT_STRING->getMessage($key),
+ );
+ }
+
+ return $value;
+ }
+}
diff --git a/src/Event/AssetsResolved.php b/src/Event/AssetsResolved.php
new file mode 100644
index 0000000..780bc37
--- /dev/null
+++ b/src/Event/AssetsResolved.php
@@ -0,0 +1,26 @@
+ $entrypoints Actual normalized entrypoints for this resolution.
+ * @param Manifest|null $manifest The manifest already used by the resolver, or `null` in development mode.
+ */
+ public function __construct(
+ public DevelopmentConfiguration|ProductionConfiguration $configuration,
+ public array $entrypoints,
+ public AssetCollection $assets,
+ public Manifest|null $manifest = null,
+ ) {}
+}
diff --git a/src/Exception/Message.php b/src/Exception/Message.php
index 06d1bac..8abe257 100644
--- a/src/Exception/Message.php
+++ b/src/Exception/Message.php
@@ -105,6 +105,83 @@ enum Message: string
*/
case DEVELOPMENT_SERVER_URL_INVALID = 'The "devServerUrl" value must be an absolute HTTP(S) URL.';
+ /**
+ * A captured build chunk has missing or malformed fields.
+ *
+ * Format: "Vite chunk diagnostics are invalid."
+ */
+ case DIAGNOSTICS_CHUNK_INVALID = 'Vite chunk diagnostics are invalid.';
+
+ /**
+ * The captured build chunks are not a list.
+ *
+ * Format: "Vite chunks must be a list."
+ */
+ case DIAGNOSTICS_CHUNK_LIST_INVALID = 'Vite chunks must be a list.';
+
+ /**
+ * A captured component is not an object.
+ *
+ * Format: "Vite component diagnostics must be an object."
+ */
+ case DIAGNOSTICS_COMPONENT_INVALID = 'Vite component diagnostics must be an object.';
+
+ /**
+ * The capture carries no component list.
+ *
+ * Format: "Vite diagnostics must contain a component list."
+ */
+ case DIAGNOSTICS_COMPONENT_LIST_REQUIRED = 'Vite diagnostics must contain a component list.';
+
+ /**
+ * The captured entrypoints are not a list.
+ *
+ * Format: "Vite entrypoints must be a list."
+ */
+ case DIAGNOSTICS_ENTRYPOINT_LIST_INVALID = 'Vite entrypoints must be a list.';
+
+ /**
+ * A captured entrypoint is not a `string`.
+ *
+ * Format: "Vite entrypoints must be strings."
+ */
+ case DIAGNOSTICS_ENTRYPOINT_TYPE_INVALID = 'Vite entrypoints must be strings.';
+
+ /**
+ * A captured configuration flag is neither a boolean nor `null`.
+ *
+ * Format: "Vite flags must be boolean or null."
+ */
+ case DIAGNOSTICS_FLAG_INVALID = 'Vite flags must be boolean or null.';
+
+ /**
+ * The captured inspection availability is not a boolean.
+ *
+ * Format: "Vite inspection availability must be a boolean."
+ */
+ case DIAGNOSTICS_INSPECTION_INVALID = 'Vite inspection availability must be a boolean.';
+
+ /**
+ * A captured component declares an unknown resolution mode.
+ *
+ * Format: "Unknown Vite diagnostics mode."
+ */
+ case DIAGNOSTICS_MODE_UNKNOWN = 'Unknown Vite diagnostics mode.';
+
+ /**
+ * A captured diagnostics value is neither a `string` nor `null`.
+ *
+ * Format: "Vite %s must be a string or null."
+ */
+ case DIAGNOSTICS_VALUE_NOT_NULLABLE_STRING = 'Vite %s must be a string or null.';
+
+ /**
+ * A captured diagnostics value is not a `string`.
+ *
+ * Format: "Vite %s must be a string."
+ */
+ case DIAGNOSTICS_VALUE_NOT_STRING = 'Vite %s must be a string.';
+
/**
* A Vite entrypoint is not a valid relative source path.
*
diff --git a/src/Resolver/DevelopmentAssetResolver.php b/src/Resolver/DevelopmentAssetResolver.php
index ebc865f..ee76782 100644
--- a/src/Resolver/DevelopmentAssetResolver.php
+++ b/src/Resolver/DevelopmentAssetResolver.php
@@ -6,8 +6,10 @@
use PHPForge\Vite\Asset\{AssetCollection, ModuleScript};
use PHPForge\Vite\Configuration\DevelopmentConfiguration;
+use PHPForge\Vite\Event\AssetsResolved;
use PHPForge\Vite\Exception\ConfigurationException;
use PHPForge\Vite\Support\Url;
+use Psr\EventDispatcher\EventDispatcherInterface;
/**
* Resolves development assets against a running Vite development server.
@@ -16,8 +18,12 @@
{
/**
* @param DevelopmentConfiguration $configuration Validated development-server configuration.
+ * @param EventDispatcherInterface|null $eventDispatcher Optional dispatcher for completed resolutions.
*/
- public function __construct(private DevelopmentConfiguration $configuration) {}
+ public function __construct(
+ private DevelopmentConfiguration $configuration,
+ private EventDispatcherInterface|null $eventDispatcher = null,
+ ) {}
/**
* Resolves the entrypoints into inline modules, the optional Vite client, and one module script per entrypoint.
@@ -52,6 +58,9 @@ public function resolve(array $entrypoints): AssetCollection
);
}
- return new AssetCollection($assets);
+ $collection = new AssetCollection($assets);
+ $this->eventDispatcher?->dispatch(new AssetsResolved($this->configuration, $entrypoints, $collection));
+
+ return $collection;
}
}
diff --git a/src/Resolver/ManifestAssetResolver.php b/src/Resolver/ManifestAssetResolver.php
index 257994f..bf26027 100644
--- a/src/Resolver/ManifestAssetResolver.php
+++ b/src/Resolver/ManifestAssetResolver.php
@@ -6,6 +6,7 @@
use PHPForge\Vite\Asset\{AssetCollection, ModulePreload, ModuleScript, Stylesheet};
use PHPForge\Vite\Configuration\ProductionConfiguration;
+use PHPForge\Vite\Event\AssetsResolved;
use PHPForge\Vite\Exception\{
ConfigurationException,
EntrypointNotFoundException,
@@ -16,6 +17,7 @@
};
use PHPForge\Vite\Manifest\{Manifest, ManifestChunk, ManifestLoader};
use PHPForge\Vite\Support\Url;
+use Psr\EventDispatcher\EventDispatcherInterface;
/**
* Resolves production assets and dependency preloads from a validated Vite manifest.
@@ -25,10 +27,12 @@
/**
* @param ProductionConfiguration $configuration Validated production configuration.
* @param ManifestLoader $manifestLoader Loader providing the validated manifest.
+ * @param EventDispatcherInterface|null $eventDispatcher Optional dispatcher for completed resolutions.
*/
public function __construct(
private ProductionConfiguration $configuration,
private ManifestLoader $manifestLoader,
+ private EventDispatcherInterface|null $eventDispatcher = null,
) {}
/**
@@ -127,7 +131,10 @@ public function resolve(array $entrypoints): AssetCollection
}
}
- return new AssetCollection($this->orderedAssets($stylesheets, $scripts, $preloads));
+ $collection = new AssetCollection($this->orderedAssets($stylesheets, $scripts, $preloads));
+ $this->eventDispatcher?->dispatch(new AssetsResolved($this->configuration, $entrypoints, $collection, $manifest));
+
+ return $collection;
}
/**
diff --git a/src/Vite.php b/src/Vite.php
index 11b7ca1..9fd0ddb 100644
--- a/src/Vite.php
+++ b/src/Vite.php
@@ -17,6 +17,7 @@
use PHPForge\Vite\Manifest\ManifestLoader;
use PHPForge\Vite\Resolver\{AssetResolverInterface, DevelopmentAssetResolver, ManifestAssetResolver};
use PHPForge\Vite\Support\EntrypointNormalizer;
+use Psr\EventDispatcher\EventDispatcherInterface;
/**
* Resolves framework-neutral Vite assets for development-server or production-manifest configuration.
@@ -53,6 +54,7 @@
* @param DevelopmentConfiguration|ProductionConfiguration $configuration Configuration selecting the strategy.
* @param list $entrypoints Default entrypoints to validate and resolve when no override is supplied.
* @param ManifestLoader|null $manifestLoader Loader to share across instances, or `null` to create one.
+ * @param EventDispatcherInterface|null $eventDispatcher Optional dispatcher for completed asset resolutions.
*
* @throws InvalidEntrypointException if a default entrypoint is not a `string`, is empty, or contains a
* backslash or a control character.
@@ -61,6 +63,7 @@ public function __construct(
DevelopmentConfiguration|ProductionConfiguration $configuration,
array $entrypoints = [],
ManifestLoader|null $manifestLoader = null,
+ EventDispatcherInterface|null $eventDispatcher = null,
) {
$this->manifestLoader = $manifestLoader ?? new ManifestLoader();
@@ -69,14 +72,14 @@ public function __construct(
if ($configuration instanceof DevelopmentConfiguration) {
$this->manifestPath = null;
- $this->resolver = new DevelopmentAssetResolver($configuration);
+ $this->resolver = new DevelopmentAssetResolver($configuration, $eventDispatcher);
return;
}
$this->manifestPath = $configuration->manifestPath;
- $this->resolver = new ManifestAssetResolver($configuration, $this->manifestLoader);
+ $this->resolver = new ManifestAssetResolver($configuration, $this->manifestLoader, $eventDispatcher);
}
/**
@@ -97,6 +100,7 @@ public function clearManifestCache(): void
* @param DevelopmentConfiguration|ProductionConfiguration $configuration Configuration selecting the strategy.
* @param list $entrypoints Default entrypoints to validate and resolve when no override is supplied.
* @param ManifestLoader|null $manifestLoader Loader to share across instances, or `null` to create one.
+ * @param EventDispatcherInterface|null $eventDispatcher Optional dispatcher for completed asset resolutions.
*
* @throws InvalidEntrypointException if a default entrypoint is not a `string`, is empty, or contains a
* backslash or a control character.
@@ -107,8 +111,9 @@ public static function create(
DevelopmentConfiguration|ProductionConfiguration $configuration,
array $entrypoints = [],
ManifestLoader|null $manifestLoader = null,
+ EventDispatcherInterface|null $eventDispatcher = null,
): self {
- return new self($configuration, $entrypoints, $manifestLoader);
+ return new self($configuration, $entrypoints, $manifestLoader, $eventDispatcher);
}
/**
diff --git a/tests/Debug/ViteCollectorTest.php b/tests/Debug/ViteCollectorTest.php
new file mode 100644
index 0000000..92eb206
--- /dev/null
+++ b/tests/Debug/ViteCollectorTest.php
@@ -0,0 +1,536 @@
+resolve();
+
+ self::assertNull(
+ $collector->capture(),
+ "No cycle means 'null'.",
+ );
+
+ $collector->startup();
+
+ self::assertSame(
+ ['components' => []],
+ $collector->capture(),
+ 'Observed empty capture, not missing.',
+ );
+ self::assertFalse(
+ (new VitePanel())->present(['components' => []])->isActive(),
+ 'An empty capture must not activate the panel.',
+ );
+
+ $collector->shutdown();
+ }
+
+ /**
+ * @param list|string> $resolutions
+ * @param array $expected
+ */
+ #[DataProviderExternal(ViteCollectorProvider::class, 'developmentCaptures')]
+ public function testDevelopmentCaptureDescribesEveryResolutionInTheCycle(array $resolutions, array $expected): void
+ {
+ $collector = new ViteCollector();
+ $dispatcher = new CollectingEventDispatcherStub([$collector]);
+
+ $vite = self::developmentVite(
+ self::developmentConfiguration(),
+ $dispatcher,
+ );
+
+ $collector->startup();
+
+ foreach ($resolutions as $entrypoints) {
+ $vite->resolve($entrypoints);
+ }
+
+ $capture = $collector->capture();
+
+ self::assertCount(
+ count($resolutions),
+ $dispatcher->events,
+ 'One event per resolution.',
+ );
+ self::assertNotNull(
+ $capture,
+ 'An active cycle must produce a capture.',
+ );
+ self::assertSame(
+ $expected,
+ $capture,
+ 'Components must accumulate in resolution order.',
+ );
+ self::assertTrue(
+ (new VitePanel())->present($capture)->isActive(),
+ 'Captured components must activate the panel.',
+ );
+
+ $collector->shutdown();
+ }
+
+ public function testDispatchedEventCarriesTheResolvedAssetsAndConfiguration(): void
+ {
+ $collector = new ViteCollector();
+
+ $configuration = self::developmentConfiguration();
+
+ $dispatcher = new CollectingEventDispatcherStub([$collector]);
+
+ $vite = self::developmentVite(
+ $configuration,
+ $dispatcher,
+ );
+
+ $collector->startup();
+
+ $assets = $vite->resolve([' /app.js ', 'app.js']);
+
+ $event = $dispatcher->events[0] ?? null;
+
+ self::assertInstanceOf(
+ AssetsResolved::class,
+ $event,
+ 'Every resolution must be dispatched.',
+ );
+ self::assertSame(
+ $assets,
+ $event->assets,
+ 'Assets must reach the listener unchanged.',
+ );
+ self::assertSame(
+ $configuration,
+ $event->configuration,
+ 'Configuration must reach the listener unchanged.',
+ );
+ self::assertNull(
+ $event->manifest,
+ 'A development resolution has no manifest.',
+ );
+ self::assertSame(
+ ['app.js'],
+ $event->entrypoints,
+ 'Entrypoints must be trimmed and deduplicated.',
+ );
+
+ $collector->shutdown();
+ }
+
+ public function testDispatchForwardsResolutionsAndIgnoresOtherEvents(): void
+ {
+ $unrelated = new stdClass();
+ $idle = new ViteCollector();
+
+ $idle->startup();
+
+ self::assertSame(
+ $unrelated,
+ $idle->dispatch($unrelated),
+ 'An unrelated event must pass through.',
+ );
+ self::assertSame(
+ ['components' => []],
+ $idle->capture(),
+ 'An unrelated event must not be captured.',
+ );
+
+ $idle->shutdown();
+
+ $collector = new ViteCollector();
+
+ $vite = self::developmentVite(
+ self::developmentConfiguration(),
+ $collector,
+ );
+
+ $collector->startup();
+
+ $vite->resolve('app.js');
+
+ $capture = $collector->capture();
+
+ self::assertNotNull(
+ $capture,
+ 'An active cycle must produce a capture.',
+ );
+
+ $components = $capture['components'] ?? null;
+
+ self::assertIsArray(
+ $components,
+ 'The capture must carry a component list.',
+ );
+ self::assertCount(
+ 1,
+ $components,
+ 'The collector must observe the resolution it dispatched itself.',
+ );
+
+ $collector->shutdown();
+ }
+
+ public function testExternalResolutionWithoutManifestDoesNotReadTheFilesystem(): void
+ {
+ $collector = new ViteCollector();
+
+ $collector->startup();
+
+ $collector(
+ new AssetsResolved(
+ ProductionConfiguration::create(
+ '/not-present/manifest.json',
+ '/build',
+ ),
+ ['app.js'],
+ new AssetCollection(),
+ ),
+ );
+
+ $payload = $collector->capture();
+
+ self::assertNotNull(
+ $payload,
+ 'An active cycle must produce a capture.',
+ );
+
+ $components = $payload['components'] ?? null;
+
+ self::assertIsArray(
+ $components,
+ 'The capture must carry a component list.',
+ );
+
+ $component = $components[0] ?? null;
+
+ self::assertIsArray(
+ $component,
+ 'The observed resolution must be described.',
+ );
+ self::assertSame(
+ [],
+ $component['chunks'] ?? null,
+ 'A missing manifest must yield no chunks.',
+ );
+
+ $collector->shutdown();
+ }
+
+ public function testIdStaysStable(): void
+ {
+ self::assertSame(
+ 'vite',
+ (new ViteCollector())->id(),
+ 'Collector ID must stay stable.',
+ );
+ }
+
+ public function testInlineProvidersRunOncePerResolution(): void
+ {
+ $collector = new ViteCollector();
+ $inline = new CountingInlineModuleProviderStub();
+ $dispatcher = new CollectingEventDispatcherStub([$collector]);
+
+ $vite = self::developmentVite(
+ self::developmentConfiguration($inline),
+ $dispatcher,
+ );
+
+ $collector->startup();
+
+ $vite->resolve();
+ $vite->resolve('app.js');
+ $vite->resolve('second.js');
+
+ self::assertCount(
+ 3,
+ $dispatcher->events,
+ 'One event per resolution.',
+ );
+ self::assertSame(
+ 3,
+ $inline->calls,
+ 'Inline providers must run once per resolution.',
+ );
+
+ $collector->shutdown();
+ }
+
+ public function testListenerFailurePropagatesWithoutRepeatingTheOperation(): void
+ {
+ $failure = new RuntimeException(
+ 'listener failed',
+ );
+ $dispatcher = new CollectingEventDispatcherStub(
+ [static fn(AssetsResolved $event): never => throw $failure],
+ );
+
+ $vite = self::developmentVite(
+ self::developmentConfiguration(),
+ $dispatcher,
+ );
+
+ try {
+ $vite->resolve();
+
+ self::fail(
+ 'A failing listener must not be swallowed.',
+ );
+ } catch (RuntimeException $caught) {
+ self::assertSame(
+ $failure,
+ $caught,
+ 'The listener failure must stay primary.',
+ );
+ }
+
+ self::assertCount(
+ 1,
+ $dispatcher->events,
+ 'The failed operation must not be retried.',
+ );
+ }
+
+ public function testProductionCaptureUsesTheResolvedManifestEvenAfterRemoval(): void
+ {
+ $path = sys_get_temp_dir() . '/observed-vite-' . uniqid() . '.json';
+
+ file_put_contents(
+ $path,
+ json_encode(
+ [
+ 'app.js' => [
+ 'file' => 'assets/app.js',
+ 'isEntry' => true,
+ 'css' => ['assets/app.css'],
+ 'imports' => ['extra.js'],
+ ],
+ 'extra.js' => ['file' => 'assets/extra.js'],
+ ],
+ JSON_THROW_ON_ERROR,
+ ),
+ );
+
+ $collector = new ViteCollector();
+ $dispatcher = new CollectingEventDispatcherStub([$collector]);
+
+ $configuration = ProductionConfiguration::create(
+ $path,
+ '/build',
+ false,
+ );
+
+ $vite = new Vite(
+ $configuration,
+ ['app.js'],
+ eventDispatcher: $dispatcher,
+ );
+
+ $collector->startup();
+
+ try {
+ $assets = $vite->resolve();
+ } finally {
+ unlink($path);
+ }
+
+ $event = $dispatcher->events[0] ?? null;
+
+ self::assertInstanceOf(
+ AssetsResolved::class,
+ $event,
+ 'The resolution must be dispatched.',
+ );
+ self::assertSame(
+ $assets,
+ $event->assets,
+ 'Assets must reach the listener unchanged.',
+ );
+ self::assertSame(
+ $configuration,
+ $event->configuration,
+ 'Configuration must reach the listener unchanged.',
+ );
+ self::assertNotNull(
+ $event->manifest,
+ 'A production resolution must carry the manifest.',
+ );
+ self::assertCount(
+ 2,
+ $event->manifest->chunks(),
+ 'Both manifest entries must be loaded.',
+ );
+
+ $capture = $collector->capture();
+
+ self::assertSame(
+ [
+ 'components' => [
+ [
+ 'id' => 'vite-1',
+ 'class' => Vite::class,
+ 'implementation' => 'modern',
+ 'inspectionAvailable' => true,
+ 'mode' => 'production',
+ 'entrypoints' => ['app.js'],
+ 'baseUrl' => '/build',
+ 'devServerUrl' => null,
+ 'manifestPath' => $path,
+ 'includeViteClient' => null,
+ 'modulePreload' => false,
+ 'chunks' => [
+ [
+ 'name' => 'app.js',
+ 'file' => 'assets/app.js',
+ 'cssCount' => 1,
+ 'imports' => 1,
+ 'isEntry' => true,
+ ],
+ [
+ 'name' => 'extra.js',
+ 'file' => 'assets/extra.js',
+ 'cssCount' => 0,
+ 'imports' => 0,
+ 'isEntry' => false,
+ ],
+ ],
+ ],
+ ],
+ ],
+ $capture,
+ 'Capture must keep the manifest values read before removal.',
+ );
+
+ $collector->shutdown();
+
+ self::assertTrue(
+ (new VitePanel())->present($capture)->isActive(),
+ 'A replayed capture must activate the panel.',
+ );
+
+ $collector->startup();
+
+ $this->expectException(ManifestNotFoundException::class);
+
+ try {
+ $vite->resolve();
+ } finally {
+ self::assertSame(
+ ['components' => []],
+ $collector->capture(),
+ 'A failed resolution must leave the cycle empty.',
+ );
+ self::assertCount(
+ 1,
+ $dispatcher->events,
+ 'A failed resolution must not dispatch.',
+ );
+
+ $collector->shutdown();
+ }
+ }
+
+ public function testShutdownDiscardsTheCycleAndStartupBeginsEmpty(): void
+ {
+ $collector = new ViteCollector();
+
+ $vite = self::developmentVite(
+ self::developmentConfiguration(),
+ new CollectingEventDispatcherStub([$collector]),
+ );
+
+ $collector->startup();
+
+ $vite->resolve('app.js');
+
+ $collector->shutdown();
+ $collector->shutdown();
+
+ $vite->resolve();
+
+ self::assertNull(
+ $collector->capture(),
+ 'A stopped collector must not observe.',
+ );
+
+ $collector->startup();
+
+ self::assertSame(
+ ['components' => []],
+ $collector->capture(),
+ 'A new cycle must start empty.',
+ );
+
+ $collector->shutdown();
+ }
+
+ /**
+ * Builds the development configuration shared by the collector tests.
+ *
+ * @param InlineModuleProviderInterface ...$providers Inline module providers of the configuration.
+ *
+ * @return DevelopmentConfiguration Configuration pointing at the shared development server URL.
+ */
+ private static function developmentConfiguration(
+ InlineModuleProviderInterface ...$providers,
+ ): DevelopmentConfiguration {
+ return DevelopmentConfiguration::create(
+ ViteCollectorProvider::DEV_SERVER_URL,
+ false,
+ array_values($providers),
+ );
+ }
+
+ /**
+ * Builds a development Vite instance resolving through the supplied dispatcher.
+ *
+ * @param DevelopmentConfiguration $configuration Configuration the instance resolves with.
+ * @param EventDispatcherInterface $dispatcher Dispatcher receiving every resolution.
+ *
+ * @return Vite Instance resolving the default entrypoint.
+ */
+ private static function developmentVite(
+ DevelopmentConfiguration $configuration,
+ EventDispatcherInterface $dispatcher,
+ ): Vite {
+ return Vite::create(
+ $configuration,
+ ['default.js'],
+ eventDispatcher: $dispatcher,
+ );
+ }
+}
diff --git a/tests/Debug/VitePanelTest.php b/tests/Debug/VitePanelTest.php
new file mode 100644
index 0000000..9bbe7ec
--- /dev/null
+++ b/tests/Debug/VitePanelTest.php
@@ -0,0 +1,277 @@
+ 'app.js',
+ 'file' => '0',
+ 'cssCount' => 2,
+ 'imports' => 3,
+ 'isEntry' => true,
+ ],
+ ];
+
+ $view = $panel->present(['components' => $components]);
+
+ $content = self::group(self::blockAt($view, 0))['content'];
+ $rows = self::table(self::blockAt($content, 2))['rows'];
+
+ $cell = $rows[0][2] ?? self::fail('Chunks must use the shared table contract.');
+
+ self::assertSame(
+ '0',
+ self::textValue($cell),
+ 'A zero string is not an empty filename.',
+ );
+
+ $components[] = VitePanelProvider::components('development')[0];
+
+ self::assertSame(
+ '2 components ยท Mixed',
+ self::metricValue($panel->present(['components' => $components])->toolbarMetrics(), 0),
+ 'Different modes must be explicit.',
+ );
+ }
+
+ public function testCompleteDescriptionsMatchReviewedFixtures(): void
+ {
+ $paths = glob(__DIR__ . '/fixtures/*.input.json');
+
+ self::assertNotFalse(
+ $paths,
+ 'The capture fixture directory must be readable.',
+ );
+ self::assertNotEmpty(
+ $paths,
+ 'The complete-description fixtures must be present.',
+ );
+
+ foreach ($paths as $path) {
+ $input = file_get_contents($path);
+
+ self::assertNotFalse(
+ $input,
+ 'The stored capture fixture must be readable.',
+ );
+
+ /** @var array $data */
+ $data = json_decode($input, true, flags: JSON_THROW_ON_ERROR);
+
+ $view = (new VitePanel())->present($data);
+
+ self::assertStringEqualsFile(
+ str_replace('.input.json', '.view.json', $path),
+ json_encode(
+ $view,
+ JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+ ) . "\n",
+ 'The complete semantic description must preserve content, order, and styles.',
+ );
+ }
+ }
+
+ public function testMetadataAndEmptyCapture(): void
+ {
+ $panel = new VitePanel();
+
+ self::assertSame(
+ 'vite',
+ $panel->id(),
+ 'The persisted panel ID must stay stable.',
+ );
+ self::assertSame(
+ 'Vite',
+ $panel->name(),
+ 'The panel title must stay stable.',
+ );
+ self::assertSame(
+ 'brand-javascript',
+ $panel->icon(),
+ 'The panel must reuse the existing icon.',
+ );
+
+ $view = $panel->present(['components' => []]);
+
+ self::assertFalse(
+ $view->isActive(),
+ 'An empty capture must not activate navigation.',
+ );
+ self::assertSame(
+ [],
+ $view->toolbarMetrics(),
+ 'An empty capture must not add a toolbar metric.',
+ );
+ self::assertSame(
+ '0',
+ self::metricValue($view->summaryMetrics(), 0),
+ 'The summary must retain the empty count.',
+ );
+ }
+
+ #[DataProviderExternal(VitePanelProvider::class, 'modes')]
+ public function testModeLabelAndGroupRemainProviderOwned(string $mode, string $label): void
+ {
+ $view = (new VitePanel())->present(['components' => VitePanelProvider::components($mode)]);
+
+ self::assertTrue(
+ $view->isActive(),
+ 'Captured components must activate the panel.',
+ );
+ self::assertSame(
+ $label,
+ self::metricValue($view->toolbarMetrics(), 0),
+ 'The toolbar must describe the captured mode.',
+ );
+ self::assertSame(
+ 'Vite component frontend',
+ self::group(self::blockAt($view, 0))['label'],
+ 'The group must identify the component.',
+ );
+ }
+
+ /**
+ * @param array $capture
+ */
+ #[DataProviderExternal(VitePanelProvider::class, 'malformedCaptures')]
+ public function testThrowInvalidArgumentExceptionForMalformedCapture(array $capture, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ $message,
+ );
+
+ (new VitePanel())->present($capture);
+ }
+
+ public function testUnavailableInspectionAndFlagsRemainVisible(): void
+ {
+ $components = VitePanelProvider::components('unknown');
+
+ $components[0]['inspectionAvailable'] = false;
+ $components[0]['includeViteClient'] = null;
+
+ $view = (new VitePanel())->present(['components' => $components]);
+
+ $content = self::group(self::blockAt($view, 0))['content'];
+
+ self::assertCount(
+ 4,
+ $content->blocks(),
+ 'Unavailable inspection must add a warning block.',
+ );
+
+ $fields = self::overview(self::blockAt($content, 0))['fields'];
+
+ $field = $fields[9] ?? self::fail('Configuration must remain inspectable.');
+
+ self::assertSame(
+ 'Unknown',
+ self::textValue($field['value']),
+ 'Unavailable flags must not become disabled.',
+ );
+ }
+
+ /**
+ * @return Block
+ */
+ private static function blockAt(PanelView $view, int $index): array
+ {
+ return $view->blocks()[$index] ?? self::fail('The declared presentation structure must be complete.');
+ }
+
+ /**
+ * @param Block $block
+ *
+ * @return GroupBlock
+ */
+ private static function group(array $block): array
+ {
+ return match ($block['kind']) {
+ 'group' => $block,
+ default => self::fail('Each integration must have an accessible group.'),
+ };
+ }
+
+ /**
+ * @param list $metrics
+ */
+ private static function metricValue(array $metrics, int $index): string
+ {
+ $metric = $metrics[$index] ?? self::fail('The declared presentation structure must be complete.');
+
+ return self::textValue($metric['value']);
+ }
+
+ /**
+ * @param Block $block
+ *
+ * @return OverviewBlock
+ */
+ private static function overview(array $block): array
+ {
+ return match ($block['kind']) {
+ 'overview' => $block,
+ default => self::fail('Configuration must remain inspectable.'),
+ };
+ }
+
+ /**
+ * @param Block $block
+ *
+ * @return TableBlock
+ */
+ private static function table(array $block): array
+ {
+ return match ($block['kind']) {
+ 'table' => $block,
+ default => self::fail('Chunks must use the shared table contract.'),
+ };
+ }
+
+ /**
+ * @param Inline $inline
+ */
+ private static function textValue(array $inline): string
+ {
+ return match ($inline['kind']) {
+ 'text' => $inline['value'],
+ default => self::fail('The value must be plain text.'),
+ };
+ }
+}
diff --git a/tests/Debug/fixtures/chunks.input.json b/tests/Debug/fixtures/chunks.input.json
new file mode 100644
index 0000000..83a3876
--- /dev/null
+++ b/tests/Debug/fixtures/chunks.input.json
@@ -0,0 +1,36 @@
+{
+ "components": [
+ {
+ "id": "frontend",
+ "class": "Example\\Vite",
+ "implementation": "modern",
+ "inspectionAvailable": true,
+ "mode": "production",
+ "entrypoints": [
+ "app.js",
+ "style.css"
+ ],
+ "baseUrl": "/assets",
+ "devServerUrl": "http://localhost:5173",
+ "manifestPath": "/app/manifest.json",
+ "includeViteClient": true,
+ "modulePreload": false,
+ "chunks": [
+ {
+ "name": "app.js",
+ "file": "0",
+ "cssCount": 2,
+ "imports": 3,
+ "isEntry": true
+ },
+ {
+ "name": "vendor",
+ "file": "",
+ "cssCount": 0,
+ "imports": 0,
+ "isEntry": false
+ }
+ ]
+ }
+ ]
+}
diff --git a/tests/Debug/fixtures/chunks.view.json b/tests/Debug/fixtures/chunks.view.json
new file mode 100644
index 0000000..e6ac10b
--- /dev/null
+++ b/tests/Debug/fixtures/chunks.view.json
@@ -0,0 +1,228 @@
+{
+ "summary": [
+ {
+ "label": " component",
+ "value": {
+ "kind": "text",
+ "value": "1",
+ "style": "strong"
+ }
+ },
+ {
+ "label": "",
+ "value": {
+ "kind": "text",
+ "value": "Production",
+ "style": "plain"
+ }
+ }
+ ],
+ "blocks": [
+ {
+ "kind": "group",
+ "label": "Vite component frontend",
+ "content": {
+ "summary": [],
+ "blocks": [
+ {
+ "kind": "overview",
+ "fields": [
+ {
+ "label": "Component ID",
+ "value": {
+ "kind": "text",
+ "value": "frontend",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Class",
+ "value": {
+ "kind": "text",
+ "value": "Example\\Vite",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Implementation",
+ "value": {
+ "kind": "text",
+ "value": "modern",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Mode",
+ "value": {
+ "kind": "text",
+ "value": "Production",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Inspection",
+ "value": {
+ "kind": "badge",
+ "label": "Available",
+ "tone": "success"
+ }
+ },
+ {
+ "label": "Entry points",
+ "value": {
+ "kind": "text",
+ "value": "app.js, style.css",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Base URL",
+ "value": {
+ "kind": "text",
+ "value": "/assets",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Dev server",
+ "value": {
+ "kind": "text",
+ "value": "http://localhost:5173",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Manifest",
+ "value": {
+ "kind": "text",
+ "value": "/app/manifest.json",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Vite client",
+ "value": {
+ "kind": "text",
+ "value": "Not applicable",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Module preload",
+ "value": {
+ "kind": "text",
+ "value": "Disabled",
+ "style": "plain"
+ }
+ }
+ ],
+ "compact": true
+ },
+ {
+ "kind": "heading",
+ "title": "Build chunks",
+ "section": true
+ },
+ {
+ "kind": "table",
+ "headers": [
+ "#",
+ "Chunk",
+ "Output",
+ "CSS",
+ "Imports",
+ "Entry"
+ ],
+ "rows": [
+ [
+ {
+ "kind": "text",
+ "value": "1",
+ "style": "plain"
+ },
+ {
+ "kind": "text",
+ "value": "app.js",
+ "style": "strong"
+ },
+ {
+ "kind": "text",
+ "value": "0",
+ "style": "plain"
+ },
+ {
+ "kind": "text",
+ "value": "2",
+ "style": "plain"
+ },
+ {
+ "kind": "text",
+ "value": "3",
+ "style": "plain"
+ },
+ {
+ "kind": "badge",
+ "label": "entry",
+ "tone": "success"
+ }
+ ],
+ [
+ {
+ "kind": "text",
+ "value": "2",
+ "style": "plain"
+ },
+ {
+ "kind": "text",
+ "value": "vendor",
+ "style": "strong"
+ },
+ {
+ "kind": "text",
+ "value": "โ",
+ "style": "plain"
+ },
+ {
+ "kind": "text",
+ "value": "0",
+ "style": "plain"
+ },
+ {
+ "kind": "text",
+ "value": "0",
+ "style": "plain"
+ },
+ {
+ "kind": "text",
+ "value": "โ",
+ "style": "plain"
+ }
+ ]
+ ],
+ "styles": {
+ "1": "monospace",
+ "2": "monospace",
+ "3": "number",
+ "4": "number",
+ "5": "pill"
+ },
+ "collapsible": false
+ }
+ ],
+ "toolbar": [],
+ "active": true
+ }
+ }
+ ],
+ "toolbar": [
+ {
+ "label": "Vite mode",
+ "value": {
+ "kind": "text",
+ "value": "Production",
+ "style": "plain"
+ }
+ }
+ ],
+ "active": true
+}
diff --git a/tests/Debug/fixtures/development.input.json b/tests/Debug/fixtures/development.input.json
new file mode 100644
index 0000000..db50968
--- /dev/null
+++ b/tests/Debug/fixtures/development.input.json
@@ -0,0 +1,21 @@
+{
+ "components": [
+ {
+ "id": "frontend",
+ "class": "Example\\Vite",
+ "implementation": "modern",
+ "inspectionAvailable": true,
+ "mode": "development",
+ "entrypoints": [
+ "app.js",
+ "style.css"
+ ],
+ "baseUrl": "/assets",
+ "devServerUrl": "http://localhost:5173",
+ "manifestPath": "/app/manifest.json",
+ "includeViteClient": true,
+ "modulePreload": false,
+ "chunks": []
+ }
+ ]
+}
diff --git a/tests/Debug/fixtures/development.view.json b/tests/Debug/fixtures/development.view.json
new file mode 100644
index 0000000..3258939
--- /dev/null
+++ b/tests/Debug/fixtures/development.view.json
@@ -0,0 +1,154 @@
+{
+ "summary": [
+ {
+ "label": " component",
+ "value": {
+ "kind": "text",
+ "value": "1",
+ "style": "strong"
+ }
+ },
+ {
+ "label": "",
+ "value": {
+ "kind": "text",
+ "value": "Development",
+ "style": "plain"
+ }
+ }
+ ],
+ "blocks": [
+ {
+ "kind": "group",
+ "label": "Vite component frontend",
+ "content": {
+ "summary": [],
+ "blocks": [
+ {
+ "kind": "overview",
+ "fields": [
+ {
+ "label": "Component ID",
+ "value": {
+ "kind": "text",
+ "value": "frontend",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Class",
+ "value": {
+ "kind": "text",
+ "value": "Example\\Vite",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Implementation",
+ "value": {
+ "kind": "text",
+ "value": "modern",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Mode",
+ "value": {
+ "kind": "text",
+ "value": "Development",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Inspection",
+ "value": {
+ "kind": "badge",
+ "label": "Available",
+ "tone": "success"
+ }
+ },
+ {
+ "label": "Entry points",
+ "value": {
+ "kind": "text",
+ "value": "app.js, style.css",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Base URL",
+ "value": {
+ "kind": "text",
+ "value": "/assets",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Dev server",
+ "value": {
+ "kind": "text",
+ "value": "http://localhost:5173",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Manifest",
+ "value": {
+ "kind": "text",
+ "value": "/app/manifest.json",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Vite client",
+ "value": {
+ "kind": "text",
+ "value": "Enabled",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Module preload",
+ "value": {
+ "kind": "text",
+ "value": "Not applicable",
+ "style": "plain"
+ }
+ }
+ ],
+ "compact": true
+ },
+ {
+ "kind": "heading",
+ "title": "Build chunks",
+ "section": true
+ },
+ {
+ "kind": "paragraph",
+ "content": [
+ {
+ "kind": "text",
+ "value": "Development mode resolves entry points through the dev server.",
+ "style": "plain"
+ }
+ ],
+ "tone": null
+ }
+ ],
+ "toolbar": [],
+ "active": true
+ }
+ }
+ ],
+ "toolbar": [
+ {
+ "label": "Vite mode",
+ "value": {
+ "kind": "text",
+ "value": "Development",
+ "style": "plain"
+ }
+ }
+ ],
+ "active": true
+}
diff --git a/tests/Debug/fixtures/empty.input.json b/tests/Debug/fixtures/empty.input.json
new file mode 100644
index 0000000..026b5e8
--- /dev/null
+++ b/tests/Debug/fixtures/empty.input.json
@@ -0,0 +1,3 @@
+{
+ "components": []
+}
diff --git a/tests/Debug/fixtures/empty.view.json b/tests/Debug/fixtures/empty.view.json
new file mode 100644
index 0000000..bc47fb1
--- /dev/null
+++ b/tests/Debug/fixtures/empty.view.json
@@ -0,0 +1,33 @@
+{
+ "summary": [
+ {
+ "label": " components",
+ "value": {
+ "kind": "text",
+ "value": "0",
+ "style": "strong"
+ }
+ }
+ ],
+ "blocks": [
+ {
+ "kind": "emptyState",
+ "title": "No Vite integrations captured",
+ "paragraphs": [
+ {
+ "kind": "paragraph",
+ "content": [
+ {
+ "kind": "text",
+ "value": "This request did not use an initialized Vite application component.",
+ "style": "plain"
+ }
+ ],
+ "tone": null
+ }
+ ]
+ }
+ ],
+ "toolbar": [],
+ "active": false
+}
diff --git a/tests/Debug/fixtures/mixed.input.json b/tests/Debug/fixtures/mixed.input.json
new file mode 100644
index 0000000..45df308
--- /dev/null
+++ b/tests/Debug/fixtures/mixed.input.json
@@ -0,0 +1,38 @@
+{
+ "components": [
+ {
+ "id": "frontend",
+ "class": "Example\\Vite",
+ "implementation": "modern",
+ "inspectionAvailable": true,
+ "mode": "production",
+ "entrypoints": [
+ "app.js",
+ "style.css"
+ ],
+ "baseUrl": "/assets",
+ "devServerUrl": "http://localhost:5173",
+ "manifestPath": "/app/manifest.json",
+ "includeViteClient": true,
+ "modulePreload": false,
+ "chunks": []
+ },
+ {
+ "id": "frontend",
+ "class": "Example\\Vite",
+ "implementation": "modern",
+ "inspectionAvailable": true,
+ "mode": "development",
+ "entrypoints": [
+ "app.js",
+ "style.css"
+ ],
+ "baseUrl": "/assets",
+ "devServerUrl": "http://localhost:5173",
+ "manifestPath": "/app/manifest.json",
+ "includeViteClient": true,
+ "modulePreload": false,
+ "chunks": []
+ }
+ ]
+}
diff --git a/tests/Debug/fixtures/mixed.view.json b/tests/Debug/fixtures/mixed.view.json
new file mode 100644
index 0000000..83229c8
--- /dev/null
+++ b/tests/Debug/fixtures/mixed.view.json
@@ -0,0 +1,275 @@
+{
+ "summary": [
+ {
+ "label": " components",
+ "value": {
+ "kind": "text",
+ "value": "2",
+ "style": "strong"
+ }
+ },
+ {
+ "label": "",
+ "value": {
+ "kind": "text",
+ "value": "Mixed",
+ "style": "plain"
+ }
+ }
+ ],
+ "blocks": [
+ {
+ "kind": "group",
+ "label": "Vite component frontend",
+ "content": {
+ "summary": [],
+ "blocks": [
+ {
+ "kind": "overview",
+ "fields": [
+ {
+ "label": "Component ID",
+ "value": {
+ "kind": "text",
+ "value": "frontend",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Class",
+ "value": {
+ "kind": "text",
+ "value": "Example\\Vite",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Implementation",
+ "value": {
+ "kind": "text",
+ "value": "modern",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Mode",
+ "value": {
+ "kind": "text",
+ "value": "Production",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Inspection",
+ "value": {
+ "kind": "badge",
+ "label": "Available",
+ "tone": "success"
+ }
+ },
+ {
+ "label": "Entry points",
+ "value": {
+ "kind": "text",
+ "value": "app.js, style.css",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Base URL",
+ "value": {
+ "kind": "text",
+ "value": "/assets",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Dev server",
+ "value": {
+ "kind": "text",
+ "value": "http://localhost:5173",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Manifest",
+ "value": {
+ "kind": "text",
+ "value": "/app/manifest.json",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Vite client",
+ "value": {
+ "kind": "text",
+ "value": "Not applicable",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Module preload",
+ "value": {
+ "kind": "text",
+ "value": "Disabled",
+ "style": "plain"
+ }
+ }
+ ],
+ "compact": true
+ },
+ {
+ "kind": "heading",
+ "title": "Build chunks",
+ "section": true
+ },
+ {
+ "kind": "paragraph",
+ "content": [
+ {
+ "kind": "text",
+ "value": "The Vite manifest is missing or empty; run the front-end build to populate it.",
+ "style": "plain"
+ }
+ ],
+ "tone": null
+ }
+ ],
+ "toolbar": [],
+ "active": true
+ }
+ },
+ {
+ "kind": "group",
+ "label": "Vite component frontend",
+ "content": {
+ "summary": [],
+ "blocks": [
+ {
+ "kind": "overview",
+ "fields": [
+ {
+ "label": "Component ID",
+ "value": {
+ "kind": "text",
+ "value": "frontend",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Class",
+ "value": {
+ "kind": "text",
+ "value": "Example\\Vite",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Implementation",
+ "value": {
+ "kind": "text",
+ "value": "modern",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Mode",
+ "value": {
+ "kind": "text",
+ "value": "Development",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Inspection",
+ "value": {
+ "kind": "badge",
+ "label": "Available",
+ "tone": "success"
+ }
+ },
+ {
+ "label": "Entry points",
+ "value": {
+ "kind": "text",
+ "value": "app.js, style.css",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Base URL",
+ "value": {
+ "kind": "text",
+ "value": "/assets",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Dev server",
+ "value": {
+ "kind": "text",
+ "value": "http://localhost:5173",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Manifest",
+ "value": {
+ "kind": "text",
+ "value": "/app/manifest.json",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Vite client",
+ "value": {
+ "kind": "text",
+ "value": "Enabled",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Module preload",
+ "value": {
+ "kind": "text",
+ "value": "Not applicable",
+ "style": "plain"
+ }
+ }
+ ],
+ "compact": true
+ },
+ {
+ "kind": "heading",
+ "title": "Build chunks",
+ "section": true
+ },
+ {
+ "kind": "paragraph",
+ "content": [
+ {
+ "kind": "text",
+ "value": "Development mode resolves entry points through the dev server.",
+ "style": "plain"
+ }
+ ],
+ "tone": null
+ }
+ ],
+ "toolbar": [],
+ "active": true
+ }
+ }
+ ],
+ "toolbar": [
+ {
+ "label": "Vite mode",
+ "value": {
+ "kind": "text",
+ "value": "2 components ยท Mixed",
+ "style": "plain"
+ }
+ }
+ ],
+ "active": true
+}
diff --git a/tests/Debug/fixtures/production.input.json b/tests/Debug/fixtures/production.input.json
new file mode 100644
index 0000000..ace257f
--- /dev/null
+++ b/tests/Debug/fixtures/production.input.json
@@ -0,0 +1,21 @@
+{
+ "components": [
+ {
+ "id": "frontend",
+ "class": "Example\\Vite",
+ "implementation": "modern",
+ "inspectionAvailable": true,
+ "mode": "production",
+ "entrypoints": [
+ "app.js",
+ "style.css"
+ ],
+ "baseUrl": "/assets",
+ "devServerUrl": "http://localhost:5173",
+ "manifestPath": "/app/manifest.json",
+ "includeViteClient": true,
+ "modulePreload": false,
+ "chunks": []
+ }
+ ]
+}
diff --git a/tests/Debug/fixtures/production.view.json b/tests/Debug/fixtures/production.view.json
new file mode 100644
index 0000000..a1592eb
--- /dev/null
+++ b/tests/Debug/fixtures/production.view.json
@@ -0,0 +1,154 @@
+{
+ "summary": [
+ {
+ "label": " component",
+ "value": {
+ "kind": "text",
+ "value": "1",
+ "style": "strong"
+ }
+ },
+ {
+ "label": "",
+ "value": {
+ "kind": "text",
+ "value": "Production",
+ "style": "plain"
+ }
+ }
+ ],
+ "blocks": [
+ {
+ "kind": "group",
+ "label": "Vite component frontend",
+ "content": {
+ "summary": [],
+ "blocks": [
+ {
+ "kind": "overview",
+ "fields": [
+ {
+ "label": "Component ID",
+ "value": {
+ "kind": "text",
+ "value": "frontend",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Class",
+ "value": {
+ "kind": "text",
+ "value": "Example\\Vite",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Implementation",
+ "value": {
+ "kind": "text",
+ "value": "modern",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Mode",
+ "value": {
+ "kind": "text",
+ "value": "Production",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Inspection",
+ "value": {
+ "kind": "badge",
+ "label": "Available",
+ "tone": "success"
+ }
+ },
+ {
+ "label": "Entry points",
+ "value": {
+ "kind": "text",
+ "value": "app.js, style.css",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Base URL",
+ "value": {
+ "kind": "text",
+ "value": "/assets",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Dev server",
+ "value": {
+ "kind": "text",
+ "value": "http://localhost:5173",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Manifest",
+ "value": {
+ "kind": "text",
+ "value": "/app/manifest.json",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Vite client",
+ "value": {
+ "kind": "text",
+ "value": "Not applicable",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Module preload",
+ "value": {
+ "kind": "text",
+ "value": "Disabled",
+ "style": "plain"
+ }
+ }
+ ],
+ "compact": true
+ },
+ {
+ "kind": "heading",
+ "title": "Build chunks",
+ "section": true
+ },
+ {
+ "kind": "paragraph",
+ "content": [
+ {
+ "kind": "text",
+ "value": "The Vite manifest is missing or empty; run the front-end build to populate it.",
+ "style": "plain"
+ }
+ ],
+ "tone": null
+ }
+ ],
+ "toolbar": [],
+ "active": true
+ }
+ }
+ ],
+ "toolbar": [
+ {
+ "label": "Vite mode",
+ "value": {
+ "kind": "text",
+ "value": "Production",
+ "style": "plain"
+ }
+ }
+ ],
+ "active": true
+}
diff --git a/tests/Debug/fixtures/unavailable.input.json b/tests/Debug/fixtures/unavailable.input.json
new file mode 100644
index 0000000..32f0742
--- /dev/null
+++ b/tests/Debug/fixtures/unavailable.input.json
@@ -0,0 +1,18 @@
+{
+ "components": [
+ {
+ "id": "frontend",
+ "class": "Example\\Vite",
+ "implementation": "modern",
+ "inspectionAvailable": false,
+ "mode": "unknown",
+ "entrypoints": [],
+ "baseUrl": "",
+ "devServerUrl": null,
+ "manifestPath": "",
+ "includeViteClient": null,
+ "modulePreload": null,
+ "chunks": []
+ }
+ ]
+}
diff --git a/tests/Debug/fixtures/unavailable.view.json b/tests/Debug/fixtures/unavailable.view.json
new file mode 100644
index 0000000..ae4a8dc
--- /dev/null
+++ b/tests/Debug/fixtures/unavailable.view.json
@@ -0,0 +1,165 @@
+{
+ "summary": [
+ {
+ "label": " component",
+ "value": {
+ "kind": "text",
+ "value": "1",
+ "style": "strong"
+ }
+ },
+ {
+ "label": "",
+ "value": {
+ "kind": "text",
+ "value": "Unknown",
+ "style": "plain"
+ }
+ }
+ ],
+ "blocks": [
+ {
+ "kind": "group",
+ "label": "Vite component frontend",
+ "content": {
+ "summary": [],
+ "blocks": [
+ {
+ "kind": "overview",
+ "fields": [
+ {
+ "label": "Component ID",
+ "value": {
+ "kind": "text",
+ "value": "frontend",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Class",
+ "value": {
+ "kind": "text",
+ "value": "Example\\Vite",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Implementation",
+ "value": {
+ "kind": "text",
+ "value": "modern",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Mode",
+ "value": {
+ "kind": "text",
+ "value": "Unknown",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Inspection",
+ "value": {
+ "kind": "badge",
+ "label": "Unavailable",
+ "tone": "warning"
+ }
+ },
+ {
+ "label": "Entry points",
+ "value": {
+ "kind": "text",
+ "value": "โ",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Base URL",
+ "value": {
+ "kind": "text",
+ "value": "โ",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Dev server",
+ "value": {
+ "kind": "text",
+ "value": "โ",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Manifest",
+ "value": {
+ "kind": "text",
+ "value": "โ",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Vite client",
+ "value": {
+ "kind": "text",
+ "value": "Unknown",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Module preload",
+ "value": {
+ "kind": "text",
+ "value": "Unknown",
+ "style": "plain"
+ }
+ }
+ ],
+ "compact": true
+ },
+ {
+ "kind": "paragraph",
+ "content": [
+ {
+ "kind": "text",
+ "value": "Runtime inspection is unavailable for this component. Its public configuration could not be read without changing application state.",
+ "style": "plain"
+ }
+ ],
+ "tone": "warning"
+ },
+ {
+ "kind": "heading",
+ "title": "Build chunks",
+ "section": true
+ },
+ {
+ "kind": "paragraph",
+ "content": [
+ {
+ "kind": "text",
+ "value": "No build chunks were available for inspection.",
+ "style": "plain"
+ }
+ ],
+ "tone": null
+ }
+ ],
+ "toolbar": [],
+ "active": true
+ }
+ }
+ ],
+ "toolbar": [
+ {
+ "label": "Vite mode",
+ "value": {
+ "kind": "text",
+ "value": "Unknown",
+ "style": "plain"
+ }
+ }
+ ],
+ "active": true
+}
diff --git a/tests/Debug/fixtures/unknown.input.json b/tests/Debug/fixtures/unknown.input.json
new file mode 100644
index 0000000..5300e5e
--- /dev/null
+++ b/tests/Debug/fixtures/unknown.input.json
@@ -0,0 +1,21 @@
+{
+ "components": [
+ {
+ "id": "frontend",
+ "class": "Example\\Vite",
+ "implementation": "modern",
+ "inspectionAvailable": true,
+ "mode": "unknown",
+ "entrypoints": [
+ "app.js",
+ "style.css"
+ ],
+ "baseUrl": "/assets",
+ "devServerUrl": "http://localhost:5173",
+ "manifestPath": "/app/manifest.json",
+ "includeViteClient": true,
+ "modulePreload": false,
+ "chunks": []
+ }
+ ]
+}
diff --git a/tests/Debug/fixtures/unknown.view.json b/tests/Debug/fixtures/unknown.view.json
new file mode 100644
index 0000000..f51eda0
--- /dev/null
+++ b/tests/Debug/fixtures/unknown.view.json
@@ -0,0 +1,154 @@
+{
+ "summary": [
+ {
+ "label": " component",
+ "value": {
+ "kind": "text",
+ "value": "1",
+ "style": "strong"
+ }
+ },
+ {
+ "label": "",
+ "value": {
+ "kind": "text",
+ "value": "Unknown",
+ "style": "plain"
+ }
+ }
+ ],
+ "blocks": [
+ {
+ "kind": "group",
+ "label": "Vite component frontend",
+ "content": {
+ "summary": [],
+ "blocks": [
+ {
+ "kind": "overview",
+ "fields": [
+ {
+ "label": "Component ID",
+ "value": {
+ "kind": "text",
+ "value": "frontend",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Class",
+ "value": {
+ "kind": "text",
+ "value": "Example\\Vite",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Implementation",
+ "value": {
+ "kind": "text",
+ "value": "modern",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Mode",
+ "value": {
+ "kind": "text",
+ "value": "Unknown",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Inspection",
+ "value": {
+ "kind": "badge",
+ "label": "Available",
+ "tone": "success"
+ }
+ },
+ {
+ "label": "Entry points",
+ "value": {
+ "kind": "text",
+ "value": "app.js, style.css",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Base URL",
+ "value": {
+ "kind": "text",
+ "value": "/assets",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Dev server",
+ "value": {
+ "kind": "text",
+ "value": "http://localhost:5173",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Manifest",
+ "value": {
+ "kind": "text",
+ "value": "/app/manifest.json",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Vite client",
+ "value": {
+ "kind": "text",
+ "value": "Enabled",
+ "style": "plain"
+ }
+ },
+ {
+ "label": "Module preload",
+ "value": {
+ "kind": "text",
+ "value": "Disabled",
+ "style": "plain"
+ }
+ }
+ ],
+ "compact": true
+ },
+ {
+ "kind": "heading",
+ "title": "Build chunks",
+ "section": true
+ },
+ {
+ "kind": "paragraph",
+ "content": [
+ {
+ "kind": "text",
+ "value": "No build chunks were available for inspection.",
+ "style": "plain"
+ }
+ ],
+ "tone": null
+ }
+ ],
+ "toolbar": [],
+ "active": true
+ }
+ }
+ ],
+ "toolbar": [
+ {
+ "label": "Vite mode",
+ "value": {
+ "kind": "text",
+ "value": "Unknown",
+ "style": "plain"
+ }
+ }
+ ],
+ "active": true
+}
diff --git a/tests/Fixture/CollectingEventDispatcherStub.php b/tests/Fixture/CollectingEventDispatcherStub.php
new file mode 100644
index 0000000..c06af83
--- /dev/null
+++ b/tests/Fixture/CollectingEventDispatcherStub.php
@@ -0,0 +1,46 @@
+ Events dispatched so far, in dispatch order.
+ */
+ public array $events = [];
+
+ /**
+ * @param list $listeners Listeners invoked for every resolved-assets event.
+ */
+ public function __construct(private readonly array $listeners = []) {}
+
+ /**
+ * Records the event and forwards it to every listener.
+ *
+ * @param object $event Event to dispatch.
+ *
+ * @return object The dispatched event.
+ */
+ public function dispatch(object $event): object
+ {
+ $this->events[] = $event;
+
+ if ($event instanceof AssetsResolved) {
+ foreach ($this->listeners as $listener) {
+ $listener($event);
+ }
+ }
+
+ return $event;
+ }
+}
diff --git a/tests/Fixture/CountingInlineModuleProviderStub.php b/tests/Fixture/CountingInlineModuleProviderStub.php
new file mode 100644
index 0000000..972ddb8
--- /dev/null
+++ b/tests/Fixture/CountingInlineModuleProviderStub.php
@@ -0,0 +1,35 @@
+calls;
+
+ return new InlineModule('window.example = true;');
+ }
+}
diff --git a/tests/Provider/ViteCollectorProvider.php b/tests/Provider/ViteCollectorProvider.php
new file mode 100644
index 0000000..fed9eca
--- /dev/null
+++ b/tests/Provider/ViteCollectorProvider.php
@@ -0,0 +1,63 @@
+|string>, array}>
+ */
+ public static function developmentCaptures(): iterable
+ {
+ yield 'single resolution' => [
+ ['second.js'],
+ ['components' => [self::component(1, ['second.js'])]],
+ ];
+ yield 'trimmed and deduplicated entrypoints' => [
+ [[' /app.js ', 'app.js']],
+ ['components' => [self::component(1, ['app.js'])]],
+ ];
+ yield 'two resolutions in one cycle' => [
+ [[' /app.js ', 'app.js'], 'second.js'],
+ ['components' => [self::component(1, ['app.js']), self::component(2, ['second.js'])]],
+ ];
+ }
+
+ /**
+ * Describes one observed development resolution.
+ *
+ * @param int $number Position of the resolution in the cycle.
+ * @param list $entrypoints Normalized entrypoints of the resolution.
+ *
+ * @return array Captured component payload.
+ */
+ private static function component(int $number, array $entrypoints): array
+ {
+ return [
+ 'id' => "vite-{$number}",
+ 'class' => Vite::class,
+ 'implementation' => 'modern',
+ 'inspectionAvailable' => true,
+ 'mode' => 'development',
+ 'entrypoints' => $entrypoints,
+ 'baseUrl' => '',
+ 'devServerUrl' => self::DEV_SERVER_URL,
+ 'manifestPath' => '',
+ 'includeViteClient' => false,
+ 'modulePreload' => null,
+ 'chunks' => [],
+ ];
+ }
+}
diff --git a/tests/Provider/VitePanelProvider.php b/tests/Provider/VitePanelProvider.php
new file mode 100644
index 0000000..e4e46c1
--- /dev/null
+++ b/tests/Provider/VitePanelProvider.php
@@ -0,0 +1,165 @@
+ $overrides Fields replacing the valid ones.
+ *
+ * @return non-empty-list> Captured component list.
+ */
+ public static function components(string $mode, array $overrides = []): array
+ {
+ return [
+ array_replace(
+ [
+ 'id' => 'frontend',
+ 'class' => 'Example\\Vite',
+ 'implementation' => 'modern',
+ 'inspectionAvailable' => true,
+ 'mode' => $mode,
+ 'entrypoints' => ['app.js'],
+ 'baseUrl' => '',
+ 'devServerUrl' => null,
+ 'manifestPath' => '',
+ 'includeViteClient' => true,
+ 'modulePreload' => false,
+ 'chunks' => [],
+ ],
+ $overrides,
+ ),
+ ];
+ }
+
+ /**
+ * @return iterable, string}>
+ */
+ public static function malformedCaptures(): iterable
+ {
+ $listRequired = Message::DIAGNOSTICS_COMPONENT_LIST_REQUIRED->getMessage();
+ $chunkInvalid = Message::DIAGNOSTICS_CHUNK_INVALID->getMessage();
+
+ yield 'missing component list' => [
+ [],
+ $listRequired,
+ ];
+ yield 'component list is not an array' => [
+ ['components' => false],
+ $listRequired,
+ ];
+ yield 'component list is not a list' => [
+ ['components' => ['invalid' => self::components('unknown')[0]]],
+ $listRequired,
+ ];
+ yield 'component is not an array' => [
+ ['components' => [false]],
+ Message::DIAGNOSTICS_COMPONENT_INVALID->getMessage(),
+ ];
+ yield 'mode is not a string' => [
+ ['components' => self::components('unknown', ['mode' => 42])],
+ Message::DIAGNOSTICS_VALUE_NOT_STRING->getMessage('mode'),
+ ];
+ yield 'mode is unknown' => [
+ ['components' => self::components('invalid')],
+ Message::DIAGNOSTICS_MODE_UNKNOWN->getMessage(),
+ ];
+ yield 'inspection availability is not a boolean' => [
+ ['components' => self::components('unknown', ['inspectionAvailable' => 1])],
+ Message::DIAGNOSTICS_INSPECTION_INVALID->getMessage(),
+ ];
+ yield 'entrypoints are not a list' => [
+ ['components' => self::components('unknown', ['entrypoints' => false])],
+ Message::DIAGNOSTICS_ENTRYPOINT_LIST_INVALID->getMessage(),
+ ];
+ yield 'entrypoint is not a string' => [
+ ['components' => self::components('unknown', ['entrypoints' => [false]])],
+ Message::DIAGNOSTICS_ENTRYPOINT_TYPE_INVALID->getMessage(),
+ ];
+ yield 'development server URL is not a string' => [
+ ['components' => self::components('unknown', ['devServerUrl' => 42])],
+ Message::DIAGNOSTICS_VALUE_NOT_NULLABLE_STRING->getMessage('devServerUrl'),
+ ];
+ yield 'Vite client flag is not a boolean' => [
+ ['components' => self::components('unknown', ['includeViteClient' => 'yes'])],
+ Message::DIAGNOSTICS_FLAG_INVALID->getMessage(),
+ ];
+ yield 'chunks are not a list' => [
+ ['components' => self::components('unknown', ['chunks' => false])],
+ Message::DIAGNOSTICS_CHUNK_LIST_INVALID->getMessage(),
+ ];
+ yield 'chunks are keyed instead of a list' => [
+ ['components' => self::components('unknown', ['chunks' => ['first' => self::chunk()]])],
+ Message::DIAGNOSTICS_CHUNK_LIST_INVALID->getMessage(),
+ ];
+ yield 'entrypoints are keyed instead of a list' => [
+ ['components' => self::components('unknown', ['entrypoints' => ['first' => 'app.js']])],
+ Message::DIAGNOSTICS_ENTRYPOINT_LIST_INVALID->getMessage(),
+ ];
+ yield 'chunk is not an array' => [
+ ['components' => self::components('unknown', ['chunks' => [false]])],
+ $chunkInvalid,
+ ];
+ yield 'chunk carries no fields' => [
+ ['components' => self::components('unknown', ['chunks' => [[]]])],
+ $chunkInvalid,
+ ];
+ yield 'chunk carries no imports' => [
+ ['components' => self::components('unknown', ['chunks' => [['cssCount' => 0]]])],
+ $chunkInvalid,
+ ];
+ yield 'chunk carries no entry flag' => [
+ ['components' => self::components('unknown', ['chunks' => [['cssCount' => 0, 'imports' => 0]]])],
+ $chunkInvalid,
+ ];
+ yield 'chunk CSS count is not an integer' => [
+ ['components' => self::components('unknown', ['chunks' => [self::chunk(['cssCount' => 'invalid'])]])],
+ $chunkInvalid,
+ ];
+ yield 'chunk imports are not an integer' => [
+ ['components' => self::components('unknown', ['chunks' => [self::chunk(['imports' => 'invalid'])]])],
+ $chunkInvalid,
+ ];
+ yield 'chunk entry flag is not a boolean' => [
+ ['components' => self::components('unknown', ['chunks' => [self::chunk(['isEntry' => 'invalid'])]])],
+ $chunkInvalid,
+ ];
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function modes(): iterable
+ {
+ yield 'development' => ['development', 'Development'];
+ yield 'production' => ['production', 'Production'];
+ yield 'unknown' => ['unknown', 'Unknown'];
+ }
+
+ /**
+ * Builds one valid production-manifest chunk.
+ *
+ * @param array $overrides Fields replacing the valid ones.
+ *
+ * @return array Captured chunk payload.
+ */
+ private static function chunk(array $overrides = []): array
+ {
+ return array_replace(
+ ['name' => 'app.js', 'file' => 'app.js', 'cssCount' => 0, 'imports' => 0, 'isEntry' => true],
+ $overrides,
+ );
+ }
+}