Skip to content
cccskills
FreePublished on GitHub

Universal Component Testing (WebView)

How to write and run

The English summary is being prepared. Showing the original description.

chemicallang/chemical31 stars(GitHub 31 + cccskills 0)
Works withPlaywright

Run score Not tested

See how to install

Files included (1)

  • SKILL.md16.3 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

Universal Component Testing (WebView)

#universal_test runs a universal component in a real WebView, renders its fixture with the exact production SSR + hydration pipeline (#html / universal_cbi), and runs arbitrary JavaScript against the hydrated DOM.

It is the native alternative to the Playwright components-e2e suite. Use it when you want to test components in Chemical itself, on the same engine your WebView app ships with. Use components_e2e for headless CI / cross-browser coverage.

  • Syntax: a top-level macro, so JSX is legal (it lives inside a #-macro).
  • Fixture: inline HTML/JSX — rendered once with production SSR, wrapped in <div data-ut="<name>">.
  • Steps: one <script> element containing raw JS (full JS power).
  • Default: one process, one page, one WebView, many tests, sequential.
  • Isolation: isolate gives a test its own page + WebView.

Requires GTK3 + WebKit2GTK and a display. It is an opt-in suite (--universal) and is part of ./scripts/test.sh --all. For headless CI wrap the run in xvfb-run.


Quick start

#universal Counter(props) {
    state count = props.start || 0
    return <div>
        <button data-testid="inc" onClick={() => count += 1}>Increment</button>
        <span data-testid="count">Count: {count}</span>
    </div>
}

#universal_test("counter increments") {
    <Counter start={0} />
    <script>
        expect($('[data-testid=count]').text()).toBe('Count: 0')  // SSR before hydration
        $('[data-testid=inc]').click()
        expect($('[data-testid=count]').text()).toBe('Count: 1')
    </script>
}

public func main(argc : int, argv : **char) : int {
    return universal_test_runner(argc, argv)
}

Module (chemical.mod) must import the macro plugin, the runner and the WebView stack:

application my_component_tests
source "src"
import cstd
import std
import page
import html_cbi
import css_cbi
import js_cbi
import universal_cbi
import universal_test
import webview
import window

Run it:

./scripts/test.sh --tcc --universal                 # all tests
./scripts/test.sh --tcc --universal --test-names "counter increments"
# show the WebView window (default is hidden/off-screen):
./scripts/test.sh --tcc --universal --ut-headed
# or build the module directly:
cmake-build-debug/TCCCompiler path/to/chemical.mod -o /tmp/tests \
    --mode debug_quick --no-cache -frecompile-plugins
/tmp/tests [--test-names a,b] [--ut-headed]

Output is PASS <name> / FAIL <name>: <message> plus a summary; exit code is non-zero if any test failed. The WebView is hidden by default (no window flashes); pass --ut-headed to watch a run.

--test-names splits on commas, so a test whose name contains a comma cannot be selected individually. Keep test names comma-free.


Syntax

#universal_test("name" [, isolate] [, group = "g"]) {
    <fixture JSX/HTML/>          // zero or more root elements
    <script> ...raw JS steps... </script>   // exactly one
}
  • "name" is the test name used in reports and --test-names.
  • isolate runs the test in its own page + WebView (portals, globals, timers, navigation, flaky interference). Also required for native form-control groups: radio inputs are grouped by name across the whole document, so two fixtures on the shared page that both emit <input type=radio name="size"> form one group and uncheck each other (a fixture's hydration clears a sibling fixture's checked). Mark such tests isolate, or give each fixture a unique name.
  • group = "g" is stored on the test metadata (reserved for grouping).
  • The fixture may be omitted (empty container) for pure JS assertions.
  • The steps must be the single <script> element; its content is captured verbatim as JavaScript. It is not HTML-escaped and not Chemical — write plain JS.

Steps are async

Each <script> body is wrapped in async function(){ ... }, so you can await t.sleep(ms) (and return Promises). The context object is the global t (not a parameter) — a test may freely declare its own const t = ... without colliding, but then it shadows t for the rest of that test.

How the fixture is rendered

