Skip to content

Commit 3eeaa8d

Browse files
test: cover more features. (#34)
ci: windows.
1 parent f22dbb3 commit 3eeaa8d

18 files changed

Lines changed: 642 additions & 31 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,20 @@ on:
1010

1111
jobs:
1212
ci:
13-
name: CI (Node ${{ matrix.node-version }})
14-
runs-on: ubuntu-latest
13+
name: CI (Node ${{ matrix.version }}-${{ matrix.os }})
1514
strategy:
1615
fail-fast: false
1716
matrix:
18-
node-version:
19-
- '22.17.0'
20-
- '24.11.1'
17+
os: [ubuntu-latest, windows-latest]
18+
version: ['22.17.0', '24.11.1']
19+
runs-on: ${{ matrix.os }}
2120
steps:
2221
- name: Checkout
2322
uses: actions/checkout@v4.2.2
2423
- name: Setup Node
2524
uses: actions/setup-node@v4.3.0
2625
with:
27-
node-version: ${{ matrix.node-version }}
26+
node-version: ${{ matrix.version }}
2827
- name: Install Dependencies
2928
run: npm ci
3029
- name: Save error log

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/css/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/css",
3-
"version": "1.0.0-rc.13",
3+
"version": "1.0.0-rc.14",
44
"description": "A build-time utility that traverses JavaScript/TypeScript module dependency graphs to extract, compile, and optimize all imported CSS into a single, in-memory string.",
55
"type": "module",
66
"main": "./dist/css.js",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"cli-generation-summary": "[log]\n[knighted-css] Selector modules updated: wrote 1, removed 0.\n[knighted-css] Manifest: <outDir>/selector-modules.json\n[knighted-css] Selector modules are up to date.\n[knighted-css] Manifest: <outDir>/selector-modules.json\n[warn]",
3+
"cli-help-output": "Usage: knighted-css-generate-types [options]\n\nOptions:\n -r, --root <path> Project root directory (default: cwd)\n -i, --include <path> Additional directories/files to scan (repeatable)\n --out-dir <path> Directory to store selector module manifest cache\n --stable-namespace <name> Stable namespace prefix for generated selector maps\n -h, --help Show this help message"
4+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import assert from 'node:assert/strict'
2+
import fs from 'node:fs/promises'
3+
import os from 'node:os'
4+
import path from 'node:path'
5+
import test from 'node:test'
6+
7+
import { cssWithMeta } from '../src/css.ts'
8+
import type { CssResolver } from '../src/types.js'
9+
10+
interface Project {
11+
root: string
12+
file: (rel: string) => string
13+
writeFile: (rel: string, contents: string) => Promise<string>
14+
cleanup: () => Promise<void>
15+
}
16+
17+
async function createProject(prefix: string): Promise<Project> {
18+
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
19+
const writeFile = async (rel: string, contents: string): Promise<string> => {
20+
const target = path.join(root, rel)
21+
await fs.mkdir(path.dirname(target), { recursive: true })
22+
await fs.writeFile(target, contents, 'utf8')
23+
return target
24+
}
25+
return {
26+
root,
27+
file: rel => path.join(root, rel),
28+
writeFile,
29+
cleanup: () => fs.rm(root, { recursive: true, force: true }),
30+
}
31+
}
32+
33+
async function realpathAll(paths: string[]): Promise<string[]> {
34+
return Promise.all(paths.map(filePath => fs.realpath(filePath)))
35+
}
36+
37+
test('css walker dedupes style modules and preserves discovery order', async () => {
38+
const project = await createProject('knighted-css-walker-order-')
39+
try {
40+
await project.writeFile('styles/reset.css', '/* reset */\n.reset { color: #111; }')
41+
await project.writeFile('styles/shared.css', '/* shared */\n.shared { color: #222; }')
42+
await project.writeFile(
43+
'components/widget.css',
44+
'/* widget */\n.widget { color: #333; }',
45+
)
46+
await project.writeFile('styles/async.css', '/* async */\n.async { color: #444; }')
47+
await project.writeFile(
48+
'styles/nested/deep.css',
49+
'/* deep */\n.deep { color: #555; }',
50+
)
51+
52+
const entrySource = `import './styles/reset.css'
53+
import './shared.ts'
54+
await import('./async-entry.ts')
55+
`
56+
await project.writeFile('entry.ts', entrySource)
57+
58+
const sharedSource = `import './styles/shared.css'
59+
import './components/widget.ts'
60+
import './styles/reset.css'
61+
`
62+
await project.writeFile('shared.ts', sharedSource)
63+
64+
const widgetSource = "import './widget.css'\n"
65+
await project.writeFile('components/widget.ts', widgetSource)
66+
67+
const asyncEntrySource = `import './styles/async.css'
68+
export async function load() {
69+
await import('./nested/deep.ts')
70+
}
71+
`
72+
await project.writeFile('async-entry.ts', asyncEntrySource)
73+
74+
await project.writeFile('nested/deep.ts', "import '../styles/nested/deep.css'\n")
75+
76+
const { css, files } = await cssWithMeta(project.file('entry.ts'))
77+
78+
const expectedOrder = [
79+
project.file('styles/reset.css'),
80+
project.file('styles/shared.css'),
81+
project.file('components/widget.css'),
82+
project.file('styles/async.css'),
83+
project.file('styles/nested/deep.css'),
84+
]
85+
86+
assert.deepEqual(await realpathAll(files), await realpathAll(expectedOrder))
87+
88+
const markers = [
89+
'/* reset */',
90+
'/* shared */',
91+
'/* widget */',
92+
'/* async */',
93+
'/* deep */',
94+
]
95+
for (const marker of markers) {
96+
const occurrences = css.split(marker).length - 1
97+
assert.equal(occurrences, 1, `expected ${marker} to appear exactly once`)
98+
}
99+
for (let i = 1; i < markers.length; i += 1) {
100+
const prevIndex = css.indexOf(markers[i - 1])
101+
const nextIndex = css.indexOf(markers[i])
102+
assert.ok(
103+
prevIndex >= 0 && nextIndex > prevIndex,
104+
`${markers[i - 1]} should precede ${markers[i]}`,
105+
)
106+
}
107+
} finally {
108+
await project.cleanup()
109+
}
110+
})
111+
112+
test('css walker honors custom resolver mappings for nonstandard specifiers', async () => {
113+
const project = await createProject('knighted-css-walker-resolver-')
114+
try {
115+
await project.writeFile('styles/global.css', '/* global */\n.global { color: #666; }')
116+
await project.writeFile('styles/button.css', '/* button */\n.button { color: #777; }')
117+
118+
const entrySource = `import '@pkg/global.css'
119+
import './view.ts'
120+
`
121+
await project.writeFile('entry.ts', entrySource)
122+
123+
const viewSource = "import '@shared/button.css'\n"
124+
await project.writeFile('view.ts', viewSource)
125+
126+
const resolver: CssResolver = async specifier => {
127+
if (specifier.startsWith('@pkg/')) {
128+
const relative = specifier.replace(/^@pkg\//, 'styles/')
129+
return project.file(relative)
130+
}
131+
if (specifier === '@shared/button.css') {
132+
return project.file('styles/button.css')
133+
}
134+
return undefined
135+
}
136+
137+
const { css, files } = await cssWithMeta(project.file('entry.ts'), {
138+
resolver,
139+
})
140+
141+
const expected = [
142+
project.file('styles/global.css'),
143+
project.file('styles/button.css'),
144+
]
145+
146+
assert.deepEqual(await realpathAll(files), await realpathAll(expected))
147+
assert.ok(css.includes('/* global */'))
148+
assert.ok(css.includes('/* button */'))
149+
} finally {
150+
await project.cleanup()
151+
}
152+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export type CombinedRuntimeCardProps = {
2+
label?: string
3+
}
4+
5+
export default function CombinedRuntimeCard(
6+
props: CombinedRuntimeCardProps = {},
7+
): string {
8+
const label = props.label ?? 'Knighted CSS'
9+
return `Runtime card for ${label}`
10+
}
11+
12+
export function CombinedRuntimeDetails(): string {
13+
return 'details rendered from combined runtime entry'
14+
}
15+
16+
export const runtimeFeatureFlag = true
17+
18+
export const runtimeMeta = Object.freeze({
19+
tone: 'violet',
20+
tags: ['combined', 'types'] as const,
21+
})

packages/css/test/generateTypes.test.ts

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import {
1414

1515
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1616

17+
const SNAPSHOT_DIR = path.join(__dirname, '__snapshots__')
18+
const CLI_SNAPSHOT_FILE = path.join(SNAPSHOT_DIR, 'generateTypes.snap.json')
19+
const UPDATE_SNAPSHOTS =
20+
process.env.UPDATE_SNAPSHOTS === '1' || process.env.UPDATE_SNAPSHOTS === 'true'
21+
22+
let cachedCliSnapshots: Record<string, string> | null = null
23+
1724
async function setupFixtureProject(): Promise<{
1825
root: string
1926
cleanup: () => Promise<void>
@@ -81,6 +88,99 @@ async function pathExists(target: string): Promise<boolean> {
8188
}
8289
}
8390

91+
async function loadCliSnapshots(): Promise<Record<string, string>> {
92+
if (cachedCliSnapshots) {
93+
return cachedCliSnapshots
94+
}
95+
try {
96+
const raw = await fs.readFile(CLI_SNAPSHOT_FILE, 'utf8')
97+
cachedCliSnapshots = JSON.parse(raw) as Record<string, string>
98+
} catch (error) {
99+
const nodeError = error as NodeJS.ErrnoException
100+
if (nodeError.code === 'ENOENT') {
101+
cachedCliSnapshots = {}
102+
} else {
103+
throw error
104+
}
105+
}
106+
return cachedCliSnapshots
107+
}
108+
109+
async function writeCliSnapshots(map: Record<string, string>): Promise<void> {
110+
cachedCliSnapshots = map
111+
await fs.mkdir(SNAPSHOT_DIR, { recursive: true })
112+
await fs.writeFile(CLI_SNAPSHOT_FILE, `${JSON.stringify(map, null, 2)}\n`)
113+
}
114+
115+
function normalizeSnapshotText(value: string): string {
116+
let next = value.replace(/\r\n/g, '\n')
117+
if (path.sep === '\\') {
118+
next = next.replace(/\\/g, '/')
119+
}
120+
return next.trimEnd()
121+
}
122+
123+
function replaceAllVariants(value: string, raw: string, token: string): string {
124+
const posix = raw.split(path.sep).join('/')
125+
const win = raw.split(path.sep).join('\\')
126+
const variants = new Set([raw, path.normalize(raw), posix, win])
127+
let result = value
128+
for (const variant of variants) {
129+
if (!variant || variant === token) {
130+
continue
131+
}
132+
result = result.split(variant).join(token)
133+
}
134+
return result
135+
}
136+
137+
function applyPathPlaceholders(
138+
value: string,
139+
placeholders: Record<string, string>,
140+
): string {
141+
let result = value
142+
const entries = Object.entries(placeholders).sort(([a], [b]) => b.length - a.length)
143+
for (const [raw, token] of entries) {
144+
if (!raw) {
145+
continue
146+
}
147+
result = replaceAllVariants(result, raw, token)
148+
}
149+
return result
150+
}
151+
152+
function buildCliTranscript(
153+
logs: string[],
154+
warns: string[],
155+
placeholders: Record<string, string> = {},
156+
): string {
157+
const sections = ['[log]', ...logs, '[warn]', ...warns]
158+
const combined = sections.join('\n')
159+
return normalizeSnapshotText(applyPathPlaceholders(combined, placeholders))
160+
}
161+
162+
async function expectCliSnapshot(name: string, value: string): Promise<void> {
163+
const normalized = normalizeSnapshotText(value)
164+
const snapshots = await loadCliSnapshots()
165+
const existing = snapshots[name]
166+
if (UPDATE_SNAPSHOTS) {
167+
if (existing !== normalized) {
168+
snapshots[name] = normalized
169+
await writeCliSnapshots(snapshots)
170+
}
171+
return
172+
}
173+
assert.ok(
174+
existing,
175+
`Snapshot "${name}" is missing. Re-run with UPDATE_SNAPSHOTS=1 to record it.`,
176+
)
177+
assert.equal(
178+
normalized,
179+
existing,
180+
`Snapshot mismatch for "${name}". Re-run with UPDATE_SNAPSHOTS=1 to update.`,
181+
)
182+
}
183+
84184
test('generateTypes emits declarations and reuses cache', async () => {
85185
const project = await setupFixtureProject()
86186
try {
@@ -356,9 +456,11 @@ test('runGenerateTypesCli executes generation and reports summaries', async () =
356456
console.log = originalLog
357457
console.warn = originalWarn
358458
}
359-
assert.ok(logs.some(log => log.includes('Selector modules updated')))
360-
assert.ok(logs.some(log => log.includes('Selector modules are up to date.')))
361-
assert.equal(warns.length, 0)
459+
const transcript = buildCliTranscript(logs, warns, {
460+
[project.root]: '<projectRoot>',
461+
[outDir]: '<outDir>',
462+
})
463+
await expectCliSnapshot('cli-generation-summary', transcript)
362464
const manifestPath = path.join(outDir, 'selector-modules.json')
363465
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record<
364466
string,
@@ -379,7 +481,7 @@ test('runGenerateTypesCli prints help output when requested', async () => {
379481
} finally {
380482
console.log = originalLog
381483
}
382-
assert.ok(printed.some(line => line.includes('Usage: knighted-css-generate-types')))
484+
await expectCliSnapshot('cli-help-output', printed.join('\n'))
383485
})
384486
test('generateTypes internals support selector module helpers', async () => {
385487
const {

packages/css/test/helpers/resolver-fixture.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import path from 'node:path'
2+
import { fileURLToPath } from 'node:url'
23

34
import type { CssResolver } from '../../src/css.js'
45

5-
const fixturesRoot = path.resolve(
6-
path.dirname(new URL(import.meta.url).pathname),
7-
'../fixtures/resolvers',
8-
)
6+
const helperDir = fileURLToPath(new URL('.', import.meta.url))
7+
const fixturesRoot = path.resolve(helperDir, '../fixtures/resolvers')
98

109
type FixtureName = 'rspack' | 'vite' | 'webpack'
1110

0 commit comments

Comments
 (0)