From cc4b0e254097b8846987dec539510c865e162b9a Mon Sep 17 00:00:00 2001 From: David Richardson Date: Tue, 1 Sep 2026 20:03:32 -0600 Subject: [PATCH 1/7] Jakefile Narwhal => Node migration script, initial effort --- migrate_jakefiles.py | 319 ++++++++++++++++++ .../CollectionView.j => update-jake.py | 0 2 files changed, 319 insertions(+) create mode 100755 migrate_jakefiles.py rename Tests/Manual/CopyAndPaste/CollectionView.j => update-jake.py (100%) diff --git a/migrate_jakefiles.py b/migrate_jakefiles.py new file mode 100755 index 000000000..91db9042a --- /dev/null +++ b/migrate_jakefiles.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +""" +Migrate Cappuccino integration-test Jakefiles under Tests/Manual from the +Narwhal-hosted format to the Node-hosted format. + +Each Jakefile is updated in place: the leading file-metadata comment block +at the top of the file is left untouched, and everything after it is fully +replaced with the non-metadata content of JakefileNew (the template), +substituted with per-app values. + +Six lines are copied from the legacy file, each tested for existence +independently -- any that aren't present fall back to the template's own +literal default: + + task.setProductName("capp-modernize"); + task.setIdentifier("com.yourcompany.cappModernize"); + task.setVersion("1.0"); + task.setAuthor("Your Company"); + task.setEmail("feedback @nospam@ yourcompany.com"); + task.setSummary("capp-modernize"); + +Nothing else is extracted -- sources/resources/index/info-plist paths, +compiler flags, OBJJ_INCLUDE_PATHS, app-size tracking, task list, etc. all +come from the template unchanged. + +The internal `projectName` variable (used in the app(...) call and all +build-path joins) is not one of the six copied fields -- it's taken from +the containing directory name, since it must be unique per app and the +old file's app(...) call is not reliably a string literal. + +This is a full technical-debt pass: every Jakefile under the given root +is updated, including ones already hand-updated to Node idioms. Before +any file is overwritten, the original is copied to JakefileBackup in the +same directory. + +Some test application directories have no Jakefile at all. These are +detected by the presence of Info.plist or index.html with no sibling +Jakefile, and are reported and skipped rather than guessed at. + +Usage: + python3 migrate_jakefiles.py [root] [--apply] + +Default is a dry run: prints what would happen, writes nothing. +--apply performs the migration. +""" + +import argparse +import re +import shutil +import sys +from pathlib import Path +from string import Template + +HEADER_RE = re.compile(r'\A\s*/\*.*?\*/\s*', re.DOTALL) + +FIELD_PATTERNS = { + "product_name": r'task\.setProductName\(\s*"([^"]*)"\s*\)', + "identifier": r'task\.setIdentifier\(\s*"([^"]*)"\s*\)', + "version": r'task\.setVersion\(\s*"([^"]*)"\s*\)', + "author": r'task\.setAuthor\(\s*"([^"]*)"\s*\)', + "email": r'task\.setEmail\(\s*"([^"]*)"\s*\)', + "summary": r'task\.setSummary\(\s*"([^"]*)"\s*\)', +} + +# Literal defaults, taken verbatim from JakefileNew -- used only when the +# corresponding line is absent from the legacy file. +DEFAULTS = { + "product_name": "capp-modernize", + "identifier": "com.yourcompany.cappModernize", + "version": "1.0", + "author": "Your Company", + "email": "feedback @nospam@ yourcompany.com", + "summary": "capp-modernize", +} + +# JakefileNew's non-metadata content, verbatim, with $-style placeholders +# for project_name (directory-derived) and the six copied fields. +BODY_TEMPLATE = Template('''const path = require("path"); +const fs = require("fs"); + +var ENV = process.env, + task = JAKE.task, + FileList = JAKE.FileList, + app = CAPPUCCINO.Jake.applicationtask.app, + configuration = ENV["CONFIG"] || ENV["CONFIGURATION"] || ENV["c"] || "Debug", + OS = require("os"), + projectName = "$project_name"; + +var buildDir = path.resolve(ENV["BUILD_PATH"] || ENV["CAPP_BUILD"] || "Build"); + +app (projectName, function(task) +{ + ENV["OBJJ_INCLUDE_PATHS"] = "Frameworks"; + + if (configuration === "Debug") + ENV["OBJJ_INCLUDE_PATHS"] = path.join(ENV["OBJJ_INCLUDE_PATHS"], configuration); + + task.setBuildIntermediatesPath(path.join(buildDir, projectName + ".build", configuration)); + task.setBuildPath(path.join(buildDir, configuration)); + + task.setProductName("$product_name"); + task.setIdentifier("$identifier"); + task.setVersion("$version"); + task.setAuthor("$author"); + task.setEmail("$email"); + task.setSummary("$summary"); + task.setSources(new FileList("**/*.j").exclude(path.join("Build", "**")).exclude(path.join("Frameworks", "Source", "**"))); + task.setResources(new FileList("Resources/**")); + task.setIndexFilePath("index.html"); + task.setInfoPlistPath("Info.plist"); + + if (configuration === "Debug") + task.setCompilerFlags("-DDEBUG -g -S --inline-msg-send"); + else + task.setCompilerFlags("-O2"); +}); + +task ("default", [projectName], function() +{ + printResults(configuration); +}); + +task ("build", ["default"], function() +{ + updateApplicationSize(); +}); + +task ("debug", function() +{ + configuration = ENV["CONFIGURATION"] = "Debug"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("release", function() +{ + configuration = ENV["CONFIGURATION"] = "Release"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("run", ["debug"], function() +{ + OS.system(["open", path.join(buildDir, "Debug", projectName, "index.html")]); +}); + +task ("run-release", ["release"], function() +{ + OS.system(["open", path.join(buildDir, "Release", projectName, "index.html")]); +}); + +task ("deploy", ["release"], function() +{ + FILE.mkdirs(path.join(buildDir, "Deployment", projectName)); + OS.system(["press", "-f", path.join(buildDir, "Release", projectName), path.join(buildDir, "Deployment", projectName)]); + printResults("Deployment") +}); + +function printResults(configuration) +{ + console.log("----------------------------"); + console.log(configuration+" app built at path: " + path.join(buildDir, configuration, projectName)); + console.log("----------------------------"); +} + +function updateApplicationSize() +{ + console.log("Calculating application file sizes..."); + + var contents = fs.readFileSync(path.join(buildDir, configuration, projectName, "Info.plist"), { encoding: "utf8" }), + format = CFPropertyList.sniffedFormatOfString(contents), + plist = CFPropertyList.propertyListFromString(contents), + totalBytes = {executable:0, data:0, mhtml:0}; + + // Get the size of all framework executables and sprite data + var frameworksDir = "Frameworks"; + + if (configuration === "Debug") + frameworksDir = path.join(frameworksDir, "Debug"); + + var frameworks = []; + + if (fs.existsSync(frameworksDir)) { + frameworks = fs.readdirSync(frameworksDir); + } + + frameworks.forEach(function(framework) + { + if (framework !== "Source") + addBundleFileSizes(path.join(frameworksDir, framework), totalBytes); + }); + + // Read in the default theme name, and attempt to get its size + var themeName = plist.valueForKey("CPDefaultTheme") || "Aristo2", + themePath = nil; + + if (themeName === "Aristo" || themeName === "Aristo2") + themePath = path.join(frameworksDir, "AppKit", "Resources", themeName + ".blend"); + else + themePath = path.join("Frameworks", "Resources", themeName + ".blend"); + + if (fs.existsSync(themePath) && fs.lstatSync(themePath).isDirectory()) + addBundleFileSizes(themePath, totalBytes); + + // Add sizes for the app + addBundleFileSizes(path.join(buildDir, configuration, projectName), totalBytes); + + console.log("Executables: " + totalBytes.executable + ", sprite data: " + totalBytes.data + ", total: " + (totalBytes.executable + totalBytes.data)); + + var dict = new CFMutableDictionary(); + + dict.setValueForKey("executable", totalBytes.executable); + dict.setValueForKey("data", totalBytes.data); + dict.setValueForKey("mhtml", totalBytes.mhtml); + + plist.setValueForKey("CPApplicationSize", dict); + fs.writeFileSync(path.join(buildDir, configuration, projectName, "Info.plist"), CFPropertyList.stringFromPropertyList(plist, format), { encoding: "utf8" }); +} + +function addBundleFileSizes(bundlePath, totalBytes) +{ + var bundleName = path.basename(bundlePath), + environment = bundleName === "Foundation" ? "Objj" : "Browser", + bundlePath = path.join(bundlePath, environment + ".environment"); + + if (fs.existsSync(bundlePath) && fs.lstatSync(bundlePath).isDirectory()) + { + var filename = bundleName + ".sj", + filePath = path.join(bundlePath, filename); + + if (fs.existsSync(filePath)) { + totalBytes.executable += fs.lstatSync(filePath).size; + } + + filePath = path.join(bundlePath, "dataURLs.txt"); + + if (fs.existsSync(filePath)) + totalBytes.data += fs.lstatSync(filePath).size; + + filePath = path.join(bundlePath, "MHTMLData.txt"); + + if (fs.existsSync(filePath)) + totalBytes.mhtml += fs.lstatSync(filePath).size; + + filePath = path.join(bundlePath, "MHTMLPaths.txt"); + + if (fs.existsSync(filePath)) + totalBytes.mhtml += fs.lstatSync(filePath).size; + } +} +''') + + +def migrate_file(jakefile_path, apply_changes): + text = jakefile_path.read_text() + project_name = jakefile_path.parent.name + + header_match = HEADER_RE.match(text) + header = header_match.group(0).rstrip("\n") if header_match else "" + + fields = {"project_name": project_name} + for name, pattern in FIELD_PATTERNS.items(): + m = re.search(pattern, text) + fields[name] = m.group(1) if m else DEFAULTS[name] + + body = BODY_TEMPLATE.substitute(fields) + new_content = (header + "\n\n" if header else "") + body + + if apply_changes: + shutil.copy2(jakefile_path, jakefile_path.with_name("JakefileBackup")) + jakefile_path.write_text(new_content) + + +def find_missing_jakefile_dirs(root, jakefile_dirs): + """Directories that look like app roots (contain Info.plist or + index.html) but have no Jakefile of their own.""" + candidates = set() + for marker in ("Info.plist", "index.html"): + for p in root.rglob(marker): + candidates.add(p.parent) + return sorted(candidates - jakefile_dirs) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("root", nargs="?", default="Tests/Manual", + help="root directory to search (default: Tests/Manual)") + parser.add_argument("--apply", action="store_true", help="write changes (default is dry run)") + args = parser.parse_args() + + root = Path(args.root) + if not root.is_dir(): + sys.exit(f"error: {root} is not a directory") + + jakefiles = sorted(root.rglob("Jakefile")) + jakefile_dirs = {jf.parent for jf in jakefiles} + missing_dirs = find_missing_jakefile_dirs(root, jakefile_dirs) + + if not jakefiles and not missing_dirs: + sys.exit(f"no Jakefiles or app directories found under {root}") + + migrated_count = 0 + for jf in jakefiles: + migrate_file(jf, args.apply) + migrated_count += 1 + + for d in missing_dirs: + print(f"[ no Jakefile] {d}: skipped -- no Jakefile present") + + verb = "migrated" if args.apply else "would migrate" + print() + print(f"Summary: {migrated_count} {verb}, {len(missing_dirs)} no Jakefile") + + if not args.apply: + print("\nDry run only -- re-run with --apply to write changes.") + + +if __name__ == "__main__": + main() diff --git a/Tests/Manual/CopyAndPaste/CollectionView.j b/update-jake.py similarity index 100% rename from Tests/Manual/CopyAndPaste/CollectionView.j rename to update-jake.py From 1a9ff9ffb788c297f2ba8918ee006ec61b6ad9c7 Mon Sep 17 00:00:00 2001 From: David Richardson Date: Wed, 2 Sep 2026 18:32:11 -0600 Subject: [PATCH 2/7] Provision framework dependencies for manual integration tests Previously, the Tests/Manual integration suite lacked an automated mechanism to provision the required Frameworks directories, leaving test applications unexecutable or reliant on stale, manually copied dependencies. This commit introduces a Jake task to automatically provision a shared, symlinked Frameworks cache for all manual test applications, ensuring dependencies remain synchronized with the current framework build. Additionally, it adds documentation detailing the architecture and execution of the manual test suite. --- .gitignore | 1 + Tests/Manual/Jakefile | 153 +++++++++++++++++++++++++++++++++++++++++ Tests/Manual/README.md | 34 +++++++++ 3 files changed, 188 insertions(+) create mode 100644 Tests/Manual/Jakefile create mode 100644 Tests/Manual/README.md diff --git a/.gitignore b/.gitignore index 36beffcd1..f84d28caf 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ node_modules /dist/cappuccino/package.json /dist/cappuccino/lib /dist/cappuccino/bin +Tests/Manual/.Frameworks diff --git a/Tests/Manual/Jakefile b/Tests/Manual/Jakefile new file mode 100644 index 000000000..065bc5011 --- /dev/null +++ b/Tests/Manual/Jakefile @@ -0,0 +1,153 @@ +/* + * Jakefile + * Tests/Manual + * + * Provisions ./Frameworks for every manual integration test app. + * See FRAMEWORKS-PROVISIONING-PLAN.md for the reasoning behind this design. + * + * Scope: this file only manages Frameworks symlinks. It does not build, + * run, or modify any individual test app. + */ + +const path = require("path"); +const fs = require("fs"); + +var ENV = process.env, + task = JAKE.task; + +// Single source of truth for the merge: every app links to this, and +// this links into dist. A dist rebuild is then visible everywhere with +// no re-provisioning. +var SHARED_FRAMEWORKS = ".Frameworks"; + +// Left side: name under Tests/Manual/.Frameworks (and .../Debug). +// Right side: path under dist, relative to Tests/Manual. +var RELEASE_MEMBERS = { + "AppKit": path.join("..", "..", "dist", "cappuccino", "Frameworks", "AppKit"), + "BlendKit": path.join("..", "..", "dist", "cappuccino", "Frameworks", "BlendKit"), + "Foundation": path.join("..", "..", "dist", "cappuccino", "Frameworks", "Foundation"), + "Objective-J": path.join("..", "..", "dist", "objective-j", "Frameworks", "Objective-J") +}; + +var DEBUG_MEMBERS = { + "AppKit": path.join("..", "..", "dist", "cappuccino", "Frameworks", "Debug", "AppKit"), + "BlendKit": path.join("..", "..", "dist", "cappuccino", "Frameworks", "Debug", "BlendKit"), + "Foundation": path.join("..", "..", "dist", "cappuccino", "Frameworks", "Debug", "Foundation"), + "Objective-J": path.join("..", "..", "dist", "objective-j", "Frameworks", "Debug", "Objective-J") +}; + +// lstat, not stat -- a broken symlink must be seen as "exists, is a +// symlink", not thrown away as ENOENT. +function lstatOrNull(aPath) +{ + try + { + return fs.lstatSync(aPath); + } + catch (e) + { + return null; + } +} + +// Creates or repairs a single symlink. Safe to call every time: skips +// when the link is already correct, so a repeat `configure` costs one +// lstat and one readlink per member when nothing changed. +function ensureSymlink(linkPath, targetPath) +{ + var desiredRelative = path.relative(path.dirname(linkPath), targetPath); + var info = lstatOrNull(linkPath); + + if (info) + { + if (info.isSymbolicLink() && fs.readlinkSync(linkPath) === desiredRelative) + return; + + if (info.isSymbolicLink()) + fs.unlinkSync(linkPath); + else + { + console.log("configure: replacing non-symlink at " + linkPath); + fs.rmSync(linkPath, { recursive: true, force: true }); + } + } + + fs.symlinkSync(desiredRelative, linkPath, "dir"); +} + +function buildSharedFrameworks() +{ + fs.mkdirSync(path.join(SHARED_FRAMEWORKS, "Debug"), { recursive: true }); + + Object.keys(RELEASE_MEMBERS).forEach(function(name) + { + ensureSymlink(path.join(SHARED_FRAMEWORKS, name), RELEASE_MEMBERS[name]); + }); + + Object.keys(DEBUG_MEMBERS).forEach(function(name) + { + ensureSymlink(path.join(SHARED_FRAMEWORKS, "Debug", name), DEBUG_MEMBERS[name]); + }); +} + +// An app directory is anything under Tests/Manual with Info.plist or +// index.html -- the same test migrate_jakefiles.py already uses to +// find app roots. Excludes the shared cache and any dotfile entries. +function listAppDirs() +{ + return fs.readdirSync(".", { withFileTypes: true }) + .filter(function(entry) + { + if (!entry.isDirectory()) + return false; + if (entry.name.charAt(0) === ".") + return false; + + return fs.existsSync(path.join(entry.name, "Info.plist")) || + fs.existsSync(path.join(entry.name, "index.html")); + }) + .map(function(entry) { return entry.name; }); +} + +function linkAllApps() +{ + listAppDirs().forEach(function(appDir) + { + ensureSymlink(path.join(appDir, "Frameworks"), SHARED_FRAMEWORKS); + }); +} + +task ("refresh-dist", function() +{ + JAKE.subjake(["../.."], "dist", ENV); +}); + +task ("configure", ["refresh-dist"], function() +{ + buildSharedFrameworks(); + linkAllApps(); +}); + +task ("clean", function() +{ + listAppDirs().forEach(function(appDir) + { + var linkPath = path.join(appDir, "Frameworks"); + var info = lstatOrNull(linkPath); + + if (!info) + return; + + if (info.isSymbolicLink()) + fs.unlinkSync(linkPath); + else + console.log("clean: skipping non-symlink at " + linkPath); + }); +}); + +task ("clobber", ["clean"], function() +{ + fs.rmSync(SHARED_FRAMEWORKS, { recursive: true, force: true }); +}); + +task ("default", ["configure"]); diff --git a/Tests/Manual/README.md b/Tests/Manual/README.md new file mode 100644 index 000000000..6034817a7 --- /dev/null +++ b/Tests/Manual/README.md @@ -0,0 +1,34 @@ +# Manual Integration Tests + +This directory contains the manual integration test suite for Cappuccino. +Each subdirectory represents a distinct test application. +## Integration Test Definition +Unlike automated, isolated unit tests located in Tests/AppKit, Tests/Foundation, and Tests/Objective-J, the tests in this directory validate the framework as a complete, running application. +A test consists of a minimal Cappuccino application (AppController.j, main.j, Info.plist, index.html) executing in a real browser, exercising the compiled runtime, rendering pipeline, and target classes together. +These tests require manual verification. There are no automated assertions; a human reviewer must open the application, interact with it, and visually verify its behavior. +This suite is designed for human review of specific features, not yet unattended CI. +## Execution +Open /index.html (release) or /index-debug.html (debug, unminified) directly in a web browser. +No intermediate build step is required to load the framework and application sources, provided the /Frameworks directory exists. +To provision the Frameworks directory for every application in the suite, execute `jake configure` within Tests/Manual once initially, and after any framework modifications. The available tasks are: +- `configure:` (Default): Depends on refresh-dist. Builds a shared, merged .Frameworks cache once, then provisions every test application with a single symlink pointing to it. +- `refresh-dist`: Rebuilds the dist packages at the repository root (../../dist). +- `clean`: Removes the Frameworks symlink from each application directory without affecting actual directories. Skips any unmanaged, non-symlink directories. +- `clobber`: Executes clean, then removes the shared .Frameworks cache. + +Many applications contain legacy Jakefiles predating the current Node-hosted toolchain. +These use incompatible idioms, will not execute correctly via per-application jake build or jake run commands, and are not required to open the tests directly in a browser. Migration of these Jakefiles is pending. + +## Framework Provisioning +The HTML entry points load Frameworks/Objective-J/Objective-J.js (or the debug equivalent) via a literal relative path. +Without this directory present, the application will not load. +### Shared Cache Architecture +Cappuccino's framework build is distributed across two packages (dist/cappuccino, dist/objective-j) and two target levels (release, debug). Every application requires the same merged subset of eight items. +Utilizing a shared cache (.Frameworks) with per-application symlinks provides three structural advantages over copying or linking individual framework components directly into dist: +- **Centralized Configuration**: Modifying the required framework set requires only a single edit, which immediately updates all applications. Direct linking requires restating the set for every application. +- **Atomic Cleanup**: The clean operation targets a single symlink per application. Managing a per-application directory requires sweeping the directory and distinguishing managed components from unmanaged files. +- **Scalability**: The operational cost scales with the application count, rather than the product of application count and member count. Direct linking repeats eight operations per application on every configure execution, whereas the shared cache performs the operations once globally. This ensures configure remains fast enough to run reflexively during iterative development. + +### Portability +The Frameworks symlink is ephemeral, ignored by git, and valid exclusively within the Tests/Manual tree. +To relocate a test, regenerate the Frameworks directory at the destination using `capp gen --frameworks appName` (or `--frameworks --symlink appName`), utilizing the binary located at dist/cappuccino/bin/capp. From a2181c88d752ff6102e8cfc63e3362fb0dc1a4da Mon Sep 17 00:00:00 2001 From: David Richardson Date: Thu, 3 Sep 2026 09:24:53 -0600 Subject: [PATCH 3/7] Improve migrate-jakefiles.py script Move to ./Tests/Manual Hard-code root path to ./ Exclude sibling Jakefile --- .../Manual/migrate-jakefiles.py | 43 +++++++++++-------- update-jake.py | 0 2 files changed, 25 insertions(+), 18 deletions(-) rename migrate_jakefiles.py => Tests/Manual/migrate-jakefiles.py (89%) mode change 100755 => 100644 delete mode 100644 update-jake.py diff --git a/migrate_jakefiles.py b/Tests/Manual/migrate-jakefiles.py old mode 100755 new mode 100644 similarity index 89% rename from migrate_jakefiles.py rename to Tests/Manual/migrate-jakefiles.py index 91db9042a..9e5853d4b --- a/migrate_jakefiles.py +++ b/Tests/Manual/migrate-jakefiles.py @@ -38,7 +38,7 @@ detected by the presence of Info.plist or index.html with no sibling Jakefile, and are reported and skipped rather than guessed at. Usage: - python3 migrate_jakefiles.py [root] [--apply] + python3 migrate-jakefiles.py [--apply] Default is a dry run: prints what would happen, writes nothing. --apply performs the migration. @@ -84,7 +84,8 @@ var ENV = process.env, app = CAPPUCCINO.Jake.applicationtask.app, configuration = ENV["CONFIG"] || ENV["CONFIGURATION"] || ENV["c"] || "Debug", OS = require("os"), - projectName = "$project_name"; + projectName = "$project_name", + productName = "$product_name"; var buildDir = path.resolve(ENV["BUILD_PATH"] || ENV["CAPP_BUILD"] || "Build"); @@ -98,7 +99,7 @@ app (projectName, function(task) task.setBuildIntermediatesPath(path.join(buildDir, projectName + ".build", configuration)); task.setBuildPath(path.join(buildDir, configuration)); - task.setProductName("$product_name"); + task.setProductName(productName); task.setIdentifier("$identifier"); task.setVersion("$version"); task.setAuthor("$author"); @@ -139,25 +140,25 @@ task ("release", function() task ("run", ["debug"], function() { - OS.system(["open", path.join(buildDir, "Debug", projectName, "index.html")]); + OS.system(["open", path.join(buildDir, "Debug", productName, "index.html")]); }); task ("run-release", ["release"], function() { - OS.system(["open", path.join(buildDir, "Release", projectName, "index.html")]); + OS.system(["open", path.join(buildDir, "Release", productName, "index.html")]); }); task ("deploy", ["release"], function() { - FILE.mkdirs(path.join(buildDir, "Deployment", projectName)); - OS.system(["press", "-f", path.join(buildDir, "Release", projectName), path.join(buildDir, "Deployment", projectName)]); + FILE.mkdirs(path.join(buildDir, "Deployment", productName)); + OS.system(["press", "-f", path.join(buildDir, "Release", productName), path.join(buildDir, "Deployment", productName)]); printResults("Deployment") }); function printResults(configuration) { console.log("----------------------------"); - console.log(configuration+" app built at path: " + path.join(buildDir, configuration, projectName)); + console.log(configuration+" app built at path: " + path.join(buildDir, configuration, productName)); console.log("----------------------------"); } @@ -165,7 +166,7 @@ function updateApplicationSize() { console.log("Calculating application file sizes..."); - var contents = fs.readFileSync(path.join(buildDir, configuration, projectName, "Info.plist"), { encoding: "utf8" }), + var contents = fs.readFileSync(path.join(buildDir, configuration, productName, "Info.plist"), { encoding: "utf8" }), format = CFPropertyList.sniffedFormatOfString(contents), plist = CFPropertyList.propertyListFromString(contents), totalBytes = {executable:0, data:0, mhtml:0}; @@ -201,7 +202,7 @@ function updateApplicationSize() addBundleFileSizes(themePath, totalBytes); // Add sizes for the app - addBundleFileSizes(path.join(buildDir, configuration, projectName), totalBytes); + addBundleFileSizes(path.join(buildDir, configuration, productName), totalBytes); console.log("Executables: " + totalBytes.executable + ", sprite data: " + totalBytes.data + ", total: " + (totalBytes.executable + totalBytes.data)); @@ -212,7 +213,7 @@ function updateApplicationSize() dict.setValueForKey("mhtml", totalBytes.mhtml); plist.setValueForKey("CPApplicationSize", dict); - fs.writeFileSync(path.join(buildDir, configuration, projectName, "Info.plist"), CFPropertyList.stringFromPropertyList(plist, format), { encoding: "utf8" }); + fs.writeFileSync(path.join(buildDir, configuration, productName, "Info.plist"), CFPropertyList.stringFromPropertyList(plist, format), { encoding: "utf8" }); } function addBundleFileSizes(bundlePath, totalBytes) @@ -265,7 +266,9 @@ def migrate_file(jakefile_path, apply_changes): new_content = (header + "\n\n" if header else "") + body if apply_changes: - shutil.copy2(jakefile_path, jakefile_path.with_name("JakefileBackup")) + backup_path = jakefile_path.with_name("JakefileBackup") + if not backup_path.exists(): + shutil.copy2(jakefile_path, backup_path) jakefile_path.write_text(new_content) @@ -275,6 +278,8 @@ def find_missing_jakefile_dirs(root, jakefile_dirs): candidates = set() for marker in ("Info.plist", "index.html"): for p in root.rglob(marker): + if any(part in ("Frameworks", ".Frameworks") for part in p.parts): + continue candidates.add(p.parent) return sorted(candidates - jakefile_dirs) @@ -283,16 +288,18 @@ def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) - parser.add_argument("root", nargs="?", default="Tests/Manual", - help="root directory to search (default: Tests/Manual)") parser.add_argument("--apply", action="store_true", help="write changes (default is dry run)") args = parser.parse_args() - root = Path(args.root) - if not root.is_dir(): - sys.exit(f"error: {root} is not a directory") + root = Path(".") - jakefiles = sorted(root.rglob("Jakefile")) + manual_jakefile = (root / "Jakefile").resolve() + + jakefiles = sorted( + p for p in root.rglob("Jakefile") + if not any(part in ("Frameworks", ".Frameworks") for part in p.parts) + and p.resolve() != manual_jakefile + ) jakefile_dirs = {jf.parent for jf in jakefiles} missing_dirs = find_missing_jakefile_dirs(root, jakefile_dirs) diff --git a/update-jake.py b/update-jake.py deleted file mode 100644 index e69de29bb..000000000 From ab8e47fd01d1fcd86392e6082bc07c3ce2c96374 Mon Sep 17 00:00:00 2001 From: David Richardson Date: Thu, 3 Sep 2026 09:45:37 -0600 Subject: [PATCH 4/7] Scripts to generate and serve index HTML file for all tests Node.js, requires no additional interpreter or dependencies. Generates a single index.html file at root which displays launch links for all tests. --- .gitignore | 1 + Tests/Manual/generate-index.js | 140 +++++++++++++++++++++++++++++++++ Tests/Manual/serve.js | 114 +++++++++++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100755 Tests/Manual/generate-index.js create mode 100755 Tests/Manual/serve.js diff --git a/.gitignore b/.gitignore index f84d28caf..ea63775cc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ node_modules /dist/cappuccino/lib /dist/cappuccino/bin Tests/Manual/.Frameworks +/Tests/Manual/index.html diff --git a/Tests/Manual/generate-index.js b/Tests/Manual/generate-index.js new file mode 100755 index 000000000..2a32ee138 --- /dev/null +++ b/Tests/Manual/generate-index.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/* + * generate-index.js + * Tests/Manual + * + * Created by David Richardson, September 7, 2026 + * Copyright 2026, David Richardson. All rights reserved. + * + * Writes ./index.html: links every manual test app's source entry + * points, plus its Build/Debug and Build/Release product entry + * points when they exist. + * + * No npm dependencies -- Node core modules only. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +const fs = require("fs"); +const path = require("path"); + +const ROOT = __dirname; + +// Same detection rule as Tests/Manual/Jakefile: an app directory has +// Info.plist or index.html, and is never Frameworks/.Frameworks. +function listAppDirs() +{ + return fs.readdirSync(ROOT, { withFileTypes: true }) + .filter(function(entry) + { + if (!entry.isDirectory()) + return false; + if (entry.name.charAt(0) === ".") + return false; + if (entry.name === "Frameworks") + return false; + + var dir = path.join(ROOT, entry.name); + return fs.existsSync(path.join(dir, "Info.plist")) || + fs.existsSync(path.join(dir, "index.html")); + }) + .map(function(entry) { return entry.name; }) + .sort(); +} + +// The built product directory is named after productName, not the app +// folder (confirmed: ArrayController1 -> Build/Debug/ArrayController). +// Find it by reading what's actually there, not by assuming a name. +function findBuiltProduct(appDir, configuration) +{ + var configDir = path.join(ROOT, appDir, "Build", configuration); + + if (!fs.existsSync(configDir)) + return null; + + var entries = fs.readdirSync(configDir, { withFileTypes: true }) + .filter(function(entry) { return entry.isDirectory(); }); + + for (var i = 0; i < entries.length; i++) + { + var candidate = path.join(configDir, entries[i].name, "index.html"); + + if (fs.existsSync(candidate)) + return path.join(appDir, "Build", configuration, entries[i].name, "index.html"); + } + + return null; +} + +function escapeHtml(s) +{ + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function link(href, label) +{ + return "" + escapeHtml(label) + ""; +} + +function buildIndexHtml() +{ + var appDirs = listAppDirs(); + + var rows = appDirs.map(function(appDir) + { + var links = []; + + if (fs.existsSync(path.join(ROOT, appDir, "index.html"))) + links.push(link(path.join(appDir, "index.html"), "source")); + + if (fs.existsSync(path.join(ROOT, appDir, "index-debug.html"))) + links.push(link(path.join(appDir, "index-debug.html"), "source debug")); + + var debugProduct = findBuiltProduct(appDir, "Debug"); + if (debugProduct) + links.push(link(debugProduct, "built debug")); + + var releaseProduct = findBuiltProduct(appDir, "Release"); + if (releaseProduct) + links.push(link(releaseProduct, "built release")); + + return "" + escapeHtml(appDir) + "" + links.join("   ") + ""; + }); + + return "\n" + + "Manual Tests\n" + + "\n" + + "