The macro desugars to ut_render_<name>(page : &mut HtmlPage), which:

  1. appends <div data-ut="<name>">,
  2. runs the fixture through ASTConverter.convertHtmlRoot — the same converter #html uses — so SSR markup, the <span data-chx-i> hydration boundary, the client component functions and the window.$__uni_dispatch(...) calls are byte-for-byte what a production page emits,
  3. appends </div> and window.__ut_register("<name>", <isolate>, async function(){ <steps> });.

The runner builds one HtmlPage, calls every fixture function (SSR markup and the __ut_register calls land in the body bundle), then emits window.__ut_expected = [...] and the JS harness into the head script block, loads the page into one WebView, and the in-page harness runs the registered tests sequentially.


Injected JS API

Everything is scoped to the current test's [data-ut="<name>"] container.

Locators (lazy handles)

HelperDescription
$(css) / byCss(css)first match by CSS selector
byTestId(id)[data-testid="id"]
byRole(role, { name })[role="role"] (optionally matching accessible name)
byText(text)first leaf element whose text equals text
byLabel(label)[aria-label="label"]

Handle methods

.text(), .value(), .attr(name), .exists(), .count(), .isVisible(), .click(), .dblclick(), .type(v) / .fill(v), .press(key), .hover(), .focus(), .blur(), .check(), .uncheck(), .selectOption(v), .scrollIntoView().

Assertions

expect(value).toBe(x); expect(value).toEqual(x); expect(value).toContain(s);
expect(value).toBeTruthy();
expect(handle).toHaveText(s); expect(handle).toContainText(s);
expect(handle).toHaveAttribute(k, v); expect(handle).toHaveCount(n);
expect(handle).toHaveClass(c);
expect(handle).toBeVisible(); expect(handle).toBeHidden();

Utilities

  • t.sleep(ms) — returns a Promise (the harness awaits returned Promises).
  • t.log(msg) — console log.
  • t.skip(reason) — reported as a pass with the SKIP: message.
  • Anything else is plain JS: document, fetch, setTimeout, etc.

Uncaught errors and unhandled rejections are captured per test and fail it with the message. A test that never settles is failed by a 15 s timeout and the suite continues.


Execution model

  • Default (fast): every non-isolated test's fixture is SSR'd into one page; the whole page is hydrated once in one WebView; tests run sequentially against their own containers (no reset needed).
  • isolate: the test gets its own page + WebView, created and destroyed around it. The suite keeps running after it.
  • Filters: --test-names a,b and --test-ids 1,2 select tests before any page is built. Names must match exactly (spaces included).
  • Results are keyed by name; the report follows declaration order.

Architecture (for extending/debugging)

LayerLocation
#universal_test macro (lexer, parse, symres, replacement)lang/libs/html_cbi/src/universal_test/ut.ch
Macro registration + universal_test collector annotationlang/libs/html_cbi/build.lab
Raw <script> lexinglang/libs/html_parser/src/lexer/{HtmlLexer.ch,nextToken.ch}
Verbatim <script> emission in #htmllang/libs/html_cbi/src/converter/language/main.ch
Discovery intrinsic intrinsics::get_universal_tests<UTFunction>()ast/utils/GlobalFunctions.cpp (InterpretGetUniversalTests)
Runner (UTFunction, universal_test_runner, run_universal_tests)lang/libs/universal_test/src/runner.ch
In-page JS harnesslang/libs/universal_test/src/harness.ch
Suitelang/tests/universal_webview/{chemical.mod,src/main.ch,src/fixtures.ch,src/tests_*.ch}
CLI./scripts/test.sh --universal (--ut-headed to show the window)

Discovery flow. ut_parseMacroNode creates the fixture function at parse time and controller.collect(...)s the embedded node with args [name, isolate, group, steps, fixture_fn]. The C++ intrinsic turns that collection into a UTFunction[]. universal_test_runner is a comptime function, so ut_all() is evaluated at the user's call site — after the test module has been parsed/collected (the same pattern as test_runner). That is why the test declarations must be reachable from the module that calls the runner.


