Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,32 @@ jobs:

- name: E2E tests
run: pytest tests/e2e/ -q --tb=short --browser chromium

# Deliberately its own job, not a step on the matrix above: nothing here
# depends on the Python version, and running it three times would only buy
# three copies of the same answer.
#
# Everything the `test` job asserts about Tailwind is a proxy — it calls the
# plugin's exports directly and never invokes Tailwind. Both defects fixed in
# #7 were invisible to that suite and only appeared under a real build. This
# job is the one that would notice if Tailwind changed the contract.
tailwind-build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "22"

# `npm ci` installs the lockfile exactly. The Tailwind version is pinned
# in tests/tailwind/package.json — a bump breaking this job is the signal
# this job exists to produce, so bump it deliberately rather than
# floating the range.
- name: Install pinned Tailwind
working-directory: tests/tailwind
run: npm ci

- name: Build the vendored plugin against real Tailwind
run: node --test --test-reporter=spec "tests/tailwind/build.test.mjs"
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

## [Unreleased]

### Added — real Tailwind build in CI (#17)

- **A CI job that builds the vendored plugin through the actual Tailwind CLI.**
Nothing did before. The existing suite calls the plugin's exports directly, so
every claim it makes about *Tailwind* is a proxy — and both defects fixed in
#7 were invisible to it, surfacing only under a real build. The sharpest case
is `assert.equal(cfUiAxes.__isOptionsFunction, true)`: it asserts cf-ui still
sets a flag, not that Tailwind still reads it. Rename the marker in both the
plugin and that assertion and the suite stays green while the CSS-first path
silently stops accepting options, leaving only the default composition
validated — which always passes.

The new job compiles a bare `@plugin` and `@plugin { composition: console; }`,
asserts the compiled CSS actually carries the axis rules, the
`--color-primary` aliases and the `@media (color-gamut: p3)` layer (a build
that succeeds and emits nothing is the other silent failure), and asserts an
unknown composition exits non-zero — read from the process directly, since
piping masks the status. Tailwind is pinned; a break on bump is the signal.

Run it locally with `just test-tailwind`. It throws rather than skipping when
the toolchain is absent, because a skip reads as a pass.

### Fixed — accessibility (#21)

Three gaps that predate 0.1.0 and were flagged during the #6 review, fixed
Expand Down
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ test:
test-js:
node --test --test-reporter=spec "tests/js/**/*.test.mjs"

# The vendored plugin through a real Tailwind build: the good paths compile,
# the CSS actually lands, and an unknown composition exits non-zero. `test-js`
# covers the same plugin but never invokes Tailwind, so it cannot notice the
# contract changing underneath it.
test-tailwind:
npm ci --prefix tests/tailwind
node --test --test-reporter=spec tests/tailwind/build.test.mjs

# Rebuild cf_ui_axes.css and cf_ui_axes.json from axes.py.
axes:
python -m cf_ui.axes
Expand Down
154 changes: 154 additions & 0 deletions tests/tailwind/build.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* The vendored plugin, run through a real Tailwind build.
*
* `tests/js/plugin.test.mjs` calls the plugin's exports directly. That suite is
* fast and it is where the logic is covered, but every claim it makes about
* *Tailwind* is a proxy. The clearest example 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. Both defects fixed in #7 were invisible to a 35-test suite and only
* surfaced under a real build — one of them meaning a CSS-first consumer could
* not pass a composition at all, so the plugin validated only the default
* composition, which always passes. The feature was inert while looking fine.
*
* So this file spawns the actual CLI and reads the actual exit code. It never
* skips: a missing toolchain throws, because a skipped check reads as a pass
* and that is the failure mode this whole file exists to close.
*
* Requires `npm ci` in this directory. The Tailwind version is pinned in
* package.json — when a bump breaks this, that is the signal, not noise.
*/

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { after, describe, it } from "node:test";

const HERE = fileURLToPath(new URL(".", import.meta.url));
const FIXTURES = join(HERE, "fixtures");
const CLI_DIR = join(HERE, "node_modules", "@tailwindcss", "cli");

const DEFINITION = JSON.parse(
readFileSync(
fileURLToPath(new URL("../../src/cf_ui/static/cf_ui/cf_ui_axes.json", import.meta.url)),
"utf-8",
),
);

/**
* Path to the CLI's entry script.
*
* Spawned through `process.execPath` rather than the `.bin` shim so the same
* call works on Windows, where the shim is a `.cmd` and needs a shell.
*/
function cliEntry() {
const manifest = join(CLI_DIR, "package.json");
if (!existsSync(manifest)) {
throw new Error(
"Tailwind is not installed for this check. Run `npm ci` in tests/tailwind " +
"(or `just test-tailwind`, which does it for you). This throws rather than " +
"skipping on purpose — a skip would read as a pass.",
);
}
const { bin } = JSON.parse(readFileSync(manifest, "utf-8"));
return join(CLI_DIR, typeof bin === "string" ? bin : Object.values(bin)[0]);
}

