Skip to content

Commit b5d0415

Browse files
authored
fix CI (#51)
* . * . * . * . * .
1 parent 1d3865f commit b5d0415

19 files changed

Lines changed: 745 additions & 180 deletions

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,17 @@ jobs:
3838
fetch-depth: 0
3939

4040
- name: Install required libraries
41-
run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libgraphene-1.0-dev libavif-dev binaryen
41+
run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libgraphene-1.0-dev libavif-dev
42+
43+
- name: Install binaryen (wasm-opt)
44+
run: |
45+
BINARYEN_VERSION=$(curl -s https://api.github.com/repos/WebAssembly/binaryen/releases/latest | jq -r .tag_name)
46+
curl -L "https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz" | tar xz
47+
sudo cp binaryen-${BINARYEN_VERSION}/bin/wasm-opt /usr/local/bin/
48+
wasm-opt --version
49+
50+
- name: Install terser
51+
run: npm install -g terser
4252

4353
- name: Cache Playwright dependencies
4454
id: cache-pw
@@ -64,6 +74,20 @@ jobs:
6474
- name: Test
6575
run: ./mill __.test
6676

77+
- name: Test
78+
run: ./mill plugin.integration.testForked
79+
80+
- name: Publish Test Report
81+
uses: mikepenz/action-junit-report@v5
82+
if: always()
83+
with:
84+
fail_on_failure: false
85+
include_passed: false
86+
detailed_summary: true
87+
annotate_only: true
88+
require_tests: false
89+
report_paths: 'out/**/test-report.xml'
90+
6791
publish:
6892
if: github.repository == 'Quafadas/live-server-scala-cli-js' && contains(github.ref, 'refs/tags/')
6993
needs: build

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ out
88
.bloop
99
.DS_Store
1010
.vscode/mcp.json
11+
.scalex
12+
.scalex/index.bin
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-03-26
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
## Context
2+
3+
Mill's `ScalaJSModule` exposes `scalaJSMinify: T[Boolean]` (defaults `true`). This flag is passed directly to the Scala.js linker's `minify` parameter, enabling linker-level size reduction during `fullLinkJS`. It is unrelated to terser.
4+
5+
The plugin provides two module traits with different output strategies:
6+
7+
- **`FileBasedContentHashScalaJSModule`** — always writes to disk. Has a `minified` task that post-processes `fullLinkJS` output through `terser`. When `scalaJSExperimentalUseWebAssembly = true`, the linker emits JS loader files alongside the `.wasm` binary; terser can break these loaders. Currently has no special WASM handling in `minified`.
8+
- **`InMemoryHashScalaJSModule`** — keeps linker output in an in-memory directory. `fastLinkJS` hashes in-memory and writes to `Task.dest`. `fullLinkJS` does the same (plus `wasm-opt` in WASM mode). Has **no `minified` task** — so JS files are never terser-minified in production builds.
9+
10+
The desired end-state:
11+
- Both module traits should terser-minify JS output for non-WASM `fullLinkJS` builds (controlled by `scalaJSMinify`).
12+
- Neither should run terser in WASM mode (where `wasm-opt` is the appropriate tool).
13+
- `fastLinkJS` output stays in-memory for `InMemoryHashScalaJSModule`; disk output is only for `fullLinkJS`.
14+
15+
## Goals / Non-Goals
16+
17+
**Goals:**
18+
- `FileBasedContentHashScalaJSModule.minified`: when `scalaJSExperimentalUseWebAssembly()` is `true`, copy all files unchanged (skip terser). Non-WASM path unchanged.
19+
- `InMemoryHashScalaJSModule.fullLinkJS`: when `scalaJSExperimentalUseWebAssembly()` is `false` and `scalaJSMinify()` is `true`, run terser on each JS file in-memory before hashing and writing to `Task.dest`.
20+
- `InMemoryHashScalaJSModule.fastLinkJS`: no change — stays in-memory, no terser.
21+
- `InMemoryHashScalaJSModule.fullLinkJS` WASM path: no change — `wasm-opt` already runs; do not add terser.
22+
23+
**Non-Goals:**
24+
- Changing how `scalaJSMinify` interacts with the Scala.js linker itself.
25+
- Running terser during `fastLinkJS` on either module.
26+
- Running terser on WASM loader JS in either module.
27+
28+
## Decisions
29+
30+
### Gate terser on `scalaJSMinify` in `InMemoryHashScalaJSModule`
31+
32+
`scalaJSMinify` is the user-facing flag that already controls linker-level minification. Reusing it for the terser gate is the least-surprise approach: one flag that means "apply all production minification". This avoids introducing a second flag.
33+
34+
### Terser invocation for `InMemoryHashScalaJSModule`
35+
36+
For each JS file in the in-memory directory, write it to a temp file, invoke `terser`, read the result back into memory, then proceed with the existing hash-and-write-to-disk flow. Source maps are updated as in `FileBasedContentHashScalaJSModule.minified`. This reuses the existing `terserConfig` task.
37+
38+
### Detect WASM mode via `scalaJSExperimentalUseWebAssembly`
39+
40+
Both module traits check `scalaJSExperimentalUseWebAssembly()` directly — the canonical flag already used by `fullLinkJS` — rather than inspecting output file extensions.
41+
42+
## Risks / Trade-offs
43+
44+
- [Risk] `InMemoryHashScalaJSModule.fullLinkJS` now writes to disk (it already did) and invokes terser — build time increases for production JS-only builds. This is expected and acceptable; users can set `scalaJSMinify = false` to skip it.
45+
- [Risk] `scalaJSMinify` could be confused with the `minified` task on `FileBasedContentHashScalaJSModule` — they are related but distinct: `scalaJSMinify` gates both the Scala.js linker pass and the terser pass; `minified` is the `FileBasedContentHashScalaJSModule`-specific task that wraps terser. Doc-comments on both should clarify this.
46+
47+
## Migration Plan
48+
49+
No migration needed. Non-WASM production builds gain terser minification in `InMemoryHashScalaJSModule` (previously absent). WASM builds that previously failed under terser in `FileBasedContentHashScalaJSModule.minified` now succeed. Users can opt out by setting `override def scalaJSMinify = Task(false)`.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Why
2+
3+
When Scala.js emits WASM output, the linker also emits JS loader files (e.g. `__loader.js`) that bootstrap the WASM module. The current `minified` task passes every `.js` file in the `fullLinkJS` output through `terser`, including these loader files. Terser does not understand the specialised WASM-bootstrap patterns and can produce broken output; even when it does not break, minifying a tiny loader file is unnecessary overhead. Only the WASM binary itself benefits meaningfully from optimisation (which is already handled by `wasm-opt`).
4+
5+
## What Changes
6+
7+
- The `minified` task in `FileBasedContentHashScalaJSModule` (and the equivalent path in `InMemoryHashScalaJSModule`) will skip JS files when the module is in WASM mode.
8+
- When `scalaJSExperimentalUseWebAssembly` is `true`, `minified` will copy JS loader files unchanged and only pass `.wasm` files through the existing `wasm-opt` pipeline (which already runs in `fullLinkJS`).
9+
- No change to the non-WASM code path.
10+
11+
## Capabilities
12+
13+
### New Capabilities
14+
15+
- `wasm-js-passthrough`: When emitting WASM, JS loader files emitted alongside the `.wasm` binary are copied as-is by `minified`, bypassing terser.
16+
17+
### Modified Capabilities
18+
19+
- `wasm-minification`: The existing requirement that `wasm-opt` is the only minification tool applied to WASM builds is made explicit and enforced in `minified` as well as `fullLinkJS`.
20+
21+
## Impact
22+
23+
- `plugin/src/FileBasedContentHashScalaJSModule.scala``minified` task gains a WASM branch
24+
- `plugin/integration/src/wasm.test.scala` — new integration test scenario covering `minified` in WASM mode
25+
- No API or published interface changes; `wasmOptFlags` and `terserConfig` remain unchanged
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## ADDED Requirements
2+
3+
### Requirement: FileBasedContentHashScalaJSModule does not run terser on WASM builds
4+
When `scalaJSExperimentalUseWebAssembly` is `true`, the `minified` task in `FileBasedContentHashScalaJSModule` SHALL copy all files unchanged to the output directory instead of passing JS files through `terser`.
5+
6+
#### Scenario: JS loader files are copied unchanged in WASM mode
7+
- **WHEN** a `FileBasedContentHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `minified`
8+
- **THEN** all `.js` files in the output SHALL be the same size as the corresponding files in the `fullLinkJS` output
9+
10+
#### Scenario: minified succeeds in WASM mode without terser
11+
- **WHEN** a `FileBasedContentHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `minified`
12+
- **THEN** the task SHALL complete successfully without invoking `terser`
13+
14+
#### Scenario: WASM file is included in minified output
15+
- **WHEN** a `FileBasedContentHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `minified`
16+
- **THEN** the output directory SHALL contain a content-hashed `.wasm` file copied from the `fullLinkJS` output
17+
18+
### Requirement: InMemoryHashScalaJSModule runs terser during fullLinkJS for non-WASM builds
19+
When `scalaJSExperimentalUseWebAssembly` is `false` and `scalaJSMinify` is `true`, `InMemoryHashScalaJSModule.fullLinkJS` SHALL invoke `terser` on each JS file before hashing and writing to `Task.dest`.
20+
21+
#### Scenario: fullLinkJS JS output is smaller than fastLinkJS when scalaJSMinify is true
22+
- **WHEN** an `InMemoryHashScalaJSModule` with `scalaJSMinify = true` runs `fullLinkJS`
23+
- **THEN** the total size of `.js` files in `Task.dest` SHALL be smaller than the total size of `.js` files produced by `fastLinkJS`
24+
25+
#### Scenario: fullLinkJS skips terser when scalaJSMinify is false
26+
- **WHEN** an `InMemoryHashScalaJSModule` with `scalaJSMinify = false` runs `fullLinkJS`
27+
- **THEN** `terser` SHALL NOT be invoked
28+
29+
#### Scenario: fastLinkJS does not run terser
30+
- **WHEN** an `InMemoryHashScalaJSModule` runs `fastLinkJS`
31+
- **THEN** `terser` SHALL NOT be invoked and in-memory output SHALL be present
32+
33+
#### Scenario: fullLinkJS WASM path does not run terser
34+
- **WHEN** an `InMemoryHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `fullLinkJS`
35+
- **THEN** `terser` SHALL NOT be invoked
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: wasm-opt is invoked on WASM output during fullLinkJS
4+
When `scalaJSExperimentalUseWebAssembly` is enabled, both `InMemoryHashScalaJSModule` and `FileBasedContentHashScalaJSModule` SHALL invoke `wasm-opt` on the emitted `.wasm` file as part of `fullLinkJS`, using the flags supplied by `wasmOptFlags`. The `minified` task SHALL NOT invoke `wasm-opt` a second time — the WASM binary produced by `fullLinkJS` is already optimised and is used as-is.
5+
6+
#### Scenario: WASM file is optimised during full link
7+
- **WHEN** a module with `scalaJSExperimentalUseWebAssembly = true` runs `fullLinkJS`
8+
- **THEN** the emitted `.wasm` file SHALL be smaller than or equal to the unoptimised `.wasm` produced by `fastLinkJS`
9+
10+
#### Scenario: fullLinkJS succeeds with default flags
11+
- **WHEN** `wasmOptFlags` is not overridden (defaults to `Seq("-O2", "-all")`)
12+
- **THEN** `wasm-opt` SHALL complete without error
13+
14+
#### Scenario: minified does not re-run wasm-opt
15+
- **WHEN** a `FileBasedContentHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `minified`
16+
- **THEN** `wasm-opt` SHALL NOT be invoked
17+
- **AND** the `.wasm` file in the `minified` output SHALL be the same binary as produced by `fullLinkJS`
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
## 1. FileBasedContentHashScalaJSModule — WASM skip
2+
3+
- [x] 1.1 In `FileBasedContentHashScalaJSModule.minified`, read `scalaJSExperimentalUseWebAssembly()` at the top of the task body
4+
- [x] 1.2 When `true`, copy all files from the `fullLinkJS` output directory directly to `Task.dest` (skipping the terser loop); leave the existing terser path untouched for the `false` branch
5+
- [x] 1.3 Update the scaladoc on `minified` to clarify the WASM skip behaviour and distinguish it from the `scalaJSMinify` linker flag
6+
7+
## 2. InMemoryHashScalaJSModule — terser on fullLinkJS
8+
9+
- [x] 2.1 Add `terserConfig` task to `InMemoryHashScalaJSModule` (same default JSON as `FileBasedContentHashScalaJSModule`)
10+
- [x] 2.2 In `InMemoryHashScalaJSModule.fullLinkJS`, in the non-WASM branch: when `scalaJSMinify()` is `true`, for each JS file in `inMemoryOutputDirectory` run terser (write to temp file, invoke CLI, read result back) and replace the in-memory entry with the minified bytes before the existing hash-and-write-to-disk flow
11+
- [x] 2.3 Ensure source map `sourceMappingURL` comments are updated to use the final hashed filename after terser rewrites them
12+
- [x] 2.4 Leave the WASM branch of `fullLinkJS` and the entirety of `fastLinkJS` unchanged
13+
14+
## 3. Tests
15+
16+
- [x] 3.1 `wasm.test.scala`: call `minified` on `FileBasedContentHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true`; assert task succeeds, output contains `.wasm` and JS loader files, and each JS file is byte-for-byte the same size as in `fullLinkJS` output
17+
- [x] 3.2 `linkInMem.test.scala`: call `fullLinkJS` on `InMemoryHashScalaJSModule` with `scalaJSMinify = true` (default); assert the output JS files are smaller than their `fastLinkJS` counterparts (confirming terser ran)
18+
- [x] 3.3 Call `fastLinkJS` on `InMemoryHashScalaJSModule`; assert in-memory output is present and that fastLinkJS does not write into the directory - i.e. no file IO.
19+
- [x] 3.4 Call `fullLinkJS` on `InMemoryHashScalaJSModule`; assert that files are written to disk and that terser is invoked when `scalaJSMinify = true` and not invoked when `scalaJSMinify = false`
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# wasm-js-passthrough Specification
2+
3+
## Purpose
4+
5+
When Scala.js emits WASM output, the JS loader files it produces use WebAssembly-specific bootstrap patterns that terser can break. This spec defines the passthrough behaviour for JS files in WASM builds, and the terser gating behaviour for non-WASM production builds.
6+
7+
### Requirement: FileBasedContentHashScalaJSModule does not run terser on WASM builds
8+
When `scalaJSExperimentalUseWebAssembly` is `true`, the `minified` task in `FileBasedContentHashScalaJSModule` SHALL copy all files unchanged to the output directory instead of passing JS files through `terser`.
9+
10+
#### Scenario: JS loader files are copied unchanged in WASM mode
11+
- **WHEN** a `FileBasedContentHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `minified`
12+
- **THEN** all `.js` files in the output SHALL be the same size as the corresponding files in the `fullLinkJS` output
13+
14+
#### Scenario: minified succeeds in WASM mode without terser
15+
- **WHEN** a `FileBasedContentHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `minified`
16+
- **THEN** the task SHALL complete successfully without invoking `terser`
17+
18+
#### Scenario: WASM file is included in minified output
19+
- **WHEN** a `FileBasedContentHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `minified`
20+
- **THEN** the output directory SHALL contain a content-hashed `.wasm` file copied from the `fullLinkJS` output
21+
22+
### Requirement: InMemoryHashScalaJSModule runs terser during fullLinkJS for non-WASM builds
23+
When `scalaJSExperimentalUseWebAssembly` is `false` and `scalaJSMinify` is `true`, `InMemoryHashScalaJSModule.fullLinkJS` SHALL invoke `terser` on each JS file before hashing and writing to `Task.dest`.
24+
25+
#### Scenario: fullLinkJS JS output is smaller than fastLinkJS when scalaJSMinify is true
26+
- **WHEN** an `InMemoryHashScalaJSModule` with `scalaJSMinify = true` runs `fullLinkJS`
27+
- **THEN** the total size of `.js` files in `Task.dest` SHALL be smaller than the total size of `.js` files produced by `fastLinkJS`
28+
29+
#### Scenario: fullLinkJS skips terser when scalaJSMinify is false
30+
- **WHEN** an `InMemoryHashScalaJSModule` with `scalaJSMinify = false` runs `fullLinkJS`
31+
- **THEN** `terser` SHALL NOT be invoked
32+
33+
#### Scenario: fastLinkJS does not run terser
34+
- **WHEN** an `InMemoryHashScalaJSModule` runs `fastLinkJS`
35+
- **THEN** `terser` SHALL NOT be invoked and in-memory output SHALL be present
36+
37+
#### Scenario: fullLinkJS WASM path does not run terser
38+
- **WHEN** an `InMemoryHashScalaJSModule` with `scalaJSExperimentalUseWebAssembly = true` runs `fullLinkJS`
39+
- **THEN** `terser` SHALL NOT be invoked

plugin/integration/src/js.test.scala

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,72 @@ import io.github.quafadas.FileBasedContentHashScalaJSModule
1313

1414
object SiteJsTests extends TestSuite:
1515
def tests: Tests = Tests {
16+
test("fullLinkJS emits content-hashed files with correct cross-module references") {
17+
object build extends TestRootModule with FileBasedContentHashScalaJSModule:
18+
override def scalaVersion: Simple[String] = "3.8.2"
19+
override def moduleSplitStyle: Simple[ModuleSplitStyle] =
20+
ModuleSplitStyle.SmallModulesFor("webapp")
21+
22+
override def mvnDeps = Seq(
23+
mvn"com.raquo::laminar::17.0.0"
24+
)
25+
26+
lazy val millDiscover = Discover[this.type]
27+
end build
28+
29+
val resourceFolder = os.Path(sys.env("MILL_TEST_RESOURCE_DIR"))
30+
31+
UnitTester(build, resourceFolder / "simple").scoped {
32+
eval =>
33+
val Right(result) = eval(build.fullLinkJS).runtimeChecked
34+
val report = result.value
35+
val outputDir = report.dest.path
36+
val files = os.list(outputDir).map(_.last).toSet
37+
val jsFiles = files.filter(f => f.endsWith(".js") && !f.endsWith(".js.map"))
38+
39+
// Must produce at least one file — catches the regression where the else-branch
40+
// returned super's report without writing anything to Task.dest.
41+
if jsFiles.isEmpty then
42+
throw new java.lang.AssertionError(
43+
s"fullLinkJS produced no .js files in Task.dest. Files present: ${files.mkString(", ")}"
44+
)
45+
end if
46+
47+
// No original (unhashed) JS filename should exist.
48+
assert(!files.contains("main.js"))
49+
50+
// No hashed JS filename should contain a hyphen.
51+
jsFiles.foreach(filename => assert(!filename.contains("-")))
52+
53+
// Every cross-module import must reference a file that actually exists in the output.
54+
jsFiles.foreach {
55+
filename =>
56+
val content = os.read(outputDir / filename)
57+
val imports = ContentHashScalaJSModule.parseJsImports(content)
58+
imports.foreach {
59+
importedName =>
60+
if !jsFiles.contains(importedName) then
61+
throw new java.lang.AssertionError(
62+
s"In $filename: import '$importedName' not found in output. " +
63+
s"Output files: ${jsFiles.mkString(", ")}"
64+
)
65+
}
66+
}
67+
68+
// The public modules reported back must have hashed filenames present in output.
69+
assert(report.publicModules.nonEmpty)
70+
report
71+
.publicModules
72+
.foreach {
73+
m =>
74+
if !jsFiles.contains(m.jsFileName) then
75+
throw new java.lang.AssertionError(
76+
s"Public module '${m.moduleID}' jsFileName '${m.jsFileName}' not found in output"
77+
)
78+
}
79+
}
80+
}
81+
1682
test("Hashed JS files have correct cross-module references") {
1783
object build extends TestRootModule with FileBasedContentHashScalaJSModule:
1884
override def scalaVersion: Simple[String] = "3.8.2"

0 commit comments

Comments
 (0)