1import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6import { beforeAll, describe, expect, it } from "vite-plus/test";
7
8// Regression guard for the uniwind platform-variant leak (audit #15 / #13161):
9// Tailwind groups every `ios:` (or `android:`) utility into one shared
10// `@media ios { ... }` block, and uniwind's CSS processor used to drop the
11// block's media queries after its first nested rule. Everything past the first
12// utility compiled unguarded and shipped to both platforms. These tests run
13// the installed uniwind compiler over real Tailwind output, per
14// platform, and assert what each bundle receives. The compiler itself runs in
15// a plain Node child process (uniwind-platform-variants.fixture.cjs) so no
16// test-runner module transforms sit between the test and the shipped code.
17
18interface CompiledStyle {
19 native: boolean;
20 minWidth: number;
21 maxWidth: number;
22 active: boolean | null;
23 focus: boolean | null;
24 disabled: boolean | null;
25 dataAttributes: Record<string, string> | null;
26}
27
28interface FixtureOutput {
29 tailwindChecks: {
30 iosBlocks: number;
31 androidBlocks: number;
32 iosUtilities: number;
33 androidUtilities: number;
34 };
35 transformerCheck: {
36 reinitPayload: boolean;
37 fingerprintArg: boolean;
38 themesArg: boolean;
39 };
40 platforms: Record<
41 string,
42 {
43 styles: Record<string, CompiledStyle[] | undefined>;
44 payloadIncludesAllCompiled: boolean;
45 payloadLeaks: string[];
46 }
47 >;
48}
49
50// Mirrors the classNames the audits flagged: multiple platform utilities per
51// block (only the first one used to keep its guard), a base utility overridden
52// by an `ios:` variant (NewTaskDraftScreen), and opposing `ios:`/`android:`
53// font families (worktree-setup-card).
54const FIXTURE_SOURCE = `export const Probe = () => (
55 <div className="android:shrink android:grow-0 ios:flex-1 pt-12 ios:pt-[72px] ios:font-[family-name:Menlo] android:font-mono flex-1 sm:p-6 sm:text-lg" />
56);
57`;
58
59const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"];
60const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"];
61const SHARED_CLASSES = ["flex-1", "pt-12"];
62const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"];
63
64const runFixture = (globalCss: string, probeSource: string): FixtureOutput => {
65 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "uniwind-platform-"));
66 try {
67 // So `@import "tailwindcss"` and `@import "uniwind"` resolve like in the app.
68 NodeFS.symlinkSync(
69 NodeFS.realpathSync(new URL("../../node_modules", import.meta.url)),
70 NodePath.join(tempDir, "node_modules"),
71 "dir",
72 );
73 NodeFS.writeFileSync(NodePath.join(tempDir, "global.css"), globalCss);
74 NodeFS.writeFileSync(NodePath.join(tempDir, "Probe.tsx"), probeSource);
75
76 const fixture = NodeURL.fileURLToPath(
77 new URL("./uniwind-platform-variants.fixture.cjs", import.meta.url),
78 );
79 const stdout = NodeChildProcess.execFileSync(process.execPath, [fixture, tempDir], {
80 encoding: "utf8",
81 maxBuffer: 32 * 1024 * 1024,
82 timeout: 60_000,
83 });
84 // The fixture's compiler may log warnings to stdout before the JSON payload.
85 const start = stdout.indexOf('{"tailwindChecks"');
86 if (start === -1) {
87 throw new Error(`Fixture produced no JSON:\n${stdout}`);
88 }
89 return JSON.parse(stdout.slice(start)) as FixtureOutput;
90 } finally {
91 NodeFS.rmSync(tempDir, { recursive: true, force: true });
92 }
93};
94
95describe("uniwind platform variants compile per platform", () => {
96 let output: FixtureOutput;
97
98 beforeAll(() => {
99 output = runFixture('@import "tailwindcss";\n@import "uniwind";\n', FIXTURE_SOURCE);
100 }, 60_000);
101
102 it("compiles the fixture utilities into shared platform blocks", () => {
103 // Without this grouping the leak could not happen and the assertions below
104 // would prove nothing.
105 expect(output.tailwindChecks).toEqual({
106 iosBlocks: 1,
107 androidBlocks: 1,
108 iosUtilities: 3,
109 androidUtilities: 3,
110 });
111 });
112
113 it("keeps ios: utilities out of the android bundle and guards them on ios", () => {
114 for (const className of IOS_CLASSES) {
115 expect(
116 output.platforms.android?.styles[className],
117 `android has ${className}`,
118 ).toBeUndefined();
119 expect(output.platforms.ios?.styles[className], `ios lacks ${className}`).toBeDefined();
120 expect(output.platforms.ios?.styles[className]?.every((style) => style.native)).toBe(true);
121 }
122 });
123
124 it("keeps android: utilities out of the ios bundle and guards them on android", () => {
125 for (const className of ANDROID_CLASSES) {
126 expect(output.platforms.ios?.styles[className], `ios has ${className}`).toBeUndefined();
127 expect(
128 output.platforms.android?.styles[className],
129 `android lacks ${className}`,
130 ).toBeDefined();
131 expect(output.platforms.android?.styles[className]?.every((style) => style.native)).toBe(
132 true,
133 );
134 }
135 });
136
137 it("keeps shared and responsive utilities usable on both platforms", () => {
138 for (const className of [...SHARED_CLASSES, ...RESPONSIVE_CLASSES]) {
139 for (const platform of ["ios", "android"]) {
140 const styles = output.platforms[platform]?.styles[className];
141 expect(styles, `${platform} lacks ${className}`).toBeDefined();
142 expect(styles?.every((style) => !style.native)).toBe(true);
143 }
144 }
145 // The leak hid a second regression: only the first utility of a width
146 // block kept its breakpoint, so the second applied at every screen size.
147 for (const platform of ["ios", "android"]) {
148 for (const className of RESPONSIVE_CLASSES) {
149 const style = output.platforms[platform]?.styles[className]?.at(-1);
150 expect(style?.minWidth, `${className} lost its breakpoint`).toBeGreaterThan(0);
151 }
152 }
153 });
154
155 it("serializes every compiled style into the platform payload and leaks nothing", () => {
156 for (const platform of ["ios", "android"]) {
157 expect(output.platforms[platform]?.payloadIncludesAllCompiled).toBe(true);
158 // A platform utility dropped from this platform's stylesheet must not
159 // survive anywhere in the payload this platform bundles.
160 expect(output.platforms[platform]?.payloadLeaks, `${platform} payload leaks`).toEqual([]);
161 }
162 });
163});
164
165// The media-query fix must not lose the surrounding rule context: Tailwind
166// also emits media rules *nested inside a class rule* (`@utility` bodies with
167// `@variant`, nested breakpoints), and those declarations belong to the
168// enclosing class. These expectations match the pre-fix compiler output for
169// the same inputs, captured against the unpatched package.
170describe("uniwind keeps media rules nested inside class rules attached to the class", () => {
171 let nested: FixtureOutput;
172
173 beforeAll(() => {
174 nested = runFixture(
175 [
176 '@import "tailwindcss";',
177 '@import "uniwind";',
178 "",
179 ".container-x {",
180 " width: 100%;",
181 " @media (width >= 40rem) {",
182 " max-width: 40rem;",
183 " }",
184 "}",
185 "",
186 "@utility foo-x {",
187 " padding: 2px;",
188 " @variant ios {",
189 " padding: 3px;",
190 " }",
191 "}",
192 "",
193 ].join("\n"),
194 'export const Probe = () => <div className="container-x foo-x" />;\n',
195 );
196 }, 60_000);
197
198 it("keeps the nested breakpoint entry of a nested media rule", () => {
199 for (const platform of ["ios", "android"]) {
200 const entries = nested.platforms[platform]?.styles["container-x"];
201 expect(entries, `${platform} lacks container-x`).toBeDefined();
202 expect(entries?.some((style) => !style.native && style.minWidth === 0)).toBe(true);
203 expect(entries?.some((style) => !style.native && style.minWidth > 0)).toBe(true);
204 }
205 });
206
207 it("keeps @variant declarations inside @utility on their platform", () => {
208 const iosEntries = nested.platforms.ios?.styles["foo-x"];
209 expect(iosEntries?.some((style) => !style.native)).toBe(true);
210 expect(iosEntries?.some((style) => style.native)).toBe(true);
211
212 const androidEntries = nested.platforms.android?.styles["foo-x"];
213 expect(androidEntries?.some((style) => !style.native)).toBe(true);
214 expect(androidEntries?.some((style) => style.native)).toBe(false);
215 });
216});
217
218// Keep guarding selector variants and the Metro native-styles fingerprint
219// alongside media-query scoping when upgrading the installed Uniwind package.
220describe("uniwind keeps selector and transformer behavior", () => {
221 let output: FixtureOutput;
222
223 beforeAll(() => {
224 output = runFixture(
225 '@import "tailwindcss";\n@import "uniwind";\n',
226 'export const Probe = () => <div className="active:opacity-50 disabled:opacity-50 data-x:underline aria-disabled:text-red-500" />;\n',
227 );
228 }, 60_000);
229
230 it("keeps state and data variants conditioned, and rejects unsupported compounds", () => {
231 for (const platform of ["ios", "android"]) {
232 const styles = output.platforms[platform]?.styles;
233 expect(styles?.["active:opacity-50"]?.every((style) => style.active === true)).toBe(true);
234 expect(styles?.["disabled:opacity-50"]?.every((style) => style.disabled === true)).toBe(true);
235 expect(styles?.["data-x:underline"]?.every((style) => style.dataAttributes?.["data-x"])).toBe(
236 true,
237 );
238 // `[aria-disabled="true"]` is not expressible at runtime; emitting it
239 // unconditionally (the pre-#9355 behavior) styles the element always.
240 expect(styles).not.toHaveProperty("aria-disabled:text-red-500");
241 }
242 });
243
244 it("emits the global.css virtual module with a native styles fingerprint", () => {
245 expect(output.transformerCheck).toEqual({
246 reinitPayload: true,
247 fingerprintArg: true,
248 themesArg: true,
249 });
250 });
251});