From c98605282609224467faf02b2e67de73a346c2da Mon Sep 17 00:00:00 2001 From: jcgueriaud1 Date: Tue, 5 May 2026 15:11:04 +0300 Subject: [PATCH 1/6] Update Skill --- skills/vaadin-playwright-test/SKILL.md | 36 +++++-- skills/vaadin-playwright-test/setup.md | 102 +++++++++--------- .../vaadin-playwright-test/tile.json | 4 +- 3 files changed, 84 insertions(+), 58 deletions(-) rename tile.json => skills/vaadin-playwright-test/tile.json (82%) diff --git a/skills/vaadin-playwright-test/SKILL.md b/skills/vaadin-playwright-test/SKILL.md index 9e6ff08..80b3aa1 100644 --- a/skills/vaadin-playwright-test/SKILL.md +++ b/skills/vaadin-playwright-test/SKILL.md @@ -19,15 +19,28 @@ Always follow the guidelines in [@TESTING.md](TESTING.md) when generating tests. Run these checks in parallel before doing anything else: -1. **DramaFinder on classpath?** — grep `pom.xml` for `dramafinder` or `org.vaadin.addons` -2. **Spring Boot app?** — grep `pom.xml` for `spring-boot-starter` or `vaadin-spring-boot-starter` -3. **Existing IT tests?** — look for `*IT.java` files under `src/test/java` +1. **DramaFinder on classpath?** — grep `pom.xml` for `dramafinder`. +2. **Spring Boot app?** — grep `pom.xml` for `spring-boot-starter`. +3. **Existing IT tests?** — look for `*IT.java` files under `src/test/java`. +4. **`SpringPlaywrightIT` already in project?** — `find src/test/java -name SpringPlaywrightIT.java`. -### DramaFinder not found +### DramaFinder not found — propose setup, then run it -Show the user this message and stop: +Resolve the latest version (Step 1 of [setup.md](setup.md)) so you can show a concrete version number, then propose a single confirmation: -> DramaFinder is not on the classpath. Follow the [setup guide](setup.md) to add the required dependencies, then come back to generate tests. +**Spring Boot project:** + +> DramaFinder isn't on the classpath. To generate this test, I need to: +> 1. Add `org.vaadin.addons:dramafinder:` and `com.microsoft.playwright:playwright` (test scope) to `pom.xml`, with `` in ``. +> 2. Create `src/test/java//it/support/SpringPlaywrightIT.java`. +> +> Proceed? + +**Non-Spring project:** + +> DramaFinder isn't on the classpath. To generate this test, I need to add `org.vaadin.addons:dramafinder:` and `com.microsoft.playwright:playwright` (test scope) to `pom.xml`, with `` in ``. Proceed? + +On confirmation, execute [setup.md](setup.md) end-to-end, then continue with Step 2. ### DramaFinder found — follow existing patterns @@ -35,6 +48,14 @@ If existing `*IT.java` files are found, read one or two of them to understand th If no existing IT tests exist, use the default structure in Step 3. +### `SpringPlaywrightIT` location + +When generating a Spring Boot test, the import for `SpringPlaywrightIT` must match wherever the file actually lives in the user's `src/test/java`: + +- 1 hit from the `find` above → use that fully-qualified class name as the import. +- 0 hits + Spring Boot detected → run setup (it will create the file). +- More than 1 hit → ask the user which one to use. + ## Step 2 — Map view components to DramaFinder elements Read the target view source provided by the user. Extract: @@ -58,7 +79,8 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.vaadin.addons.dramafinder.element.TextFieldElement; // import only used elements -import org.vaadin.addons.dramafinder.tests.it.SpringPlaywrightIT; // or AbstractBasePlaywrightIT +import .it.support.SpringPlaywrightIT; // Spring projects: actual location from Step 1 +// import org.vaadin.addons.dramafinder.AbstractBasePlaywrightIT; // non-Spring projects import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/skills/vaadin-playwright-test/setup.md b/skills/vaadin-playwright-test/setup.md index 15fc974..6fe002f 100644 --- a/skills/vaadin-playwright-test/setup.md +++ b/skills/vaadin-playwright-test/setup.md @@ -1,82 +1,86 @@ --- name: DramaFinder Setup -description: How to add DramaFinder and Playwright to a Vaadin 25 Spring Boot project. +description: Runbook for adding DramaFinder + Playwright to a Vaadin 25 project. Executed by Claude after the user confirms the setup plan. --- -# Setting Up DramaFinder in Your Vaadin Project +# DramaFinder Setup Runbook -## 1. Add dependencies to `pom.xml` +This file is a runbook for Claude to execute after the user confirms the setup plan in SKILL.md Step 1. Do not relay it to the user as documentation — perform the steps. + +## Constants + +- `KNOWN_LATEST = 1.1.1` — fallback version if Maven Central lookup fails. Bump when the library releases. + +## Step 1 — Resolve the latest version + +Run: + +```bash +curl -s "https://repo1.maven.org/maven2/org/vaadin/addons/dramafinder/maven-metadata.xml" \ + | grep -o '[^<]*' | sed 's/<[^>]*>//g' +``` + +If the command returns a non-empty version string, use it. Otherwise, use `KNOWN_LATEST`. + +## Step 2 — Edit `pom.xml` + +Two edits, both in `pom.xml`: + +### 2a. Add the version property + +Inside ``, add: + +```xml +RESOLVED_VERSION +``` + +Replace `RESOLVED_VERSION` with the value from Step 1. If a `` property already exists, leave it alone. + +### 2b. Add the dependencies + +Inside ``, add (skip whichever is already present): ```xml - com.microsoft.playwright playwright test - - org.vaadin.addons.dramafinder + org.vaadin.addons dramafinder - LATEST + ${dramafinder.version} test ``` -Check the latest version at [GitHub releases](https://github.com/vaadin/dramafinder/releases). +Note the groupId is `org.vaadin.addons` (not `org.vaadin.addons.dramafinder`). -## 2. Base test class +## Step 3 — Copy `SpringPlaywrightIT` (Spring Boot projects only) -All IT tests extend `SpringPlaywrightIT` (Spring Boot) or `AbstractBasePlaywrightIT` (plain): +Skip this step entirely if the project is not a Spring Boot project. -```java -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -public class MyViewIT extends SpringPlaywrightIT { +If `find src/test/java -name SpringPlaywrightIT.java` already returns a result, skip — the file exists and will be used as-is. - @Override - public String getView() { - return "/my-route"; - } +Otherwise: - @Test - public void testTitle() { - assertThat(page).hasTitle("Expected Title"); - } -} -``` +1. **Find the base package** by locating the class annotated with `@SpringBootApplication` under `src/main/java`. Take its package (e.g., `com.example.app`). +2. **Target package** is `.it.support` (e.g., `com.example.app.it.support`). +3. **Read** `templates/SpringPlaywrightIT.java.tmpl` from this skill directory. +4. **Substitute** `{{PACKAGE}}` with the target package. +5. **Write** the result to `src/test/java//it/support/SpringPlaywrightIT.java`. -## 3. Run tests +## Step 4 — Confirm dependencies resolve -```bash -# Run all IT tests -mvn verify +Optional but recommended: run `mvn -q dependency:resolve` to surface any pom syntax errors before generating tests. Skip if the user wants to proceed without compile-time verification. -# Run a specific IT test -mvn verify -Dit.test=MyViewIT -``` +## Debugging with a visible browser (informational) -> **Note:** The first Vaadin frontend build takes 3–5 minutes. Subsequent runs take ~25 seconds. - -## 4. Debugging with a visible browser - -To disable headless mode and watch the browser during a test run, use the `headless` property or add a `debug-ui` profile: +To run tests with a visible browser, pass `-Dheadless=false`: ```bash -# Via system property mvn -Dit.test=MyViewIT -Dheadless=false verify - -# Via profile (if added to pom.xml) -mvn -Pdebug-ui -Dit.test=MyViewIT verify ``` -Add this profile to your `pom.xml` to enable the `-Pdebug-ui` shorthand: - -```xml - - debug-ui - - false - - -``` +A `debug-ui` profile can be added to `pom.xml` for shorthand `-Pdebug-ui`. Mention this only if the user asks about debugging. diff --git a/tile.json b/skills/vaadin-playwright-test/tile.json similarity index 82% rename from tile.json rename to skills/vaadin-playwright-test/tile.json index bc0a8a1..19c8d94 100644 --- a/tile.json +++ b/skills/vaadin-playwright-test/tile.json @@ -1,11 +1,11 @@ { "name": "dramafinder/vaadin-playwright-test", - "version": "0.1.0", + "version": "0.1.1", "private": false, "summary": "Generate Playwright integration tests for Vaadin 25 views using the DramaFinder library. Use when the user wants to write IT tests for a Vaadin view, mentions DramaFinder, or asks about Playwright testing in a Vaadin project.", "skills": { "vaadin-playwright-test": { - "path": "skills/vaadin-playwright-test/SKILL.md" + "path": "SKILL.md" } } } From 751a11a3d30eccf869710205b2d0c956e832efaf Mon Sep 17 00:00:00 2001 From: jcgueriaud1 Date: Tue, 5 May 2026 15:16:44 +0300 Subject: [PATCH 2/6] Update Skill --- skills/vaadin-playwright-test/SKILL.md | 126 ++++++++++++++++--------- 1 file changed, 80 insertions(+), 46 deletions(-) diff --git a/skills/vaadin-playwright-test/SKILL.md b/skills/vaadin-playwright-test/SKILL.md index 80b3aa1..8cf5b74 100644 --- a/skills/vaadin-playwright-test/SKILL.md +++ b/skills/vaadin-playwright-test/SKILL.md @@ -7,52 +7,55 @@ description: Generate Playwright integration tests for Vaadin 25 views using the ## Best practices -Always follow the guidelines in [@TESTING.md](TESTING.md) when generating tests. Key rules: - -- **One test, one assert** — each test method covers a single piece of functionality -- **User-facing locators** — prefer label, `aria-label`, `aria-role`, or `data-testid` over CSS classes or generated IDs -- **DramaFinder elements for all interactions** — never interact with raw locators when a wrapper exists -- **No `Thread.sleep()`** — use Playwright auto-waiting or `waitFor` methods instead -- **Assert on user-visible state** — check visibility, text, or enabled/disabled, not internal CSS or component state +Always follow [@TESTING.md](TESTING.md) when generating tests. Key rules: + +- **One test, one assert** — each test method covers a single piece of + functionality +- **User-facing locators** — prefer label, `aria-label`, `aria-role`, or + `data-testid` over CSS classes or generated IDs +- **DramaFinder elements for all interactions** — never interact with raw + locators when a wrapper exists +- **No `Thread.sleep()`** — use Playwright auto-waiting or `waitFor` methods + instead +- **Assert on user-visible state** — check visibility, text, or + enabled/disabled, not internal CSS or component state ## Step 1 — Assess project state Run these checks in parallel before doing anything else: -1. **DramaFinder on classpath?** — grep `pom.xml` for `dramafinder`. +1. **DramaFinder on classpath?** — grep `pom.xml` for + `dramafinder`. 2. **Spring Boot app?** — grep `pom.xml` for `spring-boot-starter`. 3. **Existing IT tests?** — look for `*IT.java` files under `src/test/java`. -4. **`SpringPlaywrightIT` already in project?** — `find src/test/java -name SpringPlaywrightIT.java`. +4. **`SpringPlaywrightIT` already in project?** — + `find src/test/java -name SpringPlaywrightIT.java`. ### DramaFinder not found — propose setup, then run it -Resolve the latest version (Step 1 of [setup.md](setup.md)) so you can show a concrete version number, then propose a single confirmation: - -**Spring Boot project:** - -> DramaFinder isn't on the classpath. To generate this test, I need to: -> 1. Add `org.vaadin.addons:dramafinder:` and `com.microsoft.playwright:playwright` (test scope) to `pom.xml`, with `` in ``. -> 2. Create `src/test/java//it/support/SpringPlaywrightIT.java`. -> -> Proceed? +Resolve the latest version (Step 1 of [setup.md](setup.md)) and propose the +following in a single confirmation: -**Non-Spring project:** +- Add `org.vaadin.addons:dramafinder:` and + `com.microsoft.playwright:playwright` (test scope) to `pom.xml` with + `` in ``. +- **Spring Boot only:** also create + `src/test/java//it/support/SpringPlaywrightIT.java`. -> DramaFinder isn't on the classpath. To generate this test, I need to add `org.vaadin.addons:dramafinder:` and `com.microsoft.playwright:playwright` (test scope) to `pom.xml`, with `` in ``. Proceed? - -On confirmation, execute [setup.md](setup.md) end-to-end, then continue with Step 2. +On confirmation, execute [setup.md](setup.md) end-to-end, then continue with +Step 2. ### DramaFinder found — follow existing patterns -If existing `*IT.java` files are found, read one or two of them to understand the project's conventions (base class, package structure, assertion style, helper methods). Use those as the template for generated tests. +If existing `*IT.java` files are found, read one or two to understand the +project's conventions (base class, package structure, assertion style, helper +methods) and use them as the template. If no existing IT tests exist, use the default structure in Step 3. ### `SpringPlaywrightIT` location -When generating a Spring Boot test, the import for `SpringPlaywrightIT` must match wherever the file actually lives in the user's `src/test/java`: - -- 1 hit from the `find` above → use that fully-qualified class name as the import. +- 1 hit → use that fully-qualified class name. - 0 hits + Spring Boot detected → run setup (it will create the file). - More than 1 hit → ask the user which one to use. @@ -60,41 +63,57 @@ When generating a Spring Boot test, the import for `SpringPlaywrightIT` must mat Read the target view source provided by the user. Extract: -- `@Route("value")` → URL path (default: class name lowercased, stripped of "View" suffix) +- `@Route("value")` → URL path (default: class name lowercased, stripped of + `View` suffix, e.g. `PersonView` → `/person`). - `@PageTitle("...")` → expected page title -- All Vaadin component field declarations and `add(...)` calls → map to DramaFinder elements +- Every interactive component → its DramaFinder wrapper (see table below). +- Form fields → label text used as locator. +- Grids → column headers and row content to assert against. +- Navigation triggers → button labels or menu items that cause route changes. -See [element-mapping.md](element-mapping.md) for the full component → element class table. Each element also has detailed documentation with examples in the [specifications folder](https://github.com/parttio/dramafinder/tree/master/docs/specifications). +See [element-mapping.md](element-mapping.md) for the full component → element +class table. Each element also has detailed documentation with examples in +the [specifications folder](https://github.com/parttio/dramafinder/tree/master/docs/specifications). -For components with **no DramaFinder wrapper**, use a plain Playwright locator. For more complex needs, you can create your own element class extending `VaadinElement`, or [open an issue](https://github.com/vaadin/dramafinder/issues) in the DramaFinder repository to request one. +For components with **no DramaFinder wrapper**, use a plain Playwright locator. +For more complex needs, you can create your own element class extending +`VaadinElement`, +or [open an issue](https://github.com/vaadin/dramafinder/issues) in the +DramaFinder repository to request one. ## Step 3 — Generate the test class ### Default structure (no existing tests to mirror) ```java -package ; // mirror src/test/java structure +package + +; // mirror src/test/java structure import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.vaadin.addons.dramafinder.element.TextFieldElement; // import only used elements + import .it.support.SpringPlaywrightIT; // Spring projects: actual location from Step 1 // import org.vaadin.addons.dramafinder.AbstractBasePlaywrightIT; // non-Spring projects import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) // omit if not Spring Boot -public class IT extends SpringPlaywrightIT { // or AbstractBasePlaywrightIT +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +// omit if not Spring Boot +public class IT extends + +SpringPlaywrightIT { // or AbstractBasePlaywrightIT @Override - public String getView() { + public String getView () { return "/"; } @Test - public void testTitle() { + public void testTitle () { assertThat(page).hasTitle(""); } @@ -102,27 +121,39 @@ public class IT extends SpringPlaywrightIT { // or AbstractBasePlaywri } ``` -Use `SpringPlaywrightIT` if Spring Boot is detected, `AbstractBasePlaywrightIT` otherwise. +Use `SpringPlaywrightIT` if Spring Boot is detected, `AbstractBasePlaywrightIT` +otherwise. ### Component test patterns **Smoke test (one per component):** ```java -@Test -public void test() { - TextFieldElement field = TextFieldElement.getByLabel(page, "My Label"); - field.assertVisible(); - field.assertLabel("My Label"); - field.assertValue(""); - field.setValue("test value"); - field.assertValue("test value"); + @Test +public void test(){ +TextFieldElement field = TextFieldElement.getByLabel(page, "My Label"); + field. + +assertVisible(); + field. + +assertLabel("My Label"); + field. + +assertValue(""); + field. + +setValue("test value"); + field. + +assertValue("test value"); } ``` **Form with validation:** ```java + @Test public void testFormSubmitWithInvalidInput() { TextFieldElement nameField = TextFieldElement.getByLabel(page, "Name"); @@ -146,6 +177,7 @@ public void testFormSubmitWithValidInput() { **Grid data loading:** ```java + @Test public void testGridLoadsData() { GridElement grid = GridElement.get(page); @@ -160,7 +192,8 @@ Display the full generated test class in a code block. Then ask: > Shall I write this to `src/test/java//IT.java`? -Only write the file after explicit confirmation. Place it in `src/test/java` mirroring the view's package under `src/main/java`. +Only write the file after explicit confirmation. Place it in `src/test/java` +mirroring the view's package under `src/main/java`. ## Step 5 — Offer to run the test @@ -168,4 +201,5 @@ After writing, ask: > Do you want me to run this test now with `mvn verify -Dit.test=IT`? -**Warn the user**: the first Vaadin frontend build takes 3–5 minutes. Subsequent runs are ~25 seconds. +**Warn the user**: the first Vaadin frontend build takes 3–5 minutes. Subsequent +runs are ~25 seconds. From eab749cbbbef82f5dc83f201621a4b5f4ce7c772 Mon Sep 17 00:00:00 2001 From: jcgueriaud1 Date: Tue, 5 May 2026 15:19:58 +0300 Subject: [PATCH 3/6] Update Skill --- .github/workflows/tessl-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tessl-publish.yml b/.github/workflows/tessl-publish.yml index 5221375..9a6be93 100644 --- a/.github/workflows/tessl-publish.yml +++ b/.github/workflows/tessl-publish.yml @@ -11,4 +11,4 @@ jobs: - uses: tesslio/setup-tessl@v2 with: token: ${{ secrets.TESSL_TOKEN }} - - run: tessl tile publish \ No newline at end of file + - run: tessl tile publish ./skills/vaadin-playwright-test \ No newline at end of file From 23981e73e5faaff965476f08b5d1fca7bf619da3 Mon Sep 17 00:00:00 2001 From: jcgueriaud1 Date: Thu, 7 May 2026 13:03:21 +0300 Subject: [PATCH 4/6] Update Skill --- .../templates/SpringPlaywrightIT.java.tmpl | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 skills/vaadin-playwright-test/templates/SpringPlaywrightIT.java.tmpl diff --git a/skills/vaadin-playwright-test/templates/SpringPlaywrightIT.java.tmpl b/skills/vaadin-playwright-test/templates/SpringPlaywrightIT.java.tmpl new file mode 100644 index 0000000..ecb40b7 --- /dev/null +++ b/skills/vaadin-playwright-test/templates/SpringPlaywrightIT.java.tmpl @@ -0,0 +1,15 @@ +package {{PACKAGE}}; + +import org.springframework.boot.test.web.server.LocalServerPort; +import org.vaadin.addons.dramafinder.AbstractBasePlaywrightIT; + +public abstract class SpringPlaywrightIT extends AbstractBasePlaywrightIT { + + @LocalServerPort + private int port; + + @Override + public String getUrl() { + return String.format("http://localhost:%d/", port); + } +} From 78b2787fea9ca75d9945dcd207bb1142c49b2d3d Mon Sep 17 00:00:00 2001 From: jcgueriaud1 Date: Fri, 8 May 2026 14:04:32 +0300 Subject: [PATCH 5/6] Update Skill --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2ff5f03..056426a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ package.json .vscode/.copilot-plugin .vscode/launch.json + +dist +.env From 3c933d3525e3985fbf039baa935150a8b8ea1f07 Mon Sep 17 00:00:00 2001 From: jcgueriaud1 Date: Fri, 8 May 2026 14:05:21 +0300 Subject: [PATCH 6/6] Update Skill --- .../vaadin-playwright-test/evals/.env.example | 16 + skills/vaadin-playwright-test/evals/README.md | 45 ++ .../evals/docker-compose.yaml | 29 + .../evals/src/harness.ts | 97 +++ .../evals/src/runSmoke.ts | 59 ++ .../evals/tsconfig.json | 15 + tessl.json | 5 + vite.config.ts | 9 + vite.generated.ts | 668 ++++++++++++++++++ 9 files changed, 943 insertions(+) create mode 100644 skills/vaadin-playwright-test/evals/.env.example create mode 100644 skills/vaadin-playwright-test/evals/README.md create mode 100644 skills/vaadin-playwright-test/evals/docker-compose.yaml create mode 100644 skills/vaadin-playwright-test/evals/src/harness.ts create mode 100644 skills/vaadin-playwright-test/evals/src/runSmoke.ts create mode 100644 skills/vaadin-playwright-test/evals/tsconfig.json create mode 100644 tessl.json create mode 100644 vite.config.ts create mode 100644 vite.generated.ts diff --git a/skills/vaadin-playwright-test/evals/.env.example b/skills/vaadin-playwright-test/evals/.env.example new file mode 100644 index 0000000..6fb75b1 --- /dev/null +++ b/skills/vaadin-playwright-test/evals/.env.example @@ -0,0 +1,16 @@ +# Provider selection: "local" (LM Studio) or "gemini" +EVAL_PROVIDER=local + +# Local model (LM Studio, OpenAI-compatible) +LOCAL_BASE_URL=http://127.0.0.1:1234/v1 +LOCAL_MODEL=openai/gpt-oss-20b + +# Gemini (uses Google's OpenAI-compatible endpoint) +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.0-flash +GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/ + +# Langfuse (cloud or self-hosted) +LANGFUSE_PUBLIC_KEY= +LANGFUSE_SECRET_KEY= +LANGFUSE_HOST=https://cloud.langfuse.com diff --git a/skills/vaadin-playwright-test/evals/README.md b/skills/vaadin-playwright-test/evals/README.md new file mode 100644 index 0000000..cda75af --- /dev/null +++ b/skills/vaadin-playwright-test/evals/README.md @@ -0,0 +1,45 @@ +# vaadin-playwright-test — eval harness + +Phase 0 bootstrap. See `../../../PLAN.md` for the full plan. + +The harness uses the OpenAI SDK against any OpenAI-compatible endpoint. +Two providers are supported out of the box: + +- `local` — LM Studio (default), e.g. `openai/gpt-oss-20b` at `http://127.0.0.1:1234/v1` +- `gemini` — Google's OpenAI-compatible endpoint + +## Setup + +```bash +cd skills/vaadin-playwright-test/evals +npm install +cp .env.example .env +# fill in keys / pick a provider +``` + +For self-hosted Langfuse: + +```bash +docker compose up -d +# then set LANGFUSE_HOST=http://localhost:3000 in .env +``` + +## Run + +```bash +# default: local LM Studio +npm run smoke + +# Gemini +EVAL_PROVIDER=gemini npm run smoke +``` + +Runs one hardcoded prompt twice (with/without the skill in the system prompt) +and logs both as Langfuse traces tagged `phase=bootstrap`. + +## Note on prompt caching + +The PLAN.md calls for prompt caching on the system prompt. Neither LM Studio +nor Gemini's OpenAI-compatible endpoint supports the Anthropic-style +`cache_control` parameter, so caching is a no-op here. Re-introduce it when +the harness is pointed at a provider that supports it. diff --git a/skills/vaadin-playwright-test/evals/docker-compose.yaml b/skills/vaadin-playwright-test/evals/docker-compose.yaml new file mode 100644 index 0000000..d90f003 --- /dev/null +++ b/skills/vaadin-playwright-test/evals/docker-compose.yaml @@ -0,0 +1,29 @@ +services: + langfuse-db: + image: postgres:16 + restart: always + environment: + POSTGRES_USER: langfuse + POSTGRES_PASSWORD: langfuse + POSTGRES_DB: langfuse + volumes: + - langfuse_db_data:/var/lib/postgresql/data + ports: + - "5432:5432" + + langfuse: + image: langfuse/langfuse:2 + restart: always + depends_on: + - langfuse-db + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://langfuse:langfuse@langfuse-db:5432/langfuse + NEXTAUTH_SECRET: change-me + SALT: change-me + NEXTAUTH_URL: http://localhost:3000 + TELEMETRY_ENABLED: "false" + +volumes: + langfuse_db_data: diff --git a/skills/vaadin-playwright-test/evals/src/harness.ts b/skills/vaadin-playwright-test/evals/src/harness.ts new file mode 100644 index 0000000..cbad346 --- /dev/null +++ b/skills/vaadin-playwright-test/evals/src/harness.ts @@ -0,0 +1,97 @@ +import OpenAI from "openai"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SKILL_PATH = resolve(__dirname, "../../SKILL.md"); + +const BASE_SYSTEM = + "You are a senior engineer helping to write Playwright tests for a Vaadin application called Drama Finder. Reply with the requested code only, no commentary."; + +type Provider = "local" | "gemini"; + +interface ProviderConfig { + client: OpenAI; + model: string; + provider: Provider; +} + +function getProvider(): ProviderConfig { + const provider = (process.env.EVAL_PROVIDER ?? "local") as Provider; + + if (provider === "gemini") { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) throw new Error("GEMINI_API_KEY is not set"); + return { + provider, + model: process.env.GEMINI_MODEL ?? "gemini-2.0-flash", + client: new OpenAI({ + apiKey, + baseURL: + process.env.GEMINI_BASE_URL ?? + "https://generativelanguage.googleapis.com/v1beta/openai/", + }), + }; + } + + if (provider === "local") { + return { + provider, + model: process.env.LOCAL_MODEL ?? "openai/gpt-oss-20b", + client: new OpenAI({ + apiKey: "lm-studio", + baseURL: process.env.LOCAL_BASE_URL ?? "http://127.0.0.1:1234/v1", + }), + }; + } + + throw new Error(`Unknown EVAL_PROVIDER: ${provider}`); +} + +function buildSystem(withSkill: boolean): string { + if (!withSkill) return BASE_SYSTEM; + const skill = readFileSync(SKILL_PATH, "utf8"); + return `${BASE_SYSTEM}\n\nFollow the guidance in the skill below.\n\n\n${skill}\n`; +} + +export interface RunResult { + text: string; + model: string; + provider: Provider; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export async function runWithSkill( + prompt: string, + withSkill: boolean, +): Promise { + const { client, model, provider } = getProvider(); + + const completion = await client.chat.completions.create({ + model, + max_tokens: 4096, + messages: [ + { role: "system", content: buildSystem(withSkill) }, + { role: "user", content: prompt }, + ], + }); + + const text = completion.choices[0]?.message?.content ?? ""; + const usage = completion.usage; + + return { + text, + model, + provider, + usage: { + prompt_tokens: usage?.prompt_tokens ?? 0, + completion_tokens: usage?.completion_tokens ?? 0, + total_tokens: usage?.total_tokens ?? 0, + }, + }; +} diff --git a/skills/vaadin-playwright-test/evals/src/runSmoke.ts b/skills/vaadin-playwright-test/evals/src/runSmoke.ts new file mode 100644 index 0000000..cfc4922 --- /dev/null +++ b/skills/vaadin-playwright-test/evals/src/runSmoke.ts @@ -0,0 +1,59 @@ +import { Langfuse } from "langfuse"; +import { runWithSkill } from "./harness.js"; + +const SMOKE_PROMPT = + "Write a Playwright Java test for the Drama Finder app that fills the TextField labeled 'Title' with 'Hamlet' and clicks the Button labeled 'Search'. Show only the test method body."; + +async function main() { + const langfuse = new Langfuse(); + + for (const withSkill of [false, true]) { + const label = withSkill ? "with-skill" : "without-skill"; + console.log(`\n=== ${label} ===`); + + const start = new Date(); + const result = await runWithSkill(SMOKE_PROMPT, withSkill); + const end = new Date(); + + const trace = langfuse.trace({ + name: `smoke-${label}`, + tags: ["phase=bootstrap", label], + input: SMOKE_PROMPT, + metadata: { + withSkill, + provider: result.provider, + model: result.model, + }, + }); + + trace.generation({ + name: "chat.completions.create", + model: result.model, + modelParameters: { provider: result.provider }, + startTime: start, + endTime: end, + input: [ + { role: "system", content: "" }, + { role: "user", content: SMOKE_PROMPT }, + ], + output: result.text, + usage: { + input: result.usage.prompt_tokens, + output: result.usage.completion_tokens, + total: result.usage.total_tokens, + }, + metadata: { withSkill, provider: result.provider, model: result.model }, + }); + + trace.update({ output: result.text }); + + console.log(result.text); + } + + await langfuse.shutdownAsync(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/skills/vaadin-playwright-test/evals/tsconfig.json b/skills/vaadin-playwright-test/evals/tsconfig.json new file mode 100644 index 0000000..d1387cd --- /dev/null +++ b/skills/vaadin-playwright-test/evals/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/tessl.json b/tessl.json new file mode 100644 index 0000000..6e64474 --- /dev/null +++ b/tessl.json @@ -0,0 +1,5 @@ +{ + "name": "dramafinder", + "mode": "vendored", + "dependencies": {} +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4d6a022 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,9 @@ +import { UserConfigFn } from 'vite'; +import { overrideVaadinConfig } from './vite.generated'; + +const customConfig: UserConfigFn = (env) => ({ + // Here you can add custom Vite parameters + // https://vitejs.dev/config/ +}); + +export default overrideVaadinConfig(customConfig); diff --git a/vite.generated.ts b/vite.generated.ts new file mode 100644 index 0000000..5bf4fdd --- /dev/null +++ b/vite.generated.ts @@ -0,0 +1,668 @@ +/** + * NOTICE: this is an auto-generated file + * + * This file has been generated by the `flow:prepare-frontend` maven goal. + * This file will be overwritten on every run. Any custom changes should be made to vite.config.ts + */ +import path from 'path'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, Stats } from 'fs'; +import { createHash } from 'crypto'; +import * as net from 'net'; + +import { processThemeResources } from './target/plugins/application-theme-plugin/theme-handle.js'; +import { rewriteCssUrls } from './target/plugins/theme-loader/theme-loader-utils.js'; +import { addFunctionComponentSourceLocationBabel } from './target/plugins/react-function-location-plugin/react-function-location-plugin.js'; +import settings from './target/vaadin-dev-server-settings.json'; +import { + AssetInfo, + ChunkInfo, + defineConfig, + mergeConfig, + OutputOptions, + PluginOption, + UserConfigFn +} from 'vite'; + +import brotli from 'rollup-plugin-brotli'; +import checker from 'vite-plugin-checker'; +import postcssLit from './target/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js'; +import vaadinI18n from './target/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js'; + +export { default as useLocalWebComponents } from './target/plugins/vite-plugin-local-web-components'; + +import { visualizer } from 'rollup-plugin-visualizer'; +import reactPlugin from '@vitejs/plugin-react'; + + + + +const frontendFolder = path.resolve(__dirname, settings.frontendFolder); +const themeFolder = path.resolve(frontendFolder, settings.themeFolder); +const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput); +const devBundleFolder = path.resolve(__dirname, settings.devBundleOutput); +const devBundle = !!process.env.devBundle; +const jarResourcesFolder = path.resolve(__dirname, settings.jarResourcesFolder); +const themeResourceFolder = path.resolve(__dirname, settings.themeResourceFolder); +const projectPackageJsonFile = path.resolve(__dirname, 'package.json'); + +const buildOutputFolder = devBundle ? devBundleFolder : frontendBundleFolder; +const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput); +const statsFile = path.resolve(statsFolder, 'stats.json'); +const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html'); +const i18nFolder = path.resolve(__dirname, settings.i18nOutput); +const nodeModulesFolder = path.resolve(__dirname, 'node_modules'); +const webComponentTags = ''; + +const projectIndexHtml = path.resolve(frontendFolder, 'index.html'); + +const projectStaticAssetsFolders = [ + path.resolve(__dirname, 'src', 'main', 'resources', 'META-INF', 'resources'), + path.resolve(__dirname, 'src', 'main', 'resources', 'static'), + frontendFolder +]; + +// Folders in the project which can contain application themes +const themeProjectFolders = projectStaticAssetsFolders.map((folder) => path.resolve(folder, settings.themeFolder)); + +const themeOptions = { + devMode: false, + useDevBundle: devBundle, + // The following matches folder 'frontend/generated/themes/' + // (not 'frontend/themes') for theme in JAR that is copied there + themeResourceFolder: path.resolve(themeResourceFolder, settings.themeFolder), + themeProjectFolders: themeProjectFolders, + projectStaticAssetsOutputFolder: devBundle + ? path.resolve(devBundleFolder, '../assets') + : path.resolve(__dirname, settings.staticOutput), + frontendGeneratedFolder: path.resolve(frontendFolder, settings.generatedFolder), + projectStaticOutput: path.resolve(__dirname, settings.staticOutput), + javaResourceFolder: settings.javaResourceFolder ? path.resolve(__dirname, settings.javaResourceFolder) : '' +}; + +const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html')); +const commercialBannerComponent = path.resolve(frontendFolder, settings.generatedFolder, 'commercial-banner.js'); +const hasCommercialBanner = existsSync(commercialBannerComponent); + +const target = ['es2023']; + +// Block debug and trace logs. +console.trace = () => {}; +console.debug = () => {}; + +function statsExtracterPlugin(): PluginOption { + function collectThemeJsonsInFrontend(themeJsonContents: Record, themeName: string) { + const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json'); + if (existsSync(themeJson)) { + const themeJsonContent = readFileSync(themeJson, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + themeJsonContents[themeName] = themeJsonContent; + const themeJsonObject = JSON.parse(themeJsonContent); + if (themeJsonObject.parent) { + collectThemeJsonsInFrontend(themeJsonContents, themeJsonObject.parent); + } + } + } + + return { + name: 'vaadin:stats', + enforce: 'post', + async writeBundle(options: OutputOptions, bundle: { [fileName: string]: AssetInfo | ChunkInfo }) { + const modules = Object.values(bundle).flatMap((b) => (b.modules ? Object.keys(b.modules) : [])); + const nodeModulesFolders = modules + .map((id) => id.replace(/\\/g, '/')) + .filter((id) => id.startsWith(nodeModulesFolder.replace(/\\/g, '/'))) + .map((id) => id.substring(nodeModulesFolder.length + 1)); + const npmModules = nodeModulesFolders + .map((id) => id.replace(/\\/g, '/')) + .map((id) => { + const parts = id.split('/'); + if (id.startsWith('@')) { + return parts[0] + '/' + parts[1]; + } else { + return parts[0]; + } + }) + .sort() + .filter((value, index, self) => self.indexOf(value) === index); + const npmModuleAndVersion = Object.fromEntries(npmModules.map((module) => [module, getVersion(module)])); + const cvdls = Object.fromEntries( + npmModules + .filter((module) => getCvdlName(module) != null) + .map((module) => [module, { name: getCvdlName(module), version: getVersion(module) }]) + ); + + mkdirSync(path.dirname(statsFile), { recursive: true }); + const projectPackageJson = JSON.parse(readFileSync(projectPackageJsonFile, { encoding: 'utf-8' })); + + const entryScripts = Object.values(bundle) + .filter((bundle) => bundle.isEntry) + .map((bundle) => bundle.fileName); + + const generatedIndexHtml = path.resolve(buildOutputFolder, 'index.html'); + const customIndexData: string = readFileSync(projectIndexHtml, { encoding: 'utf-8' }); + const generatedIndexData: string = readFileSync(generatedIndexHtml, { + encoding: 'utf-8' + }); + + const customIndexRows = new Set(customIndexData.split(/[\r\n]/).filter((row) => row.trim() !== '')); + const generatedIndexRows = generatedIndexData.split(/[\r\n]/).filter((row) => row.trim() !== ''); + + const rowsGenerated: string[] = []; + generatedIndexRows.forEach((row) => { + if (!customIndexRows.has(row)) { + rowsGenerated.push(row); + } + }); + + //After dev-bundle build add used Flow frontend imports JsModule/JavaScript/CssImport + + const parseImports = (filename: string, result: Set): void => { + const content: string = readFileSync(filename, { encoding: 'utf-8' }); + const lines = content.split('\n'); + const staticImports = lines + .filter((line) => line.startsWith('import ')) + .map((line) => line.substring(line.indexOf("'") + 1, line.lastIndexOf("'"))) + .map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)); + const dynamicImports = lines + .filter((line) => line.includes('import(')) + .map((line) => line.replace(/.*import\(/, '')) + .map((line) => line.split(/'/)[1]) + .map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)); + + staticImports.forEach((staticImport) => result.add(staticImport)); + + dynamicImports.map((dynamicImport) => { + const importedFile = path.resolve(path.dirname(filename), dynamicImport); + parseImports(importedFile, result); + }); + }; + + const generatedImportsSet = new Set(); + parseImports( + path.resolve(themeOptions.frontendGeneratedFolder, 'flow', 'generated-flow-imports.js'), + generatedImportsSet + ); + parseImports( + path.resolve(themeOptions.frontendGeneratedFolder, 'app-shell-imports.js'), + generatedImportsSet + ); + const generatedImports = Array.from(generatedImportsSet).sort(); + + const frontendFiles: Record = {}; + frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex'); + + const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map']; + + const isThemeComponentsResource = (id: string) => + id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + && id.match(/.*\/jar-resources\/themes\/[^\/]+\/components\//); + + const isGeneratedWebComponentResource = (id: string) => + id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + && id.match(/.*\/flow\/web-components\//); + + const isFrontendResourceCollected = (id: string) => + !id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) + || isThemeComponentsResource(id) + || isGeneratedWebComponentResource(id); + + // collects project's frontend resources in frontend folder, excluding + // 'generated' sub-folder, except for legacy shadow DOM stylesheets + // packaged in `theme/components/` folder + // and generated web component resources in `flow/web-components` folder. + modules + .map((id) => id.replace(/\\/g, '/')) + .filter((id) => id.startsWith(frontendFolder.replace(/\\/g, '/'))) + .filter(isFrontendResourceCollected) + .map((id) => id.substring(frontendFolder.length + 1)) + .map((line: string) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line)) + .forEach((line: string) => { + // \r\n from windows made files may be used so change to \n + const filePath = path.resolve(frontendFolder, line); + if (projectFileExtensions.includes(path.extname(filePath))) { + const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + }); + + // collects frontend resources from the JARs + generatedImports + .filter((line: string) => line.includes('generated/jar-resources')) + .forEach((line: string) => { + let filename = line.substring(line.indexOf('generated')); + // \r\n from windows made files may be used ro remove to be only \n + const fileBuffer = readFileSync(path.resolve(frontendFolder, filename), { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + const hash = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + + const fileKey = line.substring(line.indexOf('jar-resources/') + 14); + frontendFiles[fileKey] = hash; + }); + // collects and hash rest of the Frontend resources excluding files in /generated/ and /themes/ + // and files already in frontendFiles. + let frontendFolderAlias = "Frontend"; + generatedImports + .filter((line: string) => line.startsWith(frontendFolderAlias + '/')) + .filter((line: string) => !line.startsWith(frontendFolderAlias + '/generated/')) + .filter((line: string) => !line.startsWith(frontendFolderAlias + '/themes/')) + .map((line) => line.substring(frontendFolderAlias.length + 1)) + .filter((line: string) => !frontendFiles[line]) + .forEach((line: string) => { + const filePath = path.resolve(frontendFolder, line); + if (projectFileExtensions.includes(path.extname(filePath)) && existsSync(filePath)) { + const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + }); + // If a index.ts exists hash it to be able to see if it changes. + if (existsSync(path.resolve(frontendFolder, 'index.ts'))) { + const fileBuffer = readFileSync(path.resolve(frontendFolder, 'index.ts'), { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + if (hasCommercialBanner) { + const fileBuffer = readFileSync(commercialBannerComponent, { encoding: 'utf-8' }).replace(/\r\n/g, '\n'); + frontendFiles[settings.generatedFolder + '/commercial-banner.js'] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); + } + + const themeJsonContents: Record = {}; + const themesFolder = path.resolve(jarResourcesFolder, 'themes'); + if (existsSync(themesFolder)) { + readdirSync(themesFolder).forEach((themeFolder) => { + const themeJson = path.resolve(themesFolder, themeFolder, 'theme.json'); + if (existsSync(themeJson)) { + themeJsonContents[path.basename(themeFolder)] = readFileSync(themeJson, { encoding: 'utf-8' }).replace( + /\r\n/g, + '\n' + ); + } + }); + } + + collectThemeJsonsInFrontend(themeJsonContents, settings.themeName); + + let webComponents: string[] = []; + if (webComponentTags) { + webComponents = webComponentTags.split(';'); + } + + const stats = { + packageJsonDependencies: projectPackageJson.dependencies, + npmModules: npmModuleAndVersion, + bundleImports: generatedImports, + frontendHashes: frontendFiles, + themeJsonContents: themeJsonContents, + entryScripts, + webComponents, + cvdlModules: cvdls, + packageJsonHash: projectPackageJson?.vaadin?.hash, + indexHtmlGenerated: rowsGenerated + }; + writeFileSync(statsFile, JSON.stringify(stats, null, 1)); + } + }; +} + +function themePlugin(opts: { devMode: boolean }): PluginOption { + const fullThemeOptions = { ...themeOptions, devMode: opts.devMode }; + return { + name: 'vaadin:theme', + config() { + processThemeResources(fullThemeOptions, console); + }, + configureServer(server) { + function handleThemeFileCreateDelete(themeFile: string, stats?: Stats) { + if (themeFile.startsWith(themeFolder)) { + const changed = path.relative(themeFolder, themeFile); + console.debug('Theme file ' + (!!stats ? 'created' : 'deleted'), changed); + processThemeResources(fullThemeOptions, console); + } + } + server.watcher.on('add', handleThemeFileCreateDelete); + server.watcher.on('unlink', handleThemeFileCreateDelete); + }, + hotUpdate({ file }) { + const contextPath = path.resolve(file); + const themePath = path.resolve(themeFolder); + if (contextPath.startsWith(themePath)) { + const changed = path.relative(themePath, contextPath); + + console.debug('Theme file changed', changed); + + if (changed.startsWith(settings.themeName)) { + processThemeResources(fullThemeOptions, console); + } + } + }, + async resolveId(id, importer) { + // force theme generation if generated theme sources does not yet exist + // this may happen for example during Java hot reload when updating + // @Theme annotation value + if ( + path.resolve(themeOptions.frontendGeneratedFolder, 'theme.js') === importer && + !existsSync(path.resolve(themeOptions.frontendGeneratedFolder, id)) + ) { + console.debug('Generate theme file ' + id + ' not existing. Processing theme resource'); + processThemeResources(fullThemeOptions, console); + return; + } + if (!id.startsWith(settings.themeFolder)) { + return; + } + for (const location of [themeResourceFolder, frontendFolder]) { + const result = await this.resolve(path.resolve(location, id)); + if (result) { + return result; + } + } + }, + async transform(raw, id, options) { + // rewrite urls for the application theme css files + const [bareId, query] = id.split('?'); + if ( + (!bareId?.startsWith(themeFolder) && !bareId?.startsWith(themeOptions.themeResourceFolder)) || + !bareId?.endsWith('.css') + ) { + return; + } + const resourceThemeFolder = bareId.startsWith(themeFolder) ? themeFolder : themeOptions.themeResourceFolder; + const [themeName] = bareId.substring(resourceThemeFolder.length + 1).split('/'); + return rewriteCssUrls(raw, path.dirname(bareId), path.resolve(resourceThemeFolder, themeName), console, opts); + } + }; +} + +function runWatchDog(watchDogPort: number, watchDogHost: string | undefined) { + const client = new net.Socket(); + client.setEncoding('utf8'); + client.on('error', function (err) { + console.log('Watchdog connection error. Terminating vite process...', err); + client.destroy(); + process.exit(0); + }); + client.on('close', function () { + client.destroy(); + runWatchDog(watchDogPort, watchDogHost); + }); + + client.connect(watchDogPort, watchDogHost || 'localhost'); +} + +const allowedFrontendFolders = [frontendFolder, nodeModulesFolder]; + +function showRecompileReason(): PluginOption { + return { + name: 'vaadin:why-you-compile', + hotUpdate({ file }) { + console.log('Recompiling because', file, 'changed'); + } + }; +} + +const DEV_MODE_START_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start/; +const DEV_MODE_CODE_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start([\s\S]*)vaadin-dev-mode:end\s+\*\*\//i; + +function preserveUsageStats() { + return { + name: 'vaadin:preserve-usage-stats', + + transform(src: string, id: string) { + if (id.includes('vaadin-usage-statistics')) { + if (src.includes('vaadin-dev-mode:start')) { + const expectedComment = '/*! vaadin-dev-mode:start'; + const newSrc = src.replace(DEV_MODE_START_REGEXP, expectedComment); + if (newSrc === src) { + if (!src.includes(expectedComment)) { + console.error('vaadin-dev-mode:start tag not found'); + } + } else if (!newSrc.match(DEV_MODE_CODE_REGEXP)) { + console.error('New comment fails to match original regexp'); + } else { + return { code: newSrc }; + } + } + } + + return { code: src }; + } + }; +} + +export const vaadinConfig: UserConfigFn = (env) => { + const devMode = env.mode === 'development'; + const productionMode = !devMode && !devBundle + const commercialBanner = productionMode && hasCommercialBanner; + + if (devMode && process.env.watchDogPort) { + // Open a connection with the Java dev-mode handler in order to finish + // vite when it exits or crashes. + runWatchDog(parseInt(process.env.watchDogPort), process.env.watchDogHost); + } + + return { + root: frontendFolder, + base: '', + publicDir: false, + resolve: { + alias: { + '@vaadin/flow-frontend': jarResourcesFolder, + Frontend: frontendFolder + }, + preserveSymlinks: true + }, + define: { + OFFLINE_PATH: settings.offlinePath, + VITE_ENABLED: 'true' + }, + server: { + host: '127.0.0.1', + strictPort: true, + fs: { + allow: allowedFrontendFolders + } + }, + esbuild: { + legalComments: 'inline', + }, + build: { + minify: productionMode, + outDir: buildOutputFolder, + emptyOutDir: devBundle, + assetsDir: 'VAADIN/build', + target, + rollupOptions: { + input: { + indexhtml: projectIndexHtml, + + ...(hasExportedWebComponents ? { webcomponenthtml: path.resolve(frontendFolder, 'web-component.html') } : {}) + }, + output: { + // Workaround to enable dynamic imports with top-level await for + // commonjs modules, such as "atmosphere.js" in Hilla. Extracting + // Rollup's commonjs helpers into separate manual chunk avoids + // circular dependencies in this case. Caused + // - https://github.com/vitejs/vite/issues/10995 + // - https://github.com/rollup/rollup/issues/5884 + // - https://github.com/vitejs/vite/issues/19695 + // - https://github.com/vitejs/vite/issues/12209 + manualChunks: (id: string) => id.startsWith('\0commonjsHelpers.js') ? 'commonjsHelpers' : null + }, + onwarn: (warning: any, defaultHandler: (warning: any) => void) => { + const ignoreEvalWarning = [ + 'generated/jar-resources/FlowClient.js', + 'generated/jar-resources/vaadin-spreadsheet/spreadsheet-export.js', + '@vaadin/charts/src/helpers.js' + ]; + if (warning.code === 'EVAL' && warning.id && !!ignoreEvalWarning.find((id) => warning.id?.endsWith(id))) { + return; + } + defaultHandler(warning); + } + } + }, + optimizeDeps: { + esbuildOptions: { + target, + }, + entries: [ + // Pre-scan entrypoints in Vite to avoid reloading on first open + 'generated/vaadin.ts' + ], + exclude: [ + '@vaadin/router', + '@vaadin/vaadin-license-checker', + '@vaadin/vaadin-usage-statistics', + 'workbox-core', + 'workbox-precaching', + 'workbox-routing', + 'workbox-strategies' + ] + }, + plugins: [ + productionMode && brotli(), + devMode && showRecompileReason(), + + !devMode && statsExtracterPlugin(), + !productionMode && preserveUsageStats(), + themePlugin({ devMode }), + postcssLit({ + include: ['**/*.css', /.*\/.*\.css\?.*/], + exclude: [ + `${themeFolder}/**/*.css`, + new RegExp(`${themeFolder}/.*/.*\\.css\\?.*`), + `${themeResourceFolder}/**/*.css`, + new RegExp(`${themeResourceFolder}/.*/.*\\.css\\?.*`), + new RegExp('.*/.*\\?html-proxy.*') + ] + }), + // The React plugin provides fast refresh and debug source info + reactPlugin({ + include: '**/*.tsx', + babel: { + // We need to use babel to provide the source information for it to be correct + // (otherwise Babel will slightly rewrite the source file and esbuild generate source info for the modified file) + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: productionMode ? 'react' : 'Frontend/generated/jsx-dev-transform', + development: !productionMode + } + ] + ], + // React writes the source location for where components are used, this writes for where they are defined + plugins: [ + !productionMode && addFunctionComponentSourceLocationBabel(), + [ + 'module:@preact/signals-react-transform', + { + mode: 'all' // Needed to include translations which do not use something.value + } + ] + ].filter(Boolean) + } + }), + + productionMode && vaadinI18n({ + cwd: __dirname, + meta: { + output: { + dir: i18nFolder, + }, + }, + }), + { + name: 'vaadin:force-remove-html-middleware', + configureServer(server) { + return () => { + server.middlewares.stack = server.middlewares.stack.filter((mw) => { + const handleName = `${mw.handle}`; + return !handleName.includes('viteHtmlFallbackMiddleware'); + }); + }; + }, + }, + hasExportedWebComponents && { + name: 'vaadin:inject-entrypoints-to-web-component-html', + transformIndexHtml: { + order: 'pre', + handler(_html, { path, server }) { + if (path !== '/web-component.html') { + return; + } + const scripts = [ + { + tag: 'script', + attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` }, + injectTo: 'head' + } + ]; + if (commercialBanner) { + scripts.push({ + tag: 'script', + attrs: { type: 'module', src: '/generated/commercial-banner.js' }, + injectTo: 'head' + }); + } + return scripts; + } + } + }, + { + name: 'vaadin:inject-entrypoints-to-index-html', + transformIndexHtml: { + order: 'pre', + handler(_html, { path, server }) { + if (path !== '/index.html') { + return; + } + + const scripts = []; + + if (devMode) { + scripts.push({ + tag: 'script', + attrs: { type: 'module', src: `/generated/vite-devmode.ts`, onerror: "document.location.reload()" }, + injectTo: 'head' + }); + } + scripts.push({ + tag: 'script', + attrs: { type: 'module', src: '/generated/vaadin.ts' }, + injectTo: 'head' + }); + if (commercialBanner) { + scripts.push({ + tag: 'script', + attrs: { type: 'module', src: '/generated/commercial-banner.js' }, + injectTo: 'head' + }); + } + return scripts; + } + } + }, + + checker({ + typescript: true + }), + productionMode && visualizer({ brotliSize: true, filename: bundleSizeFile }) + ] + }; +}; + +export const overrideVaadinConfig = (customConfig: UserConfigFn) => { + return defineConfig((env) => mergeConfig(vaadinConfig(env), customConfig(env))); +}; +function getVersion(module: string): string { + const packageJson = path.resolve(nodeModulesFolder, module, 'package.json'); + return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).version; +} +function getCvdlName(module: string): string { + const packageJson = path.resolve(nodeModulesFolder, module, 'package.json'); + return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).cvdlName; +}