From 91f39a80a7ab78a9ec5dbef1e1fb7222dd910886 Mon Sep 17 00:00:00 2001 From: yourfriendoss Date: Mon, 20 Oct 2025 01:23:51 +0300 Subject: [PATCH] add all of the testing --- .forgejo/workflows/test.yaml | 17 +++ src/__tests__/index.test.ts | 66 +++++++++ src/plugins/__tests__/compile-time-js.test.ts | 24 ++++ src/plugins/__tests__/dev.test.ts | 7 + .../__tests__/image-optimization.test.ts | 87 ++++++++++++ .../__tests__/markdown-compiler.test.ts | 43 ++++++ .../__tests__/markdown-metadata.test.ts | 38 +++++ src/plugins/__tests__/postcss.test.ts | 50 +++++++ src/plugins/__tests__/ts-compiler.test.ts | 57 ++++++++ src/plugins/__tests__/variables.test.ts | 18 +++ src/plugins/compile-time-js.ts | 15 +- src/plugins/dev.ts | 132 +++++++++--------- 12 files changed, 485 insertions(+), 69 deletions(-) create mode 100644 .forgejo/workflows/test.yaml create mode 100644 src/__tests__/index.test.ts create mode 100644 src/plugins/__tests__/compile-time-js.test.ts create mode 100644 src/plugins/__tests__/dev.test.ts create mode 100644 src/plugins/__tests__/image-optimization.test.ts create mode 100644 src/plugins/__tests__/markdown-compiler.test.ts create mode 100644 src/plugins/__tests__/markdown-metadata.test.ts create mode 100644 src/plugins/__tests__/postcss.test.ts create mode 100644 src/plugins/__tests__/ts-compiler.test.ts create mode 100644 src/plugins/__tests__/variables.test.ts diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..2e4440d --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -0,0 +1,17 @@ +on: [push] +jobs: + build: + strategy: + matrix: + os: [windows-node-iron, node-16] + runs-on: ${{ matrix.os }} + steps: + - uses: http://github.com/actions/checkout@v4 + - uses: http://github.com/oven-sh/setup-bun@v2 + - run: bun install + shell: ${{ matrix.os == 'windows-node-iron' && 'powershell' || 'bash' }} + - run: bun run prod + shell: ${{ matrix.os == 'windows-node-iron' && 'powershell' || 'bash' }} + - name: run tests + run: bun test + shell: ${{ matrix.os == 'windows-node-iron' && 'powershell' || 'bash' }} diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts new file mode 100644 index 0000000..6b364fa --- /dev/null +++ b/src/__tests__/index.test.ts @@ -0,0 +1,66 @@ +import { expect, test, beforeAll, afterAll } from "bun:test"; +import SSSG, { Plugin } from "../index"; +import { mkdirSync, existsSync, rmSync } from "fs"; +import { before } from "node:test"; + +// Dummy plugin for basic testing +class DummyPlugin extends Plugin { + name = "dummy"; + rewriteTriggers = ["txt"]; + longLasting = false; + async rewriteFile(file: string) { + return file.toUpperCase(); + } + + async build() { + + } +} + +// Ensure "input" folder exists before tests, and clean up after +const INPUT_FOLDER = "input"; +const OUTPUT_FOLDER = "output"; + +beforeAll(() => { + if (!existsSync(INPUT_FOLDER)) { + mkdirSync(INPUT_FOLDER); + } + if (!existsSync(OUTPUT_FOLDER)) { + mkdirSync(OUTPUT_FOLDER); + } +}); + +afterAll(() => { + if (existsSync(INPUT_FOLDER)) { + rmSync(INPUT_FOLDER, { recursive: true, force: true }); + } + if (existsSync(OUTPUT_FOLDER)) { + rmSync(OUTPUT_FOLDER, { recursive: true, force: true }); + } +}); + +test("SSSG initializes with input/output folders", () => { + const sssg = new SSSG({ inputFolder: INPUT_FOLDER, outputFolder: OUTPUT_FOLDER }); + expect(sssg.inputFolder).toBe(INPUT_FOLDER); + expect(sssg.outputFolder).toBe(OUTPUT_FOLDER); +}); + +test("SSSG run sets plugins", async () => { + const sssg = new SSSG({ inputFolder: INPUT_FOLDER, outputFolder: OUTPUT_FOLDER }); + await sssg.run({ plugins: [new DummyPlugin()] }); + expect(sssg.plugins.length).toBe(1); + expect(sssg.plugins[0].name).toBe("dummy"); +}); + +// More advanced build tests would require mocking fs and path, which Bun supports via bun:mock or manual stubbing. +// For now, we check plugin build invocation. +test("Plugin build is called", async () => { + let called = false; + class BuildPlugin extends DummyPlugin { + async build() { called = true; } + } + const sssg = new SSSG({ inputFolder: INPUT_FOLDER, outputFolder: OUTPUT_FOLDER }); + await sssg.run({ plugins: [new BuildPlugin()] }); + await sssg.build(); + expect(called).toBe(true); +}); diff --git a/src/plugins/__tests__/compile-time-js.test.ts b/src/plugins/__tests__/compile-time-js.test.ts new file mode 100644 index 0000000..0b8d266 --- /dev/null +++ b/src/plugins/__tests__/compile-time-js.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import CompileTimeJS from "../compile-time-js"; + +test("CompileTimeJS evaluates JS expressions in curly braces", async () => { + const plugin = new CompileTimeJS(); + const input = "Hello {& 2+2 &} World"; + const output = await plugin.rewriteFile(input, "test.html"); + expect(output).toBe("Hello 4 World"); +}); + +test("CompileTimeJS handles multiple expressions", async () => { + const plugin = new CompileTimeJS(); + const input = "{& 1+1 &} and {& 3*3 &}"; + console.log(input) + const output = await plugin.rewriteFile(input, "test.html"); + expect(output).toBe("2 and 9"); +}); + +test("CompileTimeJS leaves input unchanged if no expressions", async () => { + const plugin = new CompileTimeJS(); + const input = "No expressions here."; + const output = await plugin.rewriteFile(input, "test.html"); + expect(output).toBe("No expressions here."); +}); diff --git a/src/plugins/__tests__/dev.test.ts b/src/plugins/__tests__/dev.test.ts new file mode 100644 index 0000000..a443ec3 --- /dev/null +++ b/src/plugins/__tests__/dev.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from "bun:test"; +import DevPlugin from "../dev"; +import SSSG from "../../index"; + + +// DevPlugin is too hard to test yet, this will take some time to finish. +// diff --git a/src/plugins/__tests__/image-optimization.test.ts b/src/plugins/__tests__/image-optimization.test.ts new file mode 100644 index 0000000..a508ccb --- /dev/null +++ b/src/plugins/__tests__/image-optimization.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import ImageOptimization from "../image-optimization"; + +// Minimal valid image buffers for each format +function loadSampleImage(ext: string): Buffer { + switch (ext) { + case "png": + // 1x1 transparent PNG + return Buffer.from([ + 0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A, + 0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52, + 0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, + 0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4, + 0x89,0x00,0x00,0x00,0x0A,0x49,0x44,0x41, + 0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00, + 0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00, + 0x00,0x00,0x00,0x49,0x45,0x4E,0x44,0xAE, + 0x42,0x60,0x82 + ]); + case "jpg": + case "jpeg": + // 1x1 JPEG (valid minimal JPEG header) + return Buffer.from([ + 0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0x01,0x01,0x01,0x00,0x48,0x00,0x48,0x00,0x00,0xff,0xdb,0x00,0x43,0x00,0x03,0x02,0x02,0x02,0x02,0x02,0x03,0x02,0x02,0x02,0x03,0x03,0x03,0x03,0x04,0x06,0x04,0x04,0x04,0x04,0x04,0x08,0x06,0x06,0x05,0x06,0x09,0x08,0x0a,0x0a,0x09,0x08,0x09,0x09,0x0a,0x0c,0x0f,0x0c,0x0a,0x0b,0x0e,0x0b,0x09,0x09,0x0d,0x11,0x0d,0x0e,0x0f,0x10,0x10,0x11,0x10,0x0a,0x0c,0x12,0x13,0x12,0x10,0x13,0x0f,0x10,0x10,0x10,0xff,0xc9,0x00,0x0b,0x08,0x00,0x01,0x00,0x01,0x01,0x01,0x11,0x00,0xff,0xcc,0x00,0x06,0x00,0x10,0x10,0x05,0xff,0xda,0x00,0x08,0x01,0x01,0x00,0x00,0x3f,0x00,0xd2,0xcf,0x20,0xff,0xd9 + ]); + case "webp": + // 1x1 WebP + return Buffer.from( + + [82,73,70,70,64,0,0,0,87,69,66,80,86,80,56,88,10,0,0,0,16,0,0,0,0,0,0,0,0,0,65,76,80,72,2,0,0,0,0,0,86,80,56,32,24,0,0,0,48,1,0,157,1,42,1,0,1,0,1,64,38,37,164,0,3,112,0,254,253,54,104,0] + +); + default: + throw new Error(`Unknown image extension: ${ext}`); + } +} + +test("ImageOptimization returns buffer for supported types", async () => { + const plugin = new ImageOptimization(); + const types = ["png", "jpg", "jpeg", "webp"]; + for (const ext of types) { + const buf = loadSampleImage(ext); + const result = await plugin.rewriteFile(buf, `test.${ext}`); + expect(result instanceof Buffer).toBe(true); + expect(result?.length).toBeGreaterThan(0); + } +}); + +test("ImageOptimization returns original buffer if optimized is larger", async () => { + const plugin = new ImageOptimization(); + // Simulate a case where optimized buffer is larger + const buf = Buffer.from([1, 2, 3, 4, 5]); + // Patch sharp to return a larger buffer + plugin.logging = false; + plugin.rewriteFile = async (file) => { + // Simulate optimization making buffer larger + const optimized = Buffer.concat([file, Buffer.from([6, 7, 8, 9, 10])]); + if (file.length < optimized.length) { + return file; + } + return optimized; + }; + const result = await plugin.rewriteFile(buf, "test.png"); + expect(result).toEqual(buf); +}); + +test("ImageOptimization logs when skipping optimization", async () => { + const plugin = new ImageOptimization(true); + let logged = false; + const origLog = console.log; + console.log = () => { logged = true; }; + // Simulate buffer larger after optimization + const buf = Buffer.from([1, 2, 3]); + plugin.rewriteFile = async (file) => { + const optimized = Buffer.concat([file, Buffer.from([4, 5, 6])]); + if (file.length < optimized.length) { + if (plugin.logging) { + console.log("skipped"); + } + return file; + } + return optimized; + }; + await plugin.rewriteFile(buf, "test.jpg"); + console.log = origLog; + expect(logged).toBe(true); +}); diff --git a/src/plugins/__tests__/markdown-compiler.test.ts b/src/plugins/__tests__/markdown-compiler.test.ts new file mode 100644 index 0000000..8d42222 --- /dev/null +++ b/src/plugins/__tests__/markdown-compiler.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import MarkdownCompiler from "../markdown-compiler"; + +// Mock marked and parseMetadata for isolated testing +const mockMarked = { + parse: (text: string) => `

${text}

`, +}; +const mockParseMetadata = (file: string) => { + if (file.startsWith("title=Hello\n=+\n")) { + return { title: "Hello" }; + } + return undefined; +}; + +// Patch dependencies +// @ts-expect-error +MarkdownCompiler.prototype["marked"] = mockMarked; +// @ts-expect-error +MarkdownCompiler.prototype["parseMetadata"] = mockParseMetadata; + +test("MarkdownCompiler compiles markdown to HTML", async () => { + const plugin = new MarkdownCompiler(); + const input = "# Hello World"; + // Simulate marked.parse + const output = await plugin.rewriteFile(input, "test.md"); + expect(typeof output).toBe("string"); + expect(output).toContain("Hello World"); +}); + +test("MarkdownCompiler uses metadata title if present", async () => { + const plugin = new MarkdownCompiler(); + const input = "title=Hello\n=+\n# Some Content"; + const output = await plugin.rewriteFile(input, "test.md"); + expect(output).toContain("Hello"); + expect(output).toContain("Some Content"); +}); + +test("MarkdownCompiler returns HTML for markdown without metadata", async () => { + const plugin = new MarkdownCompiler(); + const input = "# Just Markdown"; + const output = await plugin.rewriteFile(input, "test.md"); + expect(output).toContain("Just Markdown"); +}); diff --git a/src/plugins/__tests__/markdown-metadata.test.ts b/src/plugins/__tests__/markdown-metadata.test.ts new file mode 100644 index 0000000..496f6da --- /dev/null +++ b/src/plugins/__tests__/markdown-metadata.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import MarkdownMetadataGenerator, { parseMetadata } from "../markdown-metadata"; + +test("parseMetadata extracts metadata from markdown", () => { + const input = `title = My Title +author = John Doe +=== +# Content starts here +`; + const metadata = parseMetadata(input); + expect(metadata).toEqual({ title: "My Title", author: "John Doe" }); +}); + +test("parseMetadata returns undefined if no metadata block", () => { + const input = `# No metadata here +Just content.`; + const metadata = parseMetadata(input); + expect(metadata).toBeUndefined(); +}); + +test("MarkdownMetadataGenerator rewrites file to JSON metadata", async () => { + const input = `title = Hello World +description = Sample file +=== +# Markdown content +`; + const plugin = new MarkdownMetadataGenerator(); + const output = await plugin.rewriteFile(input, "test.md"); + expect(output).toBe(JSON.stringify({ title: "Hello World", description: "Sample file" })); +}); + +test("MarkdownMetadataGenerator returns undefined if no metadata", async () => { + const input = `# Just content +No metadata block.`; + const plugin = new MarkdownMetadataGenerator(); + const output = await plugin.rewriteFile(input, "test.md"); + expect(output).toBeUndefined(); +}); \ No newline at end of file diff --git a/src/plugins/__tests__/postcss.test.ts b/src/plugins/__tests__/postcss.test.ts new file mode 100644 index 0000000..de836e2 --- /dev/null +++ b/src/plugins/__tests__/postcss.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import PostCSS from "../postcss"; + +// Dummy PostCSS plugin that uppercases all CSS +function uppercasePlugin() { + return { + postcssPlugin: "uppercase", + Once(root: any) { + root.walkDecls((decl: any) => { + decl.value = decl.value.toUpperCase(); + decl.prop = decl.prop.toUpperCase(); + }); + } + }; +} +uppercasePlugin.postcss = true; + +test("PostCSS applies plugins to CSS", async () => { + const plugin = new PostCSS([uppercasePlugin()]); + const input = "body { color: red; }"; + const output = await plugin.rewriteFile(input); + expect(output).toContain("COLOR: RED;"); +}); + +test("PostCSS returns unchanged CSS if no plugins", async () => { + const plugin = new PostCSS([]); + const input = "body { color: blue; }"; + const output = await plugin.rewriteFile(input); + expect(output).toBe(input); +}); + +test("PostCSS handles multiple plugins", async () => { + // Plugin to replace 'red' with 'green' + function replaceRedPlugin() { + return { + postcssPlugin: "replace-red", + Once(root: any) { + root.walkDecls((decl: any) => { + decl.value = decl.value.replace(/red/g, "green"); + }); + } + }; + } + replaceRedPlugin.postcss = true; + + const plugin = new PostCSS([replaceRedPlugin(), uppercasePlugin()]); + const input = "body { color: red; }"; + const output = await plugin.rewriteFile(input); + expect(output).toContain("COLOR: GREEN;"); +}); diff --git a/src/plugins/__tests__/ts-compiler.test.ts b/src/plugins/__tests__/ts-compiler.test.ts new file mode 100644 index 0000000..a92c292 --- /dev/null +++ b/src/plugins/__tests__/ts-compiler.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import TSCompiler from "../ts-compiler"; + +// Mock esbuild for isolated testing +const mockBuild = async (options: any) => { + // Simulate successful build with dummy JS output + return { + errors: [], + outputFiles: [{ contents: Buffer.from("console.log('hello world');") }], + }; +}; + +// Patch esbuild in TSCompiler for testing +TSCompiler.prototype["esbuildOptions"] = {}; +TSCompiler.prototype["rewriteFile"] = async function (file: string, filePath: string) { + let result; + try { + result = await mockBuild({ + stdin: { + contents: file, + resolveDir: "", + sourcefile: filePath, + loader: "ts", + }, + write: false, + bundle: true, + minify: this.minify, + ...this.esbuildOptions, + }); + } catch (e) { + return; + } + if (result.errors.length != 0) { + return; + } else { + const output = result.outputFiles[0].contents; + return new TextDecoder().decode(output); + } +}; + +test("TSCompiler compiles TypeScript to JavaScript", async () => { + const plugin = new TSCompiler(false); + const input = "const x: number = 42;"; + const output = await plugin.rewriteFile(input, "test.ts"); + expect(output).toContain("console.log('hello world');"); +}); + +test("TSCompiler returns undefined on build error", async () => { + // Simulate error + TSCompiler.prototype["rewriteFile"] = async function () { + return undefined; + }; + const plugin = new TSCompiler(false); + const output = await plugin.rewriteFile("invalid code", "test.ts"); + //@ts-expect-error + expect(output).toBe(undefined); +}); diff --git a/src/plugins/__tests__/variables.test.ts b/src/plugins/__tests__/variables.test.ts new file mode 100644 index 0000000..48761dd --- /dev/null +++ b/src/plugins/__tests__/variables.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import Variables from "../variables"; + +test("Variables replaces all variables in file", async () => { + const plugin = new Variables(() => ({ "{{foo}}": "bar", "{{baz}}": "qux" })); + plugin.build(); + const input = "Hello {{foo}}, {{baz}}!"; + const output = await plugin.rewriteFile(input, "test.html"); + expect(output).toBe("Hello bar, qux!"); +}); + +test("Variables with no matches leaves file unchanged", async () => { + const plugin = new Variables(() => ({ "{{foo}}": "bar" })); + plugin.build(); + const input = "No variables here."; + const output = await plugin.rewriteFile(input, "test.html"); + expect(output).toBe("No variables here."); +}); \ No newline at end of file diff --git a/src/plugins/compile-time-js.ts b/src/plugins/compile-time-js.ts index e122613..42d4285 100644 --- a/src/plugins/compile-time-js.ts +++ b/src/plugins/compile-time-js.ts @@ -8,16 +8,23 @@ export default class CompileTimeJS extends Plugin { async rewriteFile(file: string, filePath: string): Promise { let input = file; - const regex = /{&(.+)&}/gms; + const regex = /\{\&([\s\S]*?)\&\}/gms; let m; - - while ((m = regex.exec(file)) !== null) { + while ((m = regex.exec(input)) !== null) { if (m.index === regex.lastIndex) { regex.lastIndex++; } - input = file.slice(0, m.index) + eval(m[1]) + file.slice(m.index + m[0].length); + const before = input.slice(0, m.index); + const expr = m[1]; + const after = input.slice(m.index + m[0].length); + + const result = eval(expr); // You can replace this with Function() for sandboxing + input = before + result + after; + + // Reset regex lastIndex since we changed the string + regex.lastIndex = 0; } return input; diff --git a/src/plugins/dev.ts b/src/plugins/dev.ts index dbecd1a..6e9adde 100644 --- a/src/plugins/dev.ts +++ b/src/plugins/dev.ts @@ -15,81 +15,83 @@ export default class DevPlugin extends Plugin { server!: Server; allConnections: ServerWebSocket[] = []; - constructor(sssg: SSSG, headers: Record) { + // please never set "startEverything" to false, it's meant to be used only in tests + constructor(sssg: SSSG, headers: Record, port = 8080, startEverything = true) { super(); - - fs.watch( - sssg.inputFolder, - { - recursive: true, - }, - async (e, f) => { - console.log("[dev] Noticed update in " + f + ", of type " + e + "."); - this.allConnections.forEach((z) => z.send("refresh")); - await sssg.build(); - } - ); - - this.server = Bun.serve({ - fetch(req, server) { - const success = server.upgrade(req); - if (success) { - return undefined; + if (startEverything) { + fs.watch( + sssg.inputFolder, + { + recursive: true, + }, + async (e, f) => { + console.log("[dev] Noticed update in " + f + ", of type " + e + "."); + this.allConnections.forEach((z) => z.send("refresh")); + await sssg.build(); } + ); - const url = new URL(req.url); + this.server = Bun.serve({ + fetch(req, server) { + const success = server.upgrade(req); + if (success) { + return undefined; + } - let cleanedPath = url.pathname; - if (cleanedPath == "/") cleanedPath = "/index.html"; - if (cleanedPath.endsWith("/")) cleanedPath = cleanedPath.slice(0, -1); + const url = new URL(req.url); - let fsPath = sssg.outputFolder + cleanedPath; + let cleanedPath = url.pathname; + if (cleanedPath == "/") cleanedPath = "/index.html"; + if (cleanedPath.endsWith("/")) cleanedPath = cleanedPath.slice(0, -1); - if (fsPath.match(/\.\.\//g) !== null) { - return undefined; - } - let rawFile; - try { - rawFile = fs.readFileSync(fsPath); - } catch { - return new Response("404 Not Found", { - status: 404, + let fsPath = sssg.outputFolder + cleanedPath; + + if (fsPath.match(/\.\.\//g) !== null) { + return undefined; + } + let rawFile; + try { + rawFile = fs.readFileSync(fsPath); + } catch { + return new Response("404 Not Found", { + status: 404, + }); + } + const type = fsPath.split(".").at(-1); + if (!type) return; + if (type == "html") { + + rawFile = rawFile.toString().replace( + "", + `` + ); + } + return new Response(rawFile, { + headers: { + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + "Content-Type": (mime.lookup(type) || "application/octet-stream") + "; charset=utf-8", + ...headers + }, }); - } - const type = fsPath.split(".").at(-1); - if (!type) return; - if (type == "html") { + }, + websocket: { + open: (ws) => { + ws.data = Math.random(); - rawFile = rawFile.toString().replace( - "", - `` - ); - } - return new Response(rawFile, { - headers: { - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0", - "Content-Type": (mime.lookup(type) || "application/octet-stream") + "; charset=utf-8", - ...headers + this.allConnections.push(ws); + }, + message(ws, message) { }, + close: (ws) => { + this.allConnections = this.allConnections.filter( + (z) => z.data != ws.data + ); }, - }); - }, - websocket: { - open: (ws) => { - ws.data = Math.random(); - - this.allConnections.push(ws); }, - message(ws, message) {}, - close: (ws) => { - this.allConnections = this.allConnections.filter( - (z) => z.data != ws.data - ); - }, - }, - port: 8080, - }); + port: port, + }); + } } async rewriteFile(file: string, filePath: string) {