Skip to content

ci(tailwind): build the Tailwind plugin against real Tailwind, and assert the bad build fails (#17) - #28

Merged
fsecada01 merged 1 commit into
masterfrom
feat/component-framework-ui-phase-17-tailwind-plugin-real-build
Jul 29, 2026
Merged

ci(tailwind): build the Tailwind plugin against real Tailwind, and assert the bad build fails (#17)#28
fsecada01 merged 1 commit into
masterfrom
feat/component-framework-ui-phase-17-tailwind-plugin-real-build

Conversation

@fsecada01

Copy link
Copy Markdown
Owner

Closes #17.

What this does

Adds a CI job that runs the vendored plugin through the actual Tailwind CLI, because nothing did before. The suite in tests/js calls the plugin's exports directly — fast, and it is where the logic belongs — but every claim it makes about Tailwind is a proxy, and a proxy cannot notice the real contract changing underneath it.

The check has teeth — verified by mutation, not by assertion

The issue names the weak spot exactly:

assert.equal(cfUiAxes.__isOptionsFunction, true);

That asserts cf-ui still sets a flag. It does not assert Tailwind still reads it. So I reproduced the scenario rather than arguing about it — renamed the marker to __acceptsOptions in both the plugin and that assertion, then ran both suites:

suite result
tests/js (65 tests) 65 pass, 0 fail
tests/tailwind (new) 2 failError: The plugin "..." does not accept options

That is the #7 defect restored, with a fully green suite sitting on top of it. A CSS-first consumer who cannot pass a composition never gets theirs validated, so the plugin only ever checks the default composition — which always passes. The feature is inert while looking fine.

Both files were restored via git checkout and both suites are green again (65 + 8).

Worth noting the second failure in that run: the bad-path test also asserts why the build failed (unknown composition 'brutalist'), so it went red when the build started failing for a different reason. A failure for any old reason cannot satisfy it.

Scope checklist

  • Add a CI job that installs tailwindcss + @tailwindcss/cli and builds against the vendored plugin — new tailwind-build job in .github/workflows/ci.yml; fixtures reference the plugin by relative path, exactly as a consumer would.
  • Assert the good paths compile: bare @plugin, and @plugin { composition: console; }fixtures/bare.css and fixtures/composition.css.
  • Assert the generated CSS actually contains the axis rules, the --color-primary aliases, and the @media (color-gamut: p3) layer — driven off cf_ui_axes.json rather than a hand-written list, so it covers every axis value and both modes and cannot drift from the definition. A build that succeeds and emits nothing fails here.
  • Assert the bad path exits non-zerofixtures/bad-composition.css. The CLI is spawned with spawnSync and status is read straight off the process; nothing is piped, so nothing can mask it. status is kept raw rather than coerced, so a crash-by-signal (null) cannot satisfy an equal(status, 0) either.
  • Keep it off the per-Python-version matrix; this is one job, not three — separate job. Nothing here depends on the Python version, and cf_ui_axes.json is committed, so the job needs no Python at all.
  • Pin the Tailwind version, and treat a break on bump as signal rather than noise — exact 4.3.3 for both packages in tests/tailwind/package.json, plus a committed lockfile so CI runs npm ci.

Notes

source(none) on the @import keeps Tailwind from crawling the repo for utility classes. This check is about the plugin's base layer, and auto-detection would make the build both slower and sensitive to unrelated files.

The harness throws rather than skipping when the toolchain is absent. That mirrors the reasoning already in the workflow for tests/js — a skip reads as a pass, and this file exists precisely because a green suite hid an inert feature once already. just test-tailwind installs and runs it locally.

Follow-ups, deliberately not in this diff

  • just test-js runs zero tests and exits 0 on Windows. Under cmd.exe the quoted glob "tests/js/**/*.test.mjs" reaches node literally, node matches nothing, and node exits 0 — a silent pass, which is the same class of problem this ticket is about. CI is unaffected (bash strips the quotes, and the workflow runs the command directly), so this is local-only. Off CI: build the Tailwind plugin against real Tailwind, and assert the bad build fails #17's checklist, so it wants its own ticket.
  • The JS-config path is not covered here. The issue's Notes observe that a per-axis map or custom valueSets cannot be expressed through @plugin's flat key/value options and needs a tailwind.config.js. The literal checklist asks for the two CSS-first paths, so that is what this delivers.

🤖 Generated with Claude Code

Nothing in CI ran the plugin through an actual Tailwind build. The suite in
tests/js calls the plugin's exports directly, so every claim it makes about
Tailwind is a proxy, and a proxy cannot notice the real contract changing.

The sharpest case is the marker that lets the CSS-first path pass options:

    assert.equal(cfUiAxes.__isOptionsFunction, true);

That asserts cf-ui still sets a flag. It does not assert Tailwind still reads
it. Verified by mutation — renaming the marker in both the plugin and that
assertion leaves the 65-test suite fully green while the real build fails with
"does not accept options", which is the #7 defect restored: a consumer who
cannot pass a composition never gets theirs validated, so only the default
composition is checked, and that one always passes.

The new job covers all of it: both good paths compile (bare `@plugin`, and
`@plugin { composition: console; }`); the compiled CSS actually carries the
axis rules in both modes, the theme aliases, and the wide-gamut layer, since a
build that succeeds and emits nothing is the other silent failure; and an
unknown composition exits non-zero, read from the process itself because piping
through anything else reports the pipeline's status instead of Tailwind's. That
last check also asserts *why* the build failed, so a failure for an unrelated
reason cannot satisfy it.

Its own job rather than three matrix steps — nothing here depends on the Python
version. Tailwind is pinned via package.json plus a committed lockfile, so a
bump breaking this is the signal the job exists to produce. `just test-tailwind`
runs it locally, and it throws rather than skipping when the toolchain is
missing, because a skip reads as a pass.

Follow-up, not fixed here: `just test-js` runs zero tests and exits 0 on
Windows — under cmd.exe the quoted glob reaches node literally. CI is
unaffected, and it is off this issue's checklist.

Closes #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
@fsecada01 fsecada01 added the enhancement New feature or request label Jul 29, 2026
@fsecada01

Copy link
Copy Markdown
Owner Author

Adversarial review

Overview

Adds a tailwind-build CI job plus tests/tailwind/ — fixtures, a pinned toolchain, and a node --test harness that spawns the real Tailwind CLI and reads its exit code. The diff reads as +1414, but 1170 of that is the committed lockfile; the hand-written surface is ~244 lines across 8 files.

The design is right for what the issue asks. Assertions are driven off cf_ui_axes.json rather than a hand-written list, so they cover every axis value and both modes and cannot drift from the definition. status is kept raw rather than coerced, so a signal-kill (null) cannot satisfy equal(status, 0). The bad-path test asserts why the build failed, not merely that it did. Refusing to skip when the toolchain is absent is the correct call and consistent with the reasoning already in the workflow for tests/js.

Verified by execution, not by reading

The content assertions are not vacuous. This was the biggest risk — if stock Tailwind emitted any of these tokens on its own, the assertions would pass against a plugin that did nothing. Control build, @import "tailwindcss" source(none); with no @plugin, 4614 bytes out:

token hits without the plugin
--color-primary 0
--color-primary-content 0
--color-primary-strong 0
color-gamut: p3 0
data-accent 0
data-surface 0

Every claim is attributable to the plugin.

The error-message regex survives CI's output shape. assert.match(output, /unknown composition 'brutalist'/) would break if Tailwind's error box hard-wrapped the line or interleaved ANSI codes inside the message. It does neither — the escapes surround the box-drawing characters, and the message stays on one line at COLUMNS=40 and with FORCE_COLOR=0.

The job is not a silent no-op. Checked specifically because that is this ticket's own failure mode, and because a 7-second green job invites the assumption. The run log shows all 8 tests executing individually, not a zero-test pass.

The wheel does not ship any of this. [tool.hatch.build.targets.wheel] packages = ["src/cf_ui"], and tests/tailwind/node_modules is already covered by .gitignore.

Findings

1. [low] npm ci runs 33 packages' install scripts in CI, including a native compile, for a check that never needs them.
@parcel/watcher@2.5.1 builds from source on install. It exists for Tailwind's --watch mode; this harness spawns a one-shot build and never watches. A pinned lockfile buys reproducibility, and then lifecycle scripts hand it back as arbitrary code execution on every CI run.
Verified the fix works: npm ci --ignore-scripts installs all 33 packages and all 8 tests still pass, locally and with a clean node_modules. Worth applying to both the workflow step and the just test-tailwind recipe. This is the one change I would actually make.

2. [low] writes no stylesheet when it fails spends a fourth CLI spawn on a near-tautology.
The preceding test already asserts a non-zero exit and the specific error text. A build that failed and never opened the output file cannot have written one, so this mostly re-derives the previous assertion at the cost of ~200ms and another process. It would only earn its keep against a Tailwind that emits a partial stylesheet before erroring — plausible enough to keep, but note it does not distinguish "never written" from "written empty", so it is weaker than it looks. Judgment call, not a defect.

3. [low] Object.values(bin)[0] picks an arbitrary bin if the package ever ships more than one.
Today @tailwindcss/cli declares {"tailwindcss": "dist/index.mjs"} and the version is pinned, so the risk is near zero. But the failure mode is silent selection rather than a clear error. bin.tailwindcss ?? (throw) would fail loudly on the day that changes, which is the same instinct the rest of this file follows.

4. [low] Node is pinned to 22 in the new job and unpinned in test.
So one CI run can now execute tests/js and tests/tailwind on two different Node versions. Not wrong, and arguably broader coverage, but it is an inconsistency someone will eventually have to reason about.

Nothing found on

Correctness of the fixture paths and source(none) usage; the exit-code plumbing; secrets or credentials (none introduced); performance (the job runs in 7s wall clock, well under the Python matrix); project conventions (the harness matches the existing tests/js style, and the CHANGELOG entry follows the established per-issue format).

The two follow-ups named in the PR description — the Windows just test-js silent pass, and the uncovered JS-config path — are both correctly scoped out rather than folded in.

@fsecada01
fsecada01 merged commit 4fa5c30 into master Jul 29, 2026
5 checks passed
@fsecada01
fsecada01 deleted the feat/component-framework-ui-phase-17-tailwind-plugin-real-build branch July 31, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: build the Tailwind plugin against real Tailwind, and assert the bad build fails

1 participant