Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src/BloomBrowserUI/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,27 @@
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"dev": "node ./scripts/dev.mjs",
"// Watching: yarn dev starts vite + file watchers (LESS, pug, static assets, and key content folders)": " ",
"// COMMENTS: make the action a space rather than empty string so `yarn run` can list the scripts": " ",
"test": "vitest run",
"test:watch": "vitest",
"test:ci": "vitest run",
"check-that-node-modules-exists-in-content-dir": "cd ../content && node checkForNodeModules.js && cd ../BloomBrowserUI",
"// 'build:ui': 'builds all the stuff handled directly by vite'": " ",
"build:ui": "vite build",
"// 'build': 'builds all the core stuff devs need in both this folder and content. Does not clean.'": " ",
"build": "npm-run-all --parallel build:ui build:content",
"build:ui": "vite build --logLevel error",
"// 'build': 'builds all the core stuff devs need in both this folder and content. Cleans first.'": " ",
"build": "node scripts/build.js",
"build:clean": "node scripts/clean.js",
"// 'build-prod': 'production build: clean, then build+content in parallel, then l10n'": " ",
"build-prod": "npm run build:clean && npm-run-all --parallel build:ui build:content && npm-run-all --parallel build:l10n:translate build:l10n:create",
"build:pug": "node ./scripts/compilePug.mjs",
"// 'build-prod': 'production build: clean, pageSizes, then build:ui and build:content, then l10n'": " ",
"build-prod": "yarn build:clean && yarn --cwd ../content build:pageSizes && npm-run-all build:ui build:content && npm-run-all --parallel build:l10n:translate build:l10n:create",
"// 'build:l10n': creates/updates xliff files and translates html files.": " ",
"// 'build:l10n': is needed when markdown/html content changes or when testing l10n.": " ",
"// 'build:l10n': should be run after build. (build-prod includes this functionality.)": " ",
"build:l10n": "node scripts/l10n-build.js",
"build:l10n:translate": "node scripts/l10n-build.js translate",
"build:l10n:create": "node scripts/l10n-build.js create",
"build:content": "npm run check-that-node-modules-exists-in-content-dir && cd ../content && npm run build",
"// We shouldn't need'watchBookEditLess' anymore once we finish getting vite dev working": " ",
"watchBookEditLess": "less-watch-compiler bookEdit ../../output/browser/bookEdit",
"build:content": "yarn run check-that-node-modules-exists-in-content-dir && yarn --cwd ../content build",
"// 'watch': rebuilds bundles when source files change (for entrypoints not yet working with vite dev)": " ",
"watch": "vite build --watch",
"// You can use yarn link to symlink bloom-player. But also, bloom needs a copy in output/!": " ",
Expand Down Expand Up @@ -233,11 +231,13 @@
"less": "^3.13.1",
"less-watch-compiler": "^1.13.0",
"lessc-glob": "^1.0.9",
"chokidar": "^3.6.0",
"lint-staged": "^15.4.3",
"lorem-ipsum": "^2.0.2",
"markdown-it-attrs": "^4.3.1",
"markdown-it-container": "^4.0.0",
"npm-run-all": "^4.1.5",
"onchange": "^7.1.0",
"patch-package": "^6.4.7",
"path": "^0.12.7",
"playwright": "^1.56.1",
Expand Down
88 changes: 88 additions & 0 deletions src/BloomBrowserUI/scripts/__tests__/compilePug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import pug from "pug";
import { compilePugFiles } from "../compilePug.mjs";

let tempDir: string;
let browserUIRoot: string;
let contentRoot: string;
let outputBase: string;

function makeDir(dirPath: string) {
fs.mkdirSync(dirPath, { recursive: true });
}

function writeFile(filePath: string, contents: string) {
makeDir(path.dirname(filePath));
fs.writeFileSync(filePath, contents);
}

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "compile-pug-"));
browserUIRoot = path.join(tempDir, "browserUI");
contentRoot = path.join(tempDir, "content");
outputBase = path.join(tempDir, "out");
makeDir(browserUIRoot);
makeDir(contentRoot);
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

describe("compilePugFiles", () => {
it("recompiles dependents when an included pug file changes", async () => {
const partialPath = path.join(browserUIRoot, "partials", "partial.pug");
const mainPath = path.join(browserUIRoot, "pages", "main.pug");

writeFile(partialPath, "p Partial A\n");

writeFile(
mainPath,
[
"doctype html",
"html",
" body",
" include ../partials/partial.pug",
"",
].join("\n"),
);

await compilePugFiles({ browserUIRoot, contentRoot, outputBase });

const outPath = path.join(outputBase, "pages", "main.html");
expect(fs.existsSync(outPath)).toBe(true);
const firstHtml = fs.readFileSync(outPath, "utf8");
expect(firstHtml).toContain("Partial A");

await new Promise((resolve) => setTimeout(resolve, 30));
writeFile(partialPath, "p Partial B\n");

await compilePugFiles({ browserUIRoot, contentRoot, outputBase });
const secondHtml = fs.readFileSync(outPath, "utf8");
expect(secondHtml).toContain("Partial B");
expect(secondHtml).not.toContain("Partial A");
});

it("skips expensive compilation when files are up to date", async () => {
const mainPath = path.join(browserUIRoot, "pages", "main.pug");
writeFile(mainPath, "p Hello\n");

await compilePugFiles({ browserUIRoot, contentRoot, outputBase });

const compileSpy = vi.spyOn(pug, "compileFile");
const result = await compilePugFiles({
browserUIRoot,
contentRoot,
outputBase,
});

expect(compileSpy).not.toHaveBeenCalled();
expect(result.compiled).toBe(0);
expect(result.skipped).toBeGreaterThan(0);

compileSpy.mockRestore();
});
});
54 changes: 54 additions & 0 deletions src/BloomBrowserUI/scripts/__tests__/copyStaticFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { copyStaticFile } from "../copyStaticFile.mjs";

let tempDir: string;
let browserUIRoot: string;
let outputBase: string;

function makeDir(dirPath: string) {
fs.mkdirSync(dirPath, { recursive: true });
}

function writeFile(filePath: string, contents: string) {
makeDir(path.dirname(filePath));
fs.writeFileSync(filePath, contents);
}

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copy-static-"));
browserUIRoot = path.join(tempDir, "browserUI");
outputBase = path.join(tempDir, "out");
makeDir(browserUIRoot);
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

describe("copyStaticFile", () => {
it("ignores tsconfig.json files in nested folders", () => {
const nestedTsConfig = path.join(
browserUIRoot,
"sub",
"folder",
"tsconfig.json",
);
writeFile(nestedTsConfig, '{"compilerOptions":{}}\n');

const copied = copyStaticFile(nestedTsConfig, {
browserUIRoot,
outputBase,
quiet: true,
});

expect(copied).toBe(false);
expect(
fs.existsSync(
path.join(outputBase, "sub", "folder", "tsconfig.json"),
),
).toBe(false);
});
});
188 changes: 188 additions & 0 deletions src/BloomBrowserUI/scripts/__tests__/watchLess.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import fs from "fs";
import os from "os";
import path from "path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { LessWatchManager } from "../watchLess.mjs";

const silentLogger = {
log: () => {},
warn: () => {},
error: () => {},
};

let tempDir;
let sourceRoot;
let outputRoot;
let metadataPath;

function makeDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}

function writeFile(filePath, contents) {
makeDir(path.dirname(filePath));
fs.writeFileSync(filePath, contents);
}

function makeManager(overrides = {}) {
return new LessWatchManager({
repoRoot: tempDir,
metadataPath,
logger: silentLogger,
targets: [
{
name: "test",
root: sourceRoot,
outputBase: outputRoot,
},
],
...overrides,
});
}

function getEntryId(manager, filePath) {
const key = path
.relative(manager.repoRoot, path.resolve(filePath))
.replace(/\\/g, "/");
return key;
}

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "watch-less-"));
sourceRoot = path.join(tempDir, "src");
outputRoot = path.join(tempDir, "out");
metadataPath = path.join(outputRoot, ".state.json");
makeDir(sourceRoot);
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

describe("LessWatchManager", () => {
it("compiles missing outputs and records metadata", async () => {
const entryPath = path.join(sourceRoot, "pages", "main.less");
const partialPath = path.join(sourceRoot, "partials", "colors.less");
writeFile(partialPath, "@primary: #ff0000;\n");
writeFile(
entryPath,
'@import "../partials/colors.less";\nbody { color: @primary; }\n',
);

const manager = makeManager();
await manager.initialize();

const cssPath = path.join(outputRoot, "pages", "main.css");
expect(fs.existsSync(cssPath)).toBe(true);
const css = fs.readFileSync(cssPath, "utf8");
expect(css).toContain("body");
expect(fs.existsSync(`${cssPath}.map`)).toBe(true);

const state = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
const entryId = getEntryId(manager, entryPath);
expect(state.entries[entryId]).toContain(
path.relative(tempDir, partialPath).replace(/\\/g, "/"),
);
});

it("rebuilds when dependency is newer on startup", async () => {
const entryPath = path.join(sourceRoot, "main.less");
const partialPath = path.join(sourceRoot, "dep.less");
writeFile(partialPath, "@val: blue;\n");
writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n');

const firstManager = makeManager();
await firstManager.initialize();
const cssPath = path.join(outputRoot, "main.css");
const initialMTime = fs.statSync(cssPath).mtimeMs;

await new Promise((resolve) => setTimeout(resolve, 30));
writeFile(partialPath, "@val: green;\n");

const secondManager = makeManager();
await secondManager.initialize();
const rebuiltMTime = fs.statSync(cssPath).mtimeMs;
expect(rebuiltMTime).toBeGreaterThan(initialMTime);
});

it("updates dependency graph when imports change", async () => {
const entryPath = path.join(sourceRoot, "main.less");
const depPath = path.join(sourceRoot, "dep.less");
writeFile(depPath, "@val: blue;\n");
writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n');

const manager = makeManager();
await manager.initialize();
const entryId = getEntryId(manager, entryPath);
const cssPath = path.join(outputRoot, "main.css");
const baselineMTime = fs.statSync(cssPath).mtimeMs;

await new Promise((resolve) => setTimeout(resolve, 30));
writeFile(entryPath, "body { color: black; }\n");
await manager.handleFileChanged(entryPath, "entry updated");
const deps = manager.entryDependencies.get(entryId) ?? [];
expect(deps.length).toBe(1);

await new Promise((resolve) => setTimeout(resolve, 30));
writeFile(depPath, "@val: red;\n");
await manager.handleFileChanged(depPath, "dep changed");
const afterMTime = fs.statSync(cssPath).mtimeMs;
expect(afterMTime).toBe(baselineMTime);
});

it("adds new dependencies and rebuilds when partial changes", async () => {
const entryPath = path.join(sourceRoot, "main.less");
const depA = path.join(sourceRoot, "depA.less");
const depB = path.join(sourceRoot, "depB.less");
writeFile(depA, "@val: blue;\n");
writeFile(depB, "@alt: red;\n");
writeFile(entryPath, '@import "depA.less";\nbody { color: @val; }\n');

const manager = makeManager();
await manager.initialize();
const cssPath = path.join(outputRoot, "main.css");

await new Promise((resolve) => setTimeout(resolve, 30));
writeFile(
entryPath,
'@import "depA.less";\n@import "depB.less";\nbody { color: @alt; }\n',
);
await manager.handleFileChanged(entryPath, "entry changed");
const entryId = getEntryId(manager, entryPath);
const deps = manager.entryDependencies.get(entryId) ?? [];
expect(deps.some((dep) => dep.endsWith("depB.less"))).toBe(true);

await new Promise((resolve) => setTimeout(resolve, 30));
writeFile(depB, "@alt: purple;\n");
const before = fs.statSync(cssPath).mtimeMs;
await manager.handleFileChanged(depB, "depB updated");
const after = fs.statSync(cssPath).mtimeMs;
expect(after).toBeGreaterThan(before);
});

it("removes outputs when an entry is deleted", async () => {
const entryPath = path.join(sourceRoot, "main.less");
writeFile(entryPath, "body { color: blue; }\n");
const manager = makeManager();
await manager.initialize();
const cssPath = path.join(outputRoot, "main.css");
expect(fs.existsSync(cssPath)).toBe(true);

fs.unlinkSync(entryPath);
await manager.handleFileRemoved(entryPath);
expect(fs.existsSync(cssPath)).toBe(false);
expect(fs.existsSync(`${cssPath}.map`)).toBe(false);
});

it("builds new entries on the fly", async () => {
const manager = makeManager();
await manager.initialize();

const entryPath = path.join(sourceRoot, "new.less");
writeFile(entryPath, "body { color: orange; }\n");
await manager.handleFileAdded(manager.targets[0], entryPath);

const cssPath = path.join(outputRoot, "new.css");
expect(fs.existsSync(cssPath)).toBe(true);
});
});
Loading