create-bascik 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { parseCliOptions } from "./cli.js";
3
+ import {
4
+ E2E_APP_SPEC,
5
+ FEAT_CARD_CSS,
6
+ FEAT_CARD_HTML,
7
+ FEAT_CARD_TEST,
8
+ GITIGNORE,
9
+ MY_COUNTER_CSS,
10
+ MY_COUNTER_HTML,
11
+ MY_COUNTER_TEST,
12
+ PACKAGE_JSON,
13
+ PLAYWRIGHT_CONFIG,
14
+ SITE_FOOTER_CSS,
15
+ SITE_FOOTER_HTML,
16
+ SITE_FOOTER_TEST,
17
+ SITE_HEADER_CSS,
18
+ SITE_HEADER_HTML,
19
+ SITE_HEADER_TEST,
20
+ SITE_META_HTML,
21
+ SITE_META_TEST,
22
+ STYLES_CSS,
23
+ VITE_CONFIG,
24
+ VSCODE_LAUNCH_JSON,
25
+ aboutPage,
26
+ contactPage,
27
+ indexPage,
28
+ notFoundPage,
29
+ scaffold,
30
+ validateProjectName,
31
+ } from "./scaffold.js";
32
+
33
+ // ─── Mocks ───────────────────────────────────────────────────────────────────
34
+
35
+ vi.mock("node:fs/promises", () => ({
36
+ mkdir: vi.fn(async () => undefined),
37
+ readFile: vi.fn(async () => "# Bascik Skill"),
38
+ writeFile: vi.fn(async () => undefined),
39
+ }));
40
+
41
+ import { mkdir, writeFile } from "node:fs/promises";
42
+
43
+ const mockMkdir = mkdir as ReturnType<typeof vi.fn>;
44
+ const mockWriteFile = writeFile as ReturnType<typeof vi.fn>;
45
+
46
+ vi.spyOn(console, "log").mockImplementation(() => { });
47
+
48
+ const writtenTo = (suffix: string): string | undefined => {
49
+ for (const call of mockWriteFile.mock.calls) {
50
+ if (String(call[0]).endsWith(suffix)) return call[1] as string;
51
+ }
52
+ return undefined;
53
+ };
54
+
55
+ const allWrittenPaths = (): string[] =>
56
+ mockWriteFile.mock.calls.map((c) => String(c[0]));
57
+
58
+ describe("parseCliOptions", () => {
59
+ it("starts development by default for the non-interactive yes mode", () => {
60
+ expect(parseCliOptions(["my-site", "-y"])).toEqual({
61
+ yesFlag: true,
62
+ noDevFlag: false,
63
+ });
64
+ });
65
+
66
+ it("supports bounded non-interactive scaffolding without changing install behavior", () => {
67
+ expect(parseCliOptions(["my-site", "--yes", "--no-dev"])).toEqual({
68
+ yesFlag: true,
69
+ noDevFlag: true,
70
+ });
71
+ });
72
+ });
73
+
74
+ // ─── validateProjectName ──────────────────────────────────────────────────────
75
+
76
+ describe("validateProjectName", () => {
77
+ it("accepts a simple name", () => {
78
+ expect(validateProjectName("my-app")).toBeNull();
79
+ });
80
+
81
+ it("accepts names with numbers and dots", () => {
82
+ expect(validateProjectName("app.v2")).toBeNull();
83
+ });
84
+
85
+ it("rejects an empty string", () => {
86
+ expect(validateProjectName("")).not.toBeNull();
87
+ });
88
+
89
+ it("rejects invalid directory names with special characters", () => {
90
+ const res = validateProjectName("my app*invalid");
91
+ expect(res).not.toBeNull();
92
+ expect(res).toContain("is not a valid directory name");
93
+ });
94
+ });
95
+
96
+ // ─── Template exports ─────────────────────────────────────────────────────────
97
+
98
+ describe("PACKAGE_JSON", () => {
99
+ it("is valid JSON", () => {
100
+ expect(() => JSON.parse(PACKAGE_JSON("my-app"))).not.toThrow();
101
+ });
102
+
103
+ it('sets type:module, dev, build, lint, test, and e2e scripts', () => {
104
+ const pkg = JSON.parse(PACKAGE_JSON("my-app"));
105
+ expect(pkg.type).toBe("module");
106
+ expect(pkg.scripts.dev).toBe("bascik");
107
+ expect(pkg.scripts.build).toBe("bascik --build");
108
+ expect(pkg.scripts.lint).toBe("bascik-language-server --check");
109
+ expect(pkg.scripts.test).toBe("vitest run");
110
+ expect(pkg.scripts["test:watch"]).toBe("vitest");
111
+ expect(pkg.scripts["test:coverage"]).toBe("vitest run --coverage");
112
+ expect(pkg.scripts.e2e).toBe("playwright test --config e2e/playwright.config.ts");
113
+ });
114
+
115
+ it("includes language-server, vitest, coverage-v8, and playwright in devDependencies", () => {
116
+ const pkg = JSON.parse(PACKAGE_JSON("my-app"));
117
+ expect(pkg.devDependencies["@bascik/language-server"]).toBeDefined();
118
+ expect(pkg.devDependencies.vitest).toBeDefined();
119
+ expect(pkg.devDependencies["@vitest/coverage-v8"]).toBeDefined();
120
+ expect(pkg.devDependencies["@playwright/test"]).toBeDefined();
121
+ });
122
+
123
+ it("includes @bascik/bascik as a dependency", () => {
124
+ const pkg = JSON.parse(PACKAGE_JSON("my-app"));
125
+ expect(pkg.dependencies["@bascik/bascik"]).toBeDefined();
126
+ });
127
+
128
+ it("uses the supplied name", () => {
129
+ const pkg = JSON.parse(PACKAGE_JSON("cool-site"));
130
+ expect(pkg.name).toBe("cool-site");
131
+ });
132
+
133
+ it("declares a Node 24 engine floor matching the @types/node target", () => {
134
+ const pkg = JSON.parse(PACKAGE_JSON("my-app"));
135
+ expect(pkg.engines.node).toBe(">=24.0.0");
136
+ });
137
+ });
138
+
139
+ describe("VITE_CONFIG", () => {
140
+ it("includes test matchers and coverage config", () => {
141
+ expect(VITE_CONFIG).toContain("src/**/*.test.ts");
142
+ expect(VITE_CONFIG).toContain("provider: 'v8'");
143
+ });
144
+ });
145
+
146
+ describe("PLAYWRIGHT_CONFIG", () => {
147
+ it("configures port 8080 webServer and base URL", () => {
148
+ expect(PLAYWRIGHT_CONFIG).toContain("http://localhost:8080");
149
+ expect(PLAYWRIGHT_CONFIG).toContain("npx bascik --build && npx bascik --server --port 8080");
150
+ });
151
+ });
152
+
153
+ describe("E2E_APP_SPEC", () => {
154
+ it("includes tests for navigation, counter, and mobile menu toggle", () => {
155
+ expect(E2E_APP_SPEC).toContain("navigates pages and checks titles");
156
+ expect(E2E_APP_SPEC).toContain("counter component increments and decrements count");
157
+ expect(E2E_APP_SPEC).toContain("mobile navigation toggle expands and collapses menu");
158
+ });
159
+ });
160
+
161
+ describe("VSCODE_LAUNCH_JSON", () => {
162
+ it("is valid JSON and configures dev server and Chrome debugging", () => {
163
+ expect(() => JSON.parse(VSCODE_LAUNCH_JSON)).not.toThrow();
164
+ const config = JSON.parse(VSCODE_LAUNCH_JSON);
165
+ expect(config.version).toBe("0.2.0");
166
+ expect(config.configurations.some((c: { name: string }) => c.name.includes("Debug Dev Server"))).toBe(true);
167
+ expect(config.configurations.some((c: { name: string }) => c.name.includes("Launch Chrome"))).toBe(true);
168
+ });
169
+ });
170
+
171
+ describe("GITIGNORE", () => {
172
+ it("ignores node_modules, dist, coverage, and pem files", () => {
173
+ expect(GITIGNORE).toContain("node_modules/");
174
+ expect(GITIGNORE).toContain("dist/");
175
+ expect(GITIGNORE).toContain("coverage/");
176
+ expect(GITIGNORE).toContain(".pem");
177
+ });
178
+ });
179
+
180
+ describe("component test templates", () => {
181
+ it("SITE_META_TEST asserts head tags", () => {
182
+ expect(SITE_META_TEST).toContain("charset");
183
+ expect(SITE_META_TEST).toContain("styles.css");
184
+ });
185
+
186
+ it("SITE_HEADER_TEST asserts brand and links", () => {
187
+ expect(SITE_HEADER_TEST).toContain("data-bascik-prop-brand");
188
+ expect(SITE_HEADER_TEST).toContain("nav-toggle");
189
+ });
190
+
191
+ it("SITE_FOOTER_TEST asserts brand and year script", () => {
192
+ expect(SITE_FOOTER_TEST).toContain("getFullYear");
193
+ });
194
+
195
+ it("FEAT_CARD_TEST asserts slot zones", () => {
196
+ expect(FEAT_CARD_TEST).toContain('data-bascik-slot="header"');
197
+ });
198
+
199
+ it("MY_COUNTER_TEST asserts counter controls", () => {
200
+ expect(MY_COUNTER_TEST).toContain("btn-dec");
201
+ });
202
+ });
203
+
204
+ describe("STYLES_CSS", () => {
205
+ it("defines core CSS variables", () => {
206
+ expect(STYLES_CSS).toContain("--bg:");
207
+ expect(STYLES_CSS).toContain("--accent:");
208
+ expect(STYLES_CSS).toContain("--text:");
209
+ });
210
+
211
+ it("includes button, card, and form rules", () => {
212
+ expect(STYLES_CSS).toContain(".btn");
213
+ expect(STYLES_CSS).toContain(".card");
214
+ expect(STYLES_CSS).toContain(".form-group");
215
+ });
216
+ });
217
+
218
+ describe("SITE_META_HTML", () => {
219
+ it("contains charset and viewport meta tags", () => {
220
+ expect(SITE_META_HTML).toContain('charset="UTF-8"');
221
+ expect(SITE_META_HTML).toContain("viewport");
222
+ });
223
+
224
+ it("links the global stylesheet", () => {
225
+ expect(SITE_META_HTML).toContain("styles.css");
226
+ });
227
+ });
228
+
229
+ describe("SITE_HEADER_HTML", () => {
230
+ it("has a brand prop placeholder", () => {
231
+ expect(SITE_HEADER_HTML).toContain("data-bascik-prop-brand");
232
+ });
233
+
234
+ it("interpolates SITE_HEADER_CSS inside style block without escaping", () => {
235
+ expect(SITE_HEADER_HTML).toContain(".header {");
236
+ expect(SITE_HEADER_HTML).not.toContain("${SITE_HEADER_CSS}");
237
+ });
238
+
239
+ it("contains nav links to all three pages", () => {
240
+ expect(SITE_HEADER_HTML).toContain('href="/"');
241
+ expect(SITE_HEADER_HTML).toContain('href="/about"');
242
+ expect(SITE_HEADER_HTML).toContain('href="/contact"');
243
+ });
244
+
245
+ it("has a mobile toggle with aria-expanded", () => {
246
+ expect(SITE_HEADER_HTML).toContain("aria-expanded");
247
+ });
248
+
249
+ it("uses id-based DOM lookups (not class-based) in the script", () => {
250
+ expect(SITE_HEADER_HTML).toContain("getElementById");
251
+ expect(SITE_HEADER_HTML).not.toContain("querySelector(");
252
+ });
253
+ });
254
+
255
+ describe("SITE_HEADER_CSS", () => {
256
+ it("makes the header sticky", () => {
257
+ expect(SITE_HEADER_CSS).toContain("position: sticky");
258
+ });
259
+
260
+ it("has responsive styles", () => {
261
+ expect(SITE_HEADER_CSS).toContain("@media");
262
+ });
263
+ });
264
+
265
+ describe("SITE_FOOTER_HTML", () => {
266
+ it("has a brand prop placeholder", () => {
267
+ expect(SITE_FOOTER_HTML).toContain("data-bascik-prop-brand");
268
+ });
269
+
270
+ it("interpolates SITE_FOOTER_CSS inside style block without escaping", () => {
271
+ expect(SITE_FOOTER_HTML).toContain(".footer {");
272
+ expect(SITE_FOOTER_HTML).not.toContain("${SITE_FOOTER_CSS}");
273
+ });
274
+
275
+ it("uses a build-time script for the year", () => {
276
+ expect(SITE_FOOTER_HTML).toContain("data-bascik-build");
277
+ expect(SITE_FOOTER_HTML).toContain("getFullYear");
278
+ });
279
+ });
280
+
281
+ describe("SITE_FOOTER_CSS", () => {
282
+ it("has footer layout rules", () => {
283
+ expect(SITE_FOOTER_CSS).toContain(".footer");
284
+ expect(SITE_FOOTER_CSS).toContain(".footer-inner");
285
+ });
286
+ });
287
+
288
+ describe("MY_COUNTER_HTML", () => {
289
+ it("interpolates MY_COUNTER_CSS inside style block without escaping", () => {
290
+ expect(MY_COUNTER_HTML).toContain(".counter {");
291
+ expect(MY_COUNTER_HTML).not.toContain("${MY_COUNTER_CSS}");
292
+ });
293
+
294
+ it("uses id-based DOM lookups for instance safety", () => {
295
+ expect(MY_COUNTER_HTML).toContain("getElementById");
296
+ expect(MY_COUNTER_HTML).not.toContain("querySelector(");
297
+ });
298
+
299
+ it("has increment and decrement buttons", () => {
300
+ expect(MY_COUNTER_HTML).toContain("btn-dec");
301
+ expect(MY_COUNTER_HTML).toContain("btn-inc");
302
+ });
303
+
304
+ it("wraps the script in an IIFE", () => {
305
+ expect(MY_COUNTER_HTML).toMatch(/\(function\s*\(\)/);
306
+ });
307
+ });
308
+
309
+ describe("FEAT_CARD_HTML", () => {
310
+ it("interpolates FEAT_CARD_CSS inside style block without escaping", () => {
311
+ expect(FEAT_CARD_HTML).toContain(".fcard {");
312
+ expect(FEAT_CARD_HTML).not.toContain("${FEAT_CARD_CSS}");
313
+ });
314
+
315
+ it("defines three slot zones: header, default, and footer", () => {
316
+ expect(FEAT_CARD_HTML).toContain('data-bascik-slot="header"');
317
+ expect(FEAT_CARD_HTML).toContain("data-bascik-slot>");
318
+ expect(FEAT_CARD_HTML).toContain('data-bascik-slot="footer"');
319
+ });
320
+ });
321
+
322
+ describe("FEAT_CARD_CSS", () => {
323
+ it("styles all three card zones", () => {
324
+ expect(FEAT_CARD_CSS).toContain(".fcard-header");
325
+ expect(FEAT_CARD_CSS).toContain(".fcard-body");
326
+ expect(FEAT_CARD_CSS).toContain(".fcard-footer");
327
+ });
328
+
329
+ it("has hover lift effect", () => {
330
+ expect(FEAT_CARD_CSS).toContain("translateY");
331
+ });
332
+ });
333
+
334
+ describe("MY_COUNTER_CSS", () => {
335
+ it("uses the mono CSS variable for the count display", () => {
336
+ expect(MY_COUNTER_CSS).toContain("var(--mono)");
337
+ });
338
+ });
339
+
340
+ describe("page templates", () => {
341
+ it("indexPage uses feat-card with named slots for the features section", () => {
342
+ const html = indexPage("My App");
343
+ expect(html).toContain("<feat-card>");
344
+ expect(html).toContain('data-bascik-slot="header"');
345
+ expect(html).toContain('data-bascik-slot="footer"');
346
+ });
347
+
348
+ it("indexPage contains hero, features, and counter demo", () => {
349
+ const html = indexPage("My App");
350
+ expect(html).toContain("hero");
351
+ expect(html).toContain("<my-counter />");
352
+ expect(html).toContain("<site-header");
353
+ expect(html).toContain("<site-footer");
354
+ expect(html).toContain("<site-meta />");
355
+ });
356
+
357
+ it("aboutPage contains two feature cards", () => {
358
+ const html = aboutPage("My App");
359
+ expect(html).toContain("<site-header");
360
+ expect(html).toContain("card");
361
+ expect(html).toContain("/contact");
362
+ });
363
+
364
+ it("contactPage contains a form with name, email, and message fields", () => {
365
+ const html = contactPage("My App");
366
+ expect(html).toContain('<input type="text"');
367
+ expect(html).toContain('<input type="email"');
368
+ expect(html).toContain("<textarea");
369
+ });
370
+
371
+ it("notFoundPage contains 404 and a home link", () => {
372
+ const html = notFoundPage("My App");
373
+ expect(html).toContain("404");
374
+ expect(html).toContain('href="/"');
375
+ });
376
+
377
+ it("all pages inject brand name into header and footer props", () => {
378
+ for (const fn of [indexPage, aboutPage, contactPage, notFoundPage]) {
379
+ const html = fn("cool-site");
380
+ expect(html).toContain('data-bascik-prop-brand="cool-site"');
381
+ }
382
+ });
383
+
384
+ it("all pages use site-meta head component", () => {
385
+ for (const fn of [indexPage, aboutPage, contactPage, notFoundPage]) {
386
+ expect(fn("x")).toContain("<site-meta />");
387
+ }
388
+ });
389
+ });
390
+
391
+ // ─── scaffold ─────────────────────────────────────────────────────────────────
392
+
393
+ describe("scaffold", () => {
394
+ beforeEach(() => vi.clearAllMocks());
395
+
396
+ it("creates all component, page, and e2e directories", async () => {
397
+ await scaffold("my-app", "/tmp");
398
+ const dirs = mockMkdir.mock.calls.map((c) => String(c[0]));
399
+ expect(dirs.some((d) => d.includes(".github/skills/bascik"))).toBe(true);
400
+ expect(dirs.some((d) => d.includes(".claude/skills/bascik"))).toBe(true);
401
+ expect(dirs.some((d) => d.endsWith("e2e"))).toBe(true);
402
+ expect(dirs.some((d) => d.includes("src/pages/assets"))).toBe(true);
403
+ expect(dirs.some((d) => d.includes("src/pages/css"))).toBe(true);
404
+ expect(dirs.some((d) => d.includes("site-header"))).toBe(true);
405
+ expect(dirs.some((d) => d.includes("site-footer"))).toBe(true);
406
+ expect(dirs.some((d) => d.includes("my-counter"))).toBe(true);
407
+ expect(dirs.some((d) => d.includes("site-meta"))).toBe(true);
408
+ });
409
+
410
+ it("writes all 28 expected files", async () => {
411
+ await scaffold("my-app", "/tmp");
412
+ expect(mockWriteFile.mock.calls.length).toBe(28);
413
+ });
414
+
415
+ it("writes E2E config and spec files", async () => {
416
+ await scaffold("my-app", "/tmp");
417
+ const paths = allWrittenPaths();
418
+ expect(paths.some((p) => p.endsWith("e2e/playwright.config.ts"))).toBe(true);
419
+ expect(paths.some((p) => p.endsWith("e2e/app.spec.ts"))).toBe(true);
420
+ });
421
+
422
+ it("writes SKILL.md into .github/skills/bascik and .claude/skills/bascik", async () => {
423
+ await scaffold("my-app", "/tmp");
424
+ const paths = allWrittenPaths().filter((p) => p.endsWith("SKILL.md"));
425
+ expect(paths.some((p) => p.includes(".github/skills/bascik"))).toBe(true);
426
+ expect(paths.some((p) => p.includes(".claude/skills/bascik"))).toBe(true);
427
+ });
428
+
429
+ it("writes root config files", async () => {
430
+ await scaffold("my-app", "/tmp");
431
+ expect(writtenTo("package.json")).toBeDefined();
432
+ expect(writtenTo("bascik.config.ts")).toBeUndefined();
433
+ expect(writtenTo("vite.config.js")).toBeDefined();
434
+ expect(writtenTo(".gitignore")).toBeDefined();
435
+ expect(writtenTo(".hintrc")).toBeUndefined();
436
+ expect(writtenTo(".vscode/launch.json")).toBeDefined();
437
+ const extensionsJson = writtenTo(".vscode/extensions.json");
438
+ expect(extensionsJson).toBeDefined();
439
+ expect(JSON.parse(extensionsJson!).recommendations).toContain("bascik.bascik-vscode");
440
+ });
441
+
442
+ it("writes all four pages", async () => {
443
+ await scaffold("my-app", "/tmp");
444
+ const paths = allWrittenPaths();
445
+ expect(paths.some((p) => p.endsWith("index.html"))).toBe(true);
446
+ expect(paths.some((p) => p.endsWith("about.html"))).toBe(true);
447
+ expect(paths.some((p) => p.endsWith("contact.html"))).toBe(true);
448
+ expect(paths.some((p) => p.endsWith("404.html"))).toBe(true);
449
+ });
450
+
451
+ it("writes all component files and tests", async () => {
452
+ await scaffold("my-app", "/tmp");
453
+ const paths = allWrittenPaths();
454
+ expect(paths.some((p) => p.includes("site-meta"))).toBe(true);
455
+ expect(paths.some((p) => p.includes("site-header"))).toBe(true);
456
+ expect(paths.some((p) => p.includes("site-footer"))).toBe(true);
457
+ expect(paths.some((p) => p.includes("feat-card"))).toBe(true);
458
+ expect(paths.some((p) => p.includes("my-counter"))).toBe(true);
459
+ expect(paths.some((p) => p.endsWith(".css"))).toBe(true);
460
+ expect(paths.some((p) => p.endsWith(".test.ts"))).toBe(true);
461
+ });
462
+
463
+ it("writes styles.css", async () => {
464
+ await scaffold("my-app", "/tmp");
465
+ expect(writtenTo("styles.css")).toBeDefined();
466
+ });
467
+
468
+ it("writes favicon assets", async () => {
469
+ await scaffold("my-app", "/tmp");
470
+ expect(allWrittenPaths().some((p) => p.endsWith("favicon.ico"))).toBe(true);
471
+ expect(allWrittenPaths().some((p) => p.endsWith("favicon-32x32.png"))).toBe(true);
472
+ expect(allWrittenPaths().some((p) => p.endsWith("apple-touch-icon.png"))).toBe(true);
473
+ expect(allWrittenPaths().some((p) => p.endsWith("favicon.svg"))).toBe(true);
474
+ });
475
+
476
+ it("scopes everything under targetDir/projectName", async () => {
477
+ await scaffold("cool-site", "/projects");
478
+ expect(allWrittenPaths().every((p) => p.startsWith("/projects/cool-site"))).toBe(true);
479
+ });
480
+
481
+ it("uses cwd as default targetDir", async () => {
482
+ await scaffold("app");
483
+ expect(allWrittenPaths().every((p) => p.includes("app"))).toBe(true);
484
+ });
485
+
486
+ it("injects the project name into the index page", async () => {
487
+ await scaffold("hello-world", "/tmp");
488
+ const indexCall = mockWriteFile.mock.calls.find((c) =>
489
+ String(c[0]).endsWith("index.html"),
490
+ );
491
+ expect(indexCall?.[1]).toContain("hello-world");
492
+ });
493
+ });
494
+