| 1 | import * as NodeChildProcess from "node:child_process"; |
| 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import * as NodeURL from "node:url"; |
| 6 | import { 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 | |
| 18 | interface 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 | |
| 28 | interface 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). |
| 54 | const 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 | |
| 59 | const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"]; |
| 60 | const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"]; |
| 61 | const SHARED_CLASSES = ["flex-1", "pt-12"]; |
| 62 | const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"]; |
| 63 | |
| 64 | const 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 | |
| 95 | describe("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. |
| 170 | describe("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. |
| 220 | describe("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 | }); |