const OUT_DIR = mkdtempSync(join(tmpdir(), "cf-ui-tailwind-"));
after(() => rmSync(OUT_DIR, { recursive: true, force: true }));

/** Build one fixture, returning the raw exit status, output, and CSS. */
function build(fixture, { env } = {}) {
const output = join(OUT_DIR, `${fixture}.out.css`);
const result = spawnSync(
process.execPath,
[cliEntry(), "--input", join(FIXTURES, `${fixture}.css`), "--output", output],
{ encoding: "utf-8", env: { ...process.env, ...env } },
);
return {
// `status` is null when a process dies on a signal; keep it raw so an
// assertion against 0 cannot be satisfied by a crash.
status: result.status,
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
css: existsSync(output) ? readFileSync(output, "utf-8") : "",
};
}

describe("real Tailwind build — the good paths compile", () => {
it("accepts a bare @plugin", () => {
const { status, output } = build("bare");
assert.equal(status, 0, `build failed:\n${output}`);
});

// The regression that matters. Tailwind forwards `@plugin { ... }` options
// only to a plugin marked `__isOptionsFunction`; drop or rename that marker
// upstream and this build starts failing with "does not accept options",
// while the unit suite's `assert.equal(cfUiAxes.__isOptionsFunction, true)`
// stays green.
it("accepts options through @plugin { composition: console; }", () => {
const { status, output } = build("composition");
assert.equal(status, 0, `build failed:\n${output}`);
});
});

describe("real Tailwind build — the CSS actually reaches the output", () => {
const { status, css, output } = build("bare");

it("compiled at all", () => {
assert.equal(status, 0, `build failed:\n${output}`);
});

// A build that succeeds and emits nothing is the other silent failure: the
// plugin would be "working" and the consumer would get no axis styling.
it("emits a rule for every axis value, in both modes", () => {
for (const axis of DEFINITION.axes) {
const attr = DEFINITION.axisAttrs[axis];
for (const value of Object.keys(DEFINITION.valueSets[axis] ?? {})) {
const selector = `[${attr}="${value}"]`;
assert.ok(css.includes(selector), `missing ${selector} in the compiled CSS`);
if (DEFINITION.modeKeyedAxes.includes(axis)) {
assert.ok(
css.includes(`[data-theme="dark"]${selector}`),
`missing the dark-mode rule for ${selector}`,
);
}
}
}
});

it("emits the Tailwind theme aliases", () => {
for (const alias of Object.values(DEFINITION.aliases)) {
assert.ok(css.includes(alias), `missing the ${alias} alias in the compiled CSS`);
}
assert.ok(css.includes("--color-primary"), "the --color-primary alias is the headline one");
});

it("emits the wide-gamut layer", () => {
assert.ok(
/@media\s*\(\s*color-gamut:\s*p3\s*\)/.test(css),
"missing the @media (color-gamut: p3) layer",
);
});
});

describe("real Tailwind build — the bad path fails the build", () => {
// The whole point of #7. Checked as an exit code from the process itself:
// piping the CLI through anything else reports the pipeline's status, not
// Tailwind's, and would pass against a build that never failed.
it("exits non-zero on an unknown composition", () => {
const { status, output } = build("bad-composition");
assert.notEqual(status, 0, `the build succeeded — it must not:\n${output}`);
assert.match(output, /unknown composition 'brutalist'/);
});

it("writes no stylesheet when it fails", () => {
const { css } = build("bad-composition");
assert.equal(css, "", "a failed build left a stylesheet behind");
});
});
7 changes: 7 additions & 0 deletions tests/tailwind/fixtures/bad-composition.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* The build error, as a consumer would actually hit it. `brutalist` is not a
known composition, so this build must fail — a zero exit here means the
headline feature of #7 is inert. */
@import "tailwindcss" source(none);
@plugin "../../../src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs" {
composition: brutalist;
}
6 changes: 6 additions & 0 deletions tests/tailwind/fixtures/bare.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* The idiomatic v4 CSS-first setup, with no options. */
/* `source(none)` keeps Tailwind from crawling the repo for utilities — this
check is about the plugin's base layer, and auto-detection would make the
build both slow and sensitive to unrelated files. */
@import "tailwindcss" source(none);
@plugin "../../../src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs";
8 changes: 8 additions & 0 deletions tests/tailwind/fixtures/composition.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* The path #7 shipped and could not actually exercise: options through
`@plugin`. Tailwind only forwards these to a plugin marked
`__isOptionsFunction`; without the marker this build fails with "does not
accept options" and the composition is never validated at all. */
@import "tailwindcss" source(none);
@plugin "../../../src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs" {
composition: console;
}
Loading
Loading