Gotchas

  • JSX is only legal inside #-macros. You cannot write <Counter/> in a plain function; #universal_test's body is the macro that enables it.
  • #html cannot embed #js. #universal_test exists as its own macro because the steps need a raw-JS section, which #html does not have.
  • <script> is a raw-text element. Its content is lexed verbatim up to </script> (case-insensitive) — do not nest </script> inside the steps.
  • Locators are scoped to the test's data-ut container, so data-testid collisions across tests are fine. Accessible-name resolution is scoped too: aria-labelledby idrefs resolve inside the locator's own root first, then the data-ut container, then the document. This matters because component libraries reuse a constant default id across instances (e.g. every <Tabs> without an id prop emits id="tabs-tab-0"); a document-wide lookup can return a different instance's element and produce the wrong accessible name (a getByRole(..., {name}) lookup then finds nothing, intermittently).
  • name-based lookups with duplicated ids: if two components on one page emit the same element id, getByRole(role, { name }) is only reliable when the idref lives inside the locator's own root. Prefer data-testid-rooted locators, or give each instance a unique id.
  • UTFunction fields are string_view (like TestFunction). std::string fields in the comptime array break the 2c/TCC translation, and the member order must match the struct declaration (id, name, group, isolate, fixture_fn, steps).
  • Display required. If webview_create fails, the runner prints a hint and exits non-zero. Use xvfb-run -a ./tests on headless machines.
  • A broken page JS bundle is reported, not hung. The harness is emitted into the page head <script> (via HtmlPage.append_head_js_view), separate from the body bundle holding the fixtures' __ut_register calls — a script that fails to parse is skipped whole, so a shared element would take the harness down with it. The harness therefore survives a broken bundle, catches its SyntaxError, and fails the affected tests by name (it iterates the runner-injected window.__ut_expected, not Object.keys(__ut_tests)). A host watchdog (window_set_timer, refilled by every bridge call) additionally stops the loop after 60 s of total bridge silence, so no page behaviour can hang a run — override with UT_TIMEOUT_MS (ms).
  • Debugging the generated page JS: set UT_DUMP_JS=1 to print every script element the page emits (head block, body bundle, body tail) between ===UT_JS_START===/===UT_JS_END=== markers, then exit before opening a WebView. node --check each dump to locate a syntax error.
  • Debugging the generated page HTML: set UT_DUMP_HTML=1 to print the full SSR page between ===UT_HTML_START===/===UT_HTML_END=== markers and exit. Use it to inspect hydration boundary ids, fixture containers, and duplicated ids/names across fixtures (the usual cause of cross-fixture interference).
  • Failure diagnostics. A failed assertion appends context to the message: toBeVisible prints the element (or the ancestor that hides it / element not found), toBeChecked prints tag/name/checked/defaultChecked, an unmatched getByRole(..., {name}) prints the accessible names it did see (candidates=[...]), and any console.error/console.warn emitted during the test is appended as | console: .... Console output is diagnostic only and never fails a test by itself (many tests deliberately exercise error paths).
  • --universal is included in --all (it needs a display; use xvfb-run headless).

When to use this vs. components_e2e

#universal_test (WebView)components_e2e (Playwright)
Enginethe app's real WebView (GTK/WebKit, WebView2)Chromium (Playwright)
LanguageChemical + raw JSTypeScript
CIneeds a display (xvfb-run)headless
Best fortesting components in the shipping engine, local dev, WebView integrationcross-browser, headless CI, broad component matrix

Both exercise the same SSR → hydration → interaction contract; keep the Playwright suite as the CI backend and use #universal_test for engine-native verification.

The ported suite (components-e2e → #universal_test)

lang/tests/universal_webview/ is a port of the Playwright components-e2e suite (142 tests). Layout:

  • src/main.ch — the runner main + a few small components.
  • src/fixtures.ch — all #universal *Fixture components copied verbatim from lang/compiled/components-e2e/app/src/main.ch.
  • src/tests_core.ch — counter/ssr/root-shapes/static-children.
  • src/tests_components.ch — the component suite.
  • src/tests_runtime.ch — batching/unmount/effect-deps/derived lists/keyed reconciliation/error boundaries/memo/SVG/ref/Suspense.
  • src/tests_edges.ch — edge + utility + controlled components.
  • src/tests_reactivity.ch — rapid interactions, hydration, ARIA.

Porting a Playwright test

  1. Reuse the existing fixture component (<XFixture />) if one exists; otherwise copy it from the e2e app's main.ch into fixtures.ch.
  2. Write a #universal_test("...") { <XFixture /> <script>...</script> }.
  3. Translate the Playwright API to the harness:
    • page.getByTestId(x) → byTestId(x); f.getByTestId(x) → byTestId('f').getByTestId(x); locator(css) → find(css) (first) or findAll(css) (collection); .nth(i)/.first()/.last()/.count().
    • getByRole(role, {name}) → byRole(role, { name }) (also matches native tags and aria-label/aria-labelledby/wrapping-label names).
    • expect(...) assertions map 1:1, including .not.*, toBeDisabled, toBeEnabled, toBeChecked, toBeFocused, toHaveValue, toHaveAttribute, toHaveCSS, toHaveJSProperty, toBeGreaterThanOrEqual, …
    • page.keyboard.press(k) → t.press(k) (Tab focus movement is emulated); locator.press(k) → handle .press(k).
    • locator.fill(v)/el.value=…; dispatch input → .setValue(v) / .fill(v).
    • await page.waitForTimeout(ms) → await t.sleep(ms).
    • page.evaluate(() => window.__x) → read window.__x directly.
  4. Portals (Dialog/Sheet/Select/Dropdown/Toast) append to document.body, outside the test's container — mark those tests isolate so their portal content cannot collide with another test's fixture on the shared page.
  5. Components that do not forward data-testid (Card, Badge, Avatar, Typography, Tooltip, Alert, Separator, Stack, Grid, Skeleton, Spinner, Text, Collapsible, Slider, Container, Fab) need structural selectors, text/role queries, or an attribute the component does render ([data-variant], [data-size], [data-muted], role, tag name).
  6. Run and iterate: cmake-build-debug/TCCCompiler lang/tests/universal_webview/chemical.mod -o /tmp/t \ --mode debug_quick --no-cache -frecompile-plugins && /tmp/t --test-names "your test"

Related

  • universal — the SSR/hydration pipeline the fixture uses.
  • testing — the general test infrastructure.
  • components_e2e — the Playwright browser suite.
  • lang/docs/universal-component-testing-design.md — full design + implementation plan.

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

Comprehensive guide to the Chemical compiler's annotation system (`@name`, `@name(args)`) — how annotations are lexed, parsed, registered in the `AnnotationController`, stored and queried centrally, consumed by the compiler and CBI plugins, and how to add a new one. Load when working on annotations, `AnnotationController`, compiler plugins that define/read annotations, or node attribute flags such as `@extern`, `@test`, `@make`, `@deprecated`, `@no_mangle`, `@inline`, and `@volatile`.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

The C++ AST node framework of the Chemical compiler — the ASTAny/ASTNode/Value/BaseType hierarchy, node/value/type kind enums and range predicates, the visitor dispatch framework, the arena allocator, TypeBuilder canonicalization, member containers, and cross-reference resolution. Load when adding or modifying AST nodes, writing compiler passes/codegen visitors, debugging kind mismatches or arena/lifetime issues, or extending the compiler API bindings.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

The Chemical compiler benchmark & release analytics system — how the static GitHub Pages dashboard works, the JSON data model (manifest/daily/release records), the bash collection scripts (bench-*.sh), the GitHub Actions workflows (benchmark-daily/release), and the critical pitfalls (hidden upload dirs, worktree submodules, gh-pages-only data). Load when working on the dashboard, benchmark collection, or analytics workflows.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

Comprehensive deep-dive into the Chemical build pipeline — how chemical.mod is converted to build.lab, how build scripts are JIT-compiled via TinyCC, how jobs are created and executed, and how ASTProcessor orchestrates the parallel compilation passes.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

All documentation related to building and running Project or Tests

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

Comprehensive guide to the C translation backend (2c) — how Chemical AST is translated to C code, key patterns, gotchas, and optimization strategies.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

See all skills by chemicallang

Report a problem with this skill