| 1 | import { assert, describe, expect } from "@effect/vitest"; |
| 2 | |
| 3 | import { createOxlintRuleHarness } from "../test/utils.ts"; |
| 4 | |
| 5 | const guardedMobileFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-theme-escape-hatches", { |
| 6 | filename: "apps/mobile/src/features/settings/NewThemeSurface.tsx", |
| 7 | }); |
| 8 | const reviewedInteropFile = createOxlintRuleHarness( |
| 9 | "t3code/no-mobile-uniwind-theme-escape-hatches", |
| 10 | { |
| 11 | filename: "apps/mobile/src/features/home/HomeHeader.tsx", |
| 12 | ruleOptions: [{ allowUniwindTheme: true }], |
| 13 | }, |
| 14 | ); |
| 15 | |
| 16 | describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => { |
| 17 | guardedMobileFile.valid( |
| 18 | "allows semantic mobile theme classes", |
| 19 | `const surface = <View className="bg-surface text-foreground" />;`, |
| 20 | ); |
| 21 | |
| 22 | guardedMobileFile.valid( |
| 23 | "allows unrelated functions with legacy hook names", |
| 24 | ` |
| 25 | const useCSSVariable = () => "local value"; |
| 26 | const useThemeColor = () => "local color"; |
| 27 | |
| 28 | export const values = [useCSSVariable(), useThemeColor()]; |
| 29 | `, |
| 30 | ); |
| 31 | |
| 32 | guardedMobileFile.valid( |
| 33 | "allows unrelated imports with legacy hook names", |
| 34 | ` |
| 35 | import { useThemeColor } from "./unrelated-library"; |
| 36 | |
| 37 | export const foreground = useThemeColor(); |
| 38 | `, |
| 39 | ); |
| 40 | |
| 41 | guardedMobileFile.valid( |
| 42 | "allows erased type-only theme imports", |
| 43 | ` |
| 44 | import type { useCSSVariable } from "uniwind"; |
| 45 | import { type useCSSVariable as CSSVariableHook } from "uniwind"; |
| 46 | import type { useThemeColor } from "../../../hooks/useThemeColor"; |
| 47 | import type { useUniwindTheme } from "../../../lib/useUniwindTheme"; |
| 48 | |
| 49 | export type ThemeHooks = [ |
| 50 | typeof useCSSVariable, |
| 51 | typeof CSSVariableHook, |
| 52 | typeof useThemeColor, |
| 53 | typeof useUniwindTheme, |
| 54 | ]; |
| 55 | `, |
| 56 | ); |
| 57 | |
| 58 | guardedMobileFile.valid( |
| 59 | "allows appearance words in ordinary copy", |
| 60 | ` |
| 61 | const message = "Set dark mode manually"; |
| 62 | const detail = "For dark: high contrast mode is available"; |
| 63 | const label = <Text accessibilityLabel="Light theme">Theme</Text>; |
| 64 | `, |
| 65 | ); |
| 66 | |
| 67 | guardedMobileFile.valid( |
| 68 | "does not conflate shadowed Uniwind namespaces", |
| 69 | ` |
| 70 | import * as Uniwind from "uniwind"; |
| 71 | |
| 72 | export function readDirect(Uniwind: { useCSSVariable: () => string }) { |
| 73 | return Uniwind.useCSSVariable(); |
| 74 | } |
| 75 | |
| 76 | export function readDestructured(Uniwind: { useCSSVariable: () => string }) { |
| 77 | const { useCSSVariable } = Uniwind; |
| 78 | return useCSSVariable(); |
| 79 | } |
| 80 | `, |
| 81 | ); |
| 82 | |
| 83 | reviewedInteropFile.valid( |
| 84 | "allows reviewed native interop boundaries", |
| 85 | ` |
| 86 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 87 | |
| 88 | export const foreground = useUniwindTheme().colors.foreground; |
| 89 | `, |
| 90 | ); |
| 91 | |
| 92 | reviewedInteropFile.invalid( |
| 93 | "still reports appearance variants in reviewed interop boundaries", |
| 94 | `const surface = <View className="bg-white dark:bg-black" />;`, |
| 95 | ); |
| 96 | |
| 97 | guardedMobileFile.invalid( |
| 98 | "reports new React theme subscriptions", |
| 99 | ` |
| 100 | import { useCSSVariable } from "uniwind"; |
| 101 | |
| 102 | export const foreground = useCSSVariable("--color-foreground"); |
| 103 | `, |
| 104 | (output) => { |
| 105 | assert.match(output, /semantic className/); |
| 106 | }, |
| 107 | ); |
| 108 | |
| 109 | guardedMobileFile.invalid( |
| 110 | "reports the retired theme color hook", |
| 111 | ` |
| 112 | import { useThemeColor } from "../../../hooks/useThemeColor"; |
| 113 | |
| 114 | export const foreground = useThemeColor({}, "foreground"); |
| 115 | `, |
| 116 | (output) => { |
| 117 | assert.match(output, /replaced by semantic Uniwind classes/); |
| 118 | }, |
| 119 | ); |
| 120 | |
| 121 | guardedMobileFile.invalid( |
| 122 | "reports guarded hooks imported with TypeScript extensions", |
| 123 | ` |
| 124 | import { useThemeColor } from "../../../hooks/useThemeColor.ts"; |
| 125 | import { useUniwindTheme } from "../../../lib/useUniwindTheme.ts"; |
| 126 | |
| 127 | export const foreground = [ |
| 128 | useThemeColor({}, "foreground"), |
| 129 | useUniwindTheme().colors.foreground, |
| 130 | ]; |
| 131 | `, |
| 132 | (output) => { |
| 133 | assert.match(output, /replaced by semantic Uniwind classes/); |
| 134 | assert.match(output, /native\/third-party interop boundary/); |
| 135 | }, |
| 136 | ); |
| 137 | |
| 138 | guardedMobileFile.invalid( |
| 139 | "reports unreviewed native interop subscriptions", |
| 140 | ` |
| 141 | import { useUniwindTheme } from "../../../lib/useUniwindTheme"; |
| 142 | |
| 143 | export const foreground = useUniwindTheme().colors.foreground; |
| 144 | `, |
| 145 | (output) => { |
| 146 | assert.match(output, /native\/third-party interop boundary/); |
| 147 | }, |
| 148 | ); |
| 149 | |
| 150 | guardedMobileFile.invalid( |
| 151 | "reports appearance variants in string literals", |
| 152 | `const surface = <View className="bg-white dark:bg-black" />;`, |
| 153 | (output) => { |
| 154 | assert.match(output, /registered custom themes/); |
| 155 | }, |
| 156 | ); |
| 157 | |
| 158 | guardedMobileFile.invalid( |
| 159 | "reports appearance variants in template literals", |
| 160 | "const className = `bg-black light:bg-white`;", |
| 161 | ); |
| 162 | |
| 163 | guardedMobileFile.invalid( |
| 164 | "reports escaped appearance variants in template literals", |
| 165 | "const className = `dark\\u003abg-black`;", |
| 166 | ); |
| 167 | |
| 168 | guardedMobileFile.invalid( |
| 169 | "reports appearance variants through nested class-map indirection", |
| 170 | ` |
| 171 | const styles = { |
| 172 | variants: { root: "bg-white dark:bg-black" }, |
| 173 | }; |
| 174 | |
| 175 | const root = styles.variants.root; |
| 176 | export const surface = <View className={root} />; |
| 177 | `, |
| 178 | (output) => { |
| 179 | expect(output.match(/registered custom themes/g)).toHaveLength(1); |
| 180 | }, |
| 181 | ); |
| 182 | |
| 183 | guardedMobileFile.invalid( |
| 184 | "reports appearance variants passed to class builders", |
| 185 | `const surface = cn("bg-white", enabled && "dark:bg-black");`, |
| 186 | ); |
| 187 | |
| 188 | guardedMobileFile.invalid( |
| 189 | "reports negative and important appearance variants", |
| 190 | `const className = "dark:-mt-2 light:!bg-white";`, |
| 191 | ); |
| 192 | |
| 193 | guardedMobileFile.invalid( |
| 194 | "reports namespace CSS variable subscriptions", |
| 195 | ` |
| 196 | import * as Uniwind from "uniwind"; |
| 197 | |
| 198 | export const foreground = Uniwind.useCSSVariable("--color-foreground"); |
| 199 | `, |
| 200 | ); |
| 201 | |
| 202 | guardedMobileFile.invalid( |
| 203 | "reports destructured namespace CSS variable subscriptions", |
| 204 | ` |
| 205 | import * as Uniwind from "uniwind"; |
| 206 | |
| 207 | const { useCSSVariable: resolveVariable } = Uniwind; |
| 208 | export const foreground = resolveVariable("--color-foreground"); |
| 209 | `, |
| 210 | ); |
| 211 | |
| 212 | guardedMobileFile.invalid( |
| 213 | "reports aliased namespace CSS variable subscriptions", |
| 214 | ` |
| 215 | import * as Uniwind from "uniwind"; |
| 216 | |
| 217 | const Theme = Uniwind; |
| 218 | const NestedTheme = Theme; |
| 219 | export const foreground = NestedTheme.useCSSVariable("--color-foreground"); |
| 220 | `, |
| 221 | ); |
| 222 | |
| 223 | guardedMobileFile.invalid( |
| 224 | "reports object-rest namespace CSS variable subscriptions", |
| 225 | ` |
| 226 | import * as Uniwind from "uniwind"; |
| 227 | |
| 228 | const { ...Theme } = Uniwind; |
| 229 | export const foreground = Theme.useCSSVariable("--color-foreground"); |
| 230 | `, |
| 231 | ); |
| 232 | |
| 233 | guardedMobileFile.invalid( |
| 234 | "reports namespace access to the retired theme hook", |
| 235 | ` |
| 236 | import * as ThemeColor from "../../../hooks/useThemeColor"; |
| 237 | |
| 238 | export const foreground = ThemeColor.useThemeColor({}, "foreground"); |
| 239 | `, |
| 240 | ); |
| 241 | |
| 242 | guardedMobileFile.invalid( |
| 243 | "reports destructured namespace access to the retired theme hook", |
| 244 | ` |
| 245 | import * as ThemeColor from "../../../hooks/useThemeColor.ts"; |
| 246 | |
| 247 | const { useThemeColor: resolveThemeColor } = ThemeColor; |
| 248 | export const foreground = resolveThemeColor({}, "foreground"); |
| 249 | `, |
| 250 | ); |
| 251 | }); |