Manual Integration Tests

\n" + + "

" + appDirs.length + " applications. Generated by generate-index.js.

\n" + + "\n" + rows.join("\n") + "\n
\n" + + "\n"; +} + +function generateIndex() +{ + var html = buildIndexHtml(); + fs.writeFileSync(path.join(ROOT, "index.html"), html); + return html; +} + +module.exports = { generateIndex: generateIndex }; + +if (require.main === module) +{ + generateIndex(); + console.log("Wrote " + path.join(ROOT, "index.html")); +} diff --git a/Tests/Manual/serve.js b/Tests/Manual/serve.js new file mode 100755 index 000000000..a9990b7a1 --- /dev/null +++ b/Tests/Manual/serve.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/* + * serve.js + * Tests/Manual + * + * Regenerates index.html, then serves this directory over plain HTTP, + * so tests run in any browser -- not only Safari via file://. + * + * No npm dependencies -- Node core modules only. Ctrl-C to stop. + * + * Writes ./index.html: links every manual test app's source entry + * points, plus its Build/Debug and Build/Release product entry + * points when they exist. + * + * No npm dependencies -- Node core modules only. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const { exec } = require("child_process"); +const { generateIndex } = require("./generate-index.js"); + +const ROOT = __dirname; +const PORT = 8347; + +const MIME_TYPES = { + ".html": "text/html", + ".htm": "text/html", + ".js": "application/javascript", + ".sj": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".txt": "text/plain", + ".plist": "text/plain" +}; + +function resolveRequestPath(url) +{ + var decoded = decodeURIComponent(url.split("?")[0]); + var resolved = path.normalize(path.join(ROOT, decoded)); + + // Reject traversal outside ROOT. + if (resolved !== ROOT && !resolved.startsWith(ROOT + path.sep)) + return null; + + return resolved; +} + +function serveFile(filePath, res) +{ + fs.readFile(filePath, function(err, data) + { + if (err) + { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("404 Not Found: " + filePath); + return; + } + + var type = MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": type }); + res.end(data); + }); +} + +const server = http.createServer(function(req, res) +{ + var filePath = resolveRequestPath(req.url); + + if (!filePath) + { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("403 Forbidden"); + return; + } + + fs.stat(filePath, function(err, stats) + { + if (!err && stats.isDirectory()) + filePath = path.join(filePath, "index.html"); + + serveFile(filePath, res); + }); +}); + +generateIndex(); + +server.listen(PORT, function() +{ + var url = "http://localhost:" + PORT + "/index.html"; + console.log("Serving " + ROOT + " at " + url); + console.log("Ctrl-C to stop."); + exec("open " + url); +}); From 967760934018e5b2301e5bb8c6acda73684eab18 Mon Sep 17 00:00:00 2001 From: David Richardson Date: Thu, 3 Sep 2026 10:47:29 -0600 Subject: [PATCH 5/7] Improve index.html presentation Add logotype as self-contained data URI Improve fonts for legibility Improve table styling --- Tests/Manual/generate-index.js | 119 +++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 36 deletions(-) diff --git a/Tests/Manual/generate-index.js b/Tests/Manual/generate-index.js index 2a32ee138..c61e04a00 100755 --- a/Tests/Manual/generate-index.js +++ b/Tests/Manual/generate-index.js @@ -32,31 +32,29 @@ const path = require("path"); const ROOT = __dirname; -// Same detection rule as Tests/Manual/Jakefile: an app directory has -// Info.plist or index.html, and is never Frameworks/.Frameworks. +// Cappuccino logotype embedded as a Base64 PNG Data URL +const CAPPUCCINO_LOGO_DATA_URL = "data:image/png;base64,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"; + function listAppDirs() { return fs.readdirSync(ROOT, { withFileTypes: true }) - .filter(function(entry) - { - if (!entry.isDirectory()) - return false; - if (entry.name.charAt(0) === ".") - return false; - if (entry.name === "Frameworks") - return false; + .filter(function(entry) + { + if (!entry.isDirectory()) + return false; + if (entry.name.charAt(0) === ".") + return false; + if (entry.name === "Frameworks") + return false; - var dir = path.join(ROOT, entry.name); - return fs.existsSync(path.join(dir, "Info.plist")) || - fs.existsSync(path.join(dir, "index.html")); - }) - .map(function(entry) { return entry.name; }) - .sort(); + var dir = path.join(ROOT, entry.name); + return fs.existsSync(path.join(dir, "Info.plist")) || + fs.existsSync(path.join(dir, "index.html")); + }) + .map(function(entry) { return entry.name; }) + .sort(); } -// The built product directory is named after productName, not the app -// folder (confirmed: ArrayController1 -> Build/Debug/ArrayController). -// Find it by reading what's actually there, not by assuming a name. function findBuiltProduct(appDir, configuration) { var configDir = path.join(ROOT, appDir, "Build", configuration); @@ -65,7 +63,7 @@ function findBuiltProduct(appDir, configuration) return null; var entries = fs.readdirSync(configDir, { withFileTypes: true }) - .filter(function(entry) { return entry.isDirectory(); }); + .filter(function(entry) { return entry.isDirectory(); }); for (var i = 0; i < entries.length; i++) { @@ -83,9 +81,9 @@ function escapeHtml(s) return s.replace(/&/g, "&").replace(//g, ">"); } -function link(href, label) +function link(href, label, badgeClass) { - return "" + escapeHtml(label) + ""; + return "" + escapeHtml(label) + ""; } function buildIndexHtml() @@ -93,35 +91,84 @@ function buildIndexHtml() var appDirs = listAppDirs(); var rows = appDirs.map(function(appDir) - { + { var links = []; if (fs.existsSync(path.join(ROOT, appDir, "index.html"))) - links.push(link(path.join(appDir, "index.html"), "source")); + links.push(link(path.join(appDir, "index.html"), "source", "badge-source")); if (fs.existsSync(path.join(ROOT, appDir, "index-debug.html"))) - links.push(link(path.join(appDir, "index-debug.html"), "source debug")); + links.push(link(path.join(appDir, "index-debug.html"), "source debug", "badge-source-debug")); var debugProduct = findBuiltProduct(appDir, "Debug"); if (debugProduct) - links.push(link(debugProduct, "built debug")); + links.push(link(debugProduct, "built debug", "badge-built-debug")); var releaseProduct = findBuiltProduct(appDir, "Release"); if (releaseProduct) - links.push(link(releaseProduct, "built release")); + links.push(link(releaseProduct, "built release", "badge-built-release")); - return "" + escapeHtml(appDir) + "" + links.join("   ") + ""; + return "" + escapeHtml(appDir) + "" + links.join("") + ""; }); + var css = ` + :root { --bg: #ffffff; --text: #333333; --border: #e1e4e8; --row-alt: #f6f8fa; --row-hover: #f0f3f6; } + body { font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; color: var(--text); max-width: 960px; margin: 0 auto; padding: 2rem; background: #fbfbfb; } + .container { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); } + .header { padding: 20px; border-bottom: 1px solid var(--border); } + .brand { display: flex; align-items: center; gap: 16px; margin-bottom: 12px; } + .brand img { height: 36px; width: auto; display: block; } + .brand h1 { margin: 0; font-size: 20px; font-weight: 600; line-height: 1; } + p { margin: 0; color: #586069; } + input[type="text"] { width: 100%; padding: 8px 12px; margin-top: 16px; border: 1px solid var(--border); border-radius: 4px; font-family: inherit; font-size: 14px; box-sizing: border-box; } + table { width: 100%; border-collapse: collapse; table-layout: fixed; } + th, td { padding: 12px 20px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); } + th { background: var(--bg); font-weight: 600; color: #586069; position: sticky; top: 0; z-index: 10; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } + th:first-child { width: 35%; } + tbody tr:nth-child(even) { background: var(--row-alt); } + tbody tr:hover { background: var(--row-hover); } + tbody tr:last-child td { border-bottom: none; } + .badge { display: inline-block; padding: 4px 10px; margin: 2px 6px 2px 0; border-radius: 2em; font-size: 12px; font-weight: 600; text-decoration: none; color: white; transition: opacity 0.2s; } + .badge:hover { opacity: 0.85; } + .badge-source { background-color: #6a737d; } + .badge-source-debug { background-color: #6f42c1; } + .badge-built-debug { background-color: #0366d6; } + .badge-built-release { background-color: #28a745; } + `; + + var js = ` + function filterTable() { + var filter = document.getElementById("search").value.toUpperCase(); + var rows = document.querySelectorAll("tbody tr"); + for (var i = 0; i < rows.length; i++) { + var cell = rows[i].getElementsByTagName("td")[0]; + if (cell) { + var txtValue = cell.textContent || cell.innerText; + rows[i].style.display = txtValue.toUpperCase().indexOf(filter) > -1 ? "" : "none"; + } + } + } + `; + return "\n" + - "Manual Tests\n" + - "\n" + - "

Manual Integration Tests

\n" + - "

" + appDirs.length + " applications. Generated by generate-index.js.

\n" + - "\n" + rows.join("\n") + "\n
\n" + - "\n"; + "\n\n\nManual Tests\n" + + "\n\n\n" + + "
\n" + + "
\n" + + "
\n" + + " \"Cappuccino\n" + + "

Manual Integration Tests

\n" + + "
\n" + + "

" + appDirs.length + " applications. Generated by generate-index.js.

\n" + + " \n" + + "
\n" + + " \n" + + " \n" + + " \n" + rows.join("\n") + "\n \n" + + "
ApplicationEntry Points
\n" + + "
\n" + + "\n" + + "\n\n"; } function generateIndex() From f5b305317f2e006b0b862784926fd552db9477dc Mon Sep 17 00:00:00 2001 From: David Richardson Date: Thu, 3 Sep 2026 11:26:10 -0600 Subject: [PATCH 6/7] Improve colour scheme Match navigation bar/masthead to https://cappuccino.dev Highlight table row on hover Increase visual distinction of test buttons using colour --- Tests/Manual/generate-index.js | 82 +++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/Tests/Manual/generate-index.js b/Tests/Manual/generate-index.js index c61e04a00..ba8b5cca9 100755 --- a/Tests/Manual/generate-index.js +++ b/Tests/Manual/generate-index.js @@ -115,12 +115,74 @@ function buildIndexHtml() :root { --bg: #ffffff; --text: #333333; --border: #e1e4e8; --row-alt: #f6f8fa; --row-hover: #f0f3f6; } body { font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; color: var(--text); max-width: 960px; margin: 0 auto; padding: 2rem; background: #fbfbfb; } .container { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); } - .header { padding: 20px; border-bottom: 1px solid var(--border); } - .brand { display: flex; align-items: center; gap: 16px; margin-bottom: 12px; } - .brand img { height: 36px; width: auto; display: block; } - .brand h1 { margin: 0; font-size: 20px; font-weight: 600; line-height: 1; } - p { margin: 0; color: #586069; } - input[type="text"] { width: 100%; padding: 8px 12px; margin-top: 16px; border: 1px solid var(--border); border-radius: 4px; font-family: inherit; font-size: 14px; box-sizing: border-box; } + .header { + background-color: #242a35; + background-image: repeating-linear-gradient( + -45deg, + rgba(255, 255, 255, 0.03), + rgba(255, 255, 255, 0.03) 1px, + transparent 1px, + transparent 4px + ); + color: #ffffff; + padding: 18px 24px; + border-bottom: 1px solid #1a1e27; + } + + .brand { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 10px; + } + + .brand img { + height: 38px; + width: auto; + display: block; + } + + .brand h1 { + margin: 0; + font-size: 19px; + font-weight: 600; + line-height: 1; + color: #ffffff; + letter-spacing: -0.01em; + } + + .header p { + margin: 0; + color: #9aa5b5; + font-size: 13px; + } + + input[type="text"] { + width: 100%; + padding: 9px 14px; + margin-top: 14px; + background-color: #171b22; + color: #e6edf3; + border: 1px solid #0d1015; + border-radius: 5px; + font-family: inherit; + font-size: 13px; + box-sizing: border-box; + outline: none; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5); + transition: border-color 0.15s ease, background-color 0.15s ease; + } + + input[type="text"]::placeholder { + color: #626e7f; + } + + input[type="text"]:focus { + background-color: #1c212a; + border-color: #3b4556; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.6), 0 0 0 1px #3b4556; + } + table { width: 100%; border-collapse: collapse; table-layout: fixed; } th, td { padding: 12px 20px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); } th { background: var(--bg); font-weight: 600; color: #586069; position: sticky; top: 0; z-index: 10; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } @@ -130,10 +192,10 @@ function buildIndexHtml() tbody tr:last-child td { border-bottom: none; } .badge { display: inline-block; padding: 4px 10px; margin: 2px 6px 2px 0; border-radius: 2em; font-size: 12px; font-weight: 600; text-decoration: none; color: white; transition: opacity 0.2s; } .badge:hover { opacity: 0.85; } - .badge-source { background-color: #6a737d; } - .badge-source-debug { background-color: #6f42c1; } - .badge-built-debug { background-color: #0366d6; } - .badge-built-release { background-color: #28a745; } + .badge-source { background-color: #2d7d46; } + .badge-source-debug { background-color: #0066cc; } + .badge-built-debug { background-color: #0066cc; } + .badge-built-release { background-color: #6a737d; } `; var js = ` From 17e5d326211ba73b3ed0f5a92469f575388c89a2 Mon Sep 17 00:00:00 2001 From: David Richardson Date: Thu, 3 Sep 2026 12:19:20 -0600 Subject: [PATCH 7/7] rename Jakefile migration script prefix with wip- to indicate it is not yet stable. --- Tests/Manual/{migrate-jakefiles.py => wip-migrate-jakefiles.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Tests/Manual/{migrate-jakefiles.py => wip-migrate-jakefiles.py} (100%) mode change 100644 => 100755 diff --git a/Tests/Manual/migrate-jakefiles.py b/Tests/Manual/wip-migrate-jakefiles.py old mode 100644 new mode 100755 similarity index 100% rename from Tests/Manual/migrate-jakefiles.py rename to Tests/Manual/wip-migrate-jakefiles.py