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
12 changes: 12 additions & 0 deletions .github/workflows/scrolls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ jobs:
- name: Build and test Scroll UI
working-directory: ui
run: npm ci && npm test && npm run type-check && npm run build
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Build and test Minecraft Rust UI
working-directory: ui-minecraft
run: |
cargo install --locked wasm-tools --version 1.262.0
cargo test --manifest-path guest/Cargo.toml --locked
cargo fmt --manifest-path guest/Cargo.toml --check
./build.sh guest/Cargo.toml dist --document=src/view.openui
cargo fmt --manifest-path public/guest/Cargo.toml --check
./build.sh public/guest/Cargo.toml public/dist --document=public/src/view.openui
- name: Build scroll tree
if: github.event_name == 'pull_request'
run: make build-tree
Expand Down
14 changes: 12 additions & 2 deletions scripts/push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ SCROLL_PUSH_CATEGORIES="${SCROLL_PUSH_CATEGORIES:-1}"
SCROLL_PUSH_ARTIFACTS="${SCROLL_PUSH_ARTIFACTS:-1}"
SCROLL_PUSH_JOBS="${SCROLL_PUSH_JOBS:-1}"
SCROLL_PUSH_UI="${SCROLL_PUSH_UI:-1}"
# Optional fixed substring of an artifact's source path, e.g. /minecraft/.
SCROLL_PUSH_SOURCE_FILTER="${SCROLL_PUSH_SOURCE_FILTER:-}"
if [[ ! "$SCROLL_PUSH_JOBS" =~ ^[1-9][0-9]*$ ]]; then
echo "SCROLL_PUSH_JOBS must be a positive integer (got: $SCROLL_PUSH_JOBS)" >&2
exit 2
Expand Down Expand Up @@ -89,6 +91,9 @@ rewrite_registry_reference() {
run() {
local -a command=("$@")
local index
if [[ "${command[2]:-}" != "category" && -n "$SCROLL_PUSH_SOURCE_FILTER" && "${command[3]:-}" != *"$SCROLL_PUSH_SOURCE_FILTER"* ]]; then
return 0
fi

# Add the optional suffix only to artifact tags. Category pushes are metadata
# for the stable repository and do not get PR suffixes.
Expand Down Expand Up @@ -127,9 +132,14 @@ run() {

if [[ "$SCROLL_PUSH_UI" = "1" && "${command[1]:-}" == "push" && "${command[2]:-}" != "category" ]]; then
local staged_root
staged_root="$(mktemp -d)"
mkdir -p "$repo_root/.builds"
staged_root="$(mktemp -d "$repo_root/.builds/stage-XXXXXX")"
staged_dirs+=("$staged_root")
go run ./scripts/stage-scroll-ui "${command[3]}" "$staged_root/artifact" "$repo_root/ui/dist/app.wasm"
local ui_bundle="$repo_root/ui/dist/app.wasm"
case "${command[3]}" in
*/minecraft/*) ui_bundle="$repo_root/ui-minecraft/dist/app.wasm" ;;
esac
go run ./scripts/stage-scroll-ui "${command[3]}" "$staged_root/artifact" "$ui_bundle"
command[3]="$staged_root/artifact"
fi

Expand Down
88 changes: 70 additions & 18 deletions scripts/stage-scroll-ui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,27 @@ func stage(source, destination, bundle string) error {
return err
}
runtimeManifest := runtimeConfigManifest(configManifest)
scroll["ui"] = map[string]any{"private": map[string]any{"path": "private/dist/app.wasm"}}
ui, _ := scroll["ui"].(map[string]any)
if ui == nil {
ui = map[string]any{}
}
uiSource := filepath.Dir(filepath.Dir(bundle))
commands, _ := scroll["commands"].(map[string]any)
if commands == nil {
commands = map[string]any{}
scroll["commands"] = commands
}
ui["private"] = declareUIBuild(commands, "private", uiSource)
defaultPublic := strings.Contains(filepath.ToSlash(source), "/minecraft/") && ui["public"] == nil
if defaultPublic {
ui["public"] = declareUIBuild(commands, "public", filepath.Join(uiSource, "public"))
}
scroll["ui"] = ui
if ui["public"] != nil {
if err := ensureChunk(scroll, "public-ui", "public"); err != nil {
return err
}
}
if err := ensureChunk(scroll, "config-admin-ui", "private"); err != nil {
return err
}
Expand All @@ -390,25 +410,20 @@ func stage(source, destination, bundle string) error {
}

privateDir := filepath.Join(destination, "private")
uiSource := filepath.Dir(filepath.Dir(bundle))
if err := cp.Copy(uiSource, privateDir, cp.Options{Skip: func(_ os.FileInfo, src, _ string) (bool, error) {
relative, err := filepath.Rel(uiSource, src)
if err != nil {
return false, err
}
topLevel := strings.Split(filepath.ToSlash(relative), "/")[0]
return topLevel == "dist" || topLevel == "node_modules", nil
}}); err != nil {
return fmt.Errorf("copy UI source: %w", err)
}
if err := os.MkdirAll(filepath.Join(privateDir, "dist"), 0755); err != nil {
if err := stageUIProject(uiSource, privateDir, bundle); err != nil {
return err
}
if err := copyFile(bundle, filepath.Join(privateDir, "dist", "app.wasm")); err != nil {
return err
}
if err := copyFile(strings.TrimSuffix(bundle, ".wasm")+".openui", filepath.Join(privateDir, "dist", "app.openui")); err != nil {
return err
if defaultPublic {
publicDir := filepath.Join(destination, "public")
publicSource := filepath.Join(uiSource, "public")
if err := stageUIProject(publicSource, publicDir, filepath.Join(publicSource, "dist", "app.wasm")); err != nil {
return fmt.Errorf("stage default public UI: %w", err)
}
for _, shared := range []string{"build.sh", "wit"} {
if err := cp.Copy(filepath.Join(uiSource, shared), filepath.Join(publicDir, shared)); err != nil {
return fmt.Errorf("copy public UI build support: %w", err)
}
}
}
manifestBytes, err := json.MarshalIndent(runtimeManifest, "", " ")
if err != nil {
Expand All @@ -425,6 +440,43 @@ func stage(source, destination, bundle string) error {
return ensureConfigFiles(destination, configManifest)
}

func declareUIBuild(commands map[string]any, scope, source string) map[string]any {
name := "build_ui_" + scope
image := "node:24-bookworm-slim"
recipe := "npm ci --include=optional && npm run build"
if _, err := os.Stat(filepath.Join(source, "guest", "Cargo.toml")); err == nil {
image = "rust:1.99-slim-bookworm"
recipe = "rustup target add wasm32-unknown-unknown && (command -v wasm-tools >/dev/null || cargo install --locked wasm-tools --version 1.262.0) && sh ./build.sh guest/Cargo.toml dist --document=src/view.openui"
}
commands[name] = map[string]any{"procedures": []any{map[string]any{
"image": image, "working_dir": "/app/resources/deployment/" + scope,
"mounts": []any{map[string]any{"path": "/app/resources/deployment", "sub_path": "."}},
"command": []string{"sh", "-ec", recipe},
}}}
return map[string]any{"buildcommand": name, "wasm": scope + "/dist/app.wasm", "openui": scope + "/dist/app.openui"}
}

func stageUIProject(uiSource, destination, bundle string) error {
if err := cp.Copy(uiSource, destination, cp.Options{Skip: func(_ os.FileInfo, src, _ string) (bool, error) {
relative, err := filepath.Rel(uiSource, src)
if err != nil {
return false, err
}
topLevel := strings.Split(filepath.ToSlash(relative), "/")[0]
path := filepath.ToSlash(relative)
return topLevel == "dist" || topLevel == "node_modules" || topLevel == "public" || path == "guest/target" || strings.HasPrefix(path, "guest/target/"), nil
}}); err != nil {
return fmt.Errorf("copy UI source: %w", err)
}
if err := os.MkdirAll(filepath.Join(destination, "dist"), 0755); err != nil {
return err
}
if err := copyFile(bundle, filepath.Join(destination, "dist", "app.wasm")); err != nil {
return err
}
return copyFile(strings.TrimSuffix(bundle, ".wasm")+".openui", filepath.Join(destination, "dist", "app.openui"))
}

func ensureChunk(scroll map[string]any, name string, path string) error {
rawChunks, exists := scroll["chunks"]
if !exists {
Expand Down
157 changes: 156 additions & 1 deletion scripts/stage-scroll-ui/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -33,6 +34,15 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) {
t.Fatal(err)
}
uiSource, bundle := writeTestUISource(t)
for _, name := range []string{"guest/src/lib.rs", "guest/Cargo.toml", "guest/Cargo.lock", "build.sh", "wit/client.wit", "guest/target/build/cache"} {
file := filepath.Join(uiSource, name)
if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, []byte("fixture"), 0644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(uiSource, "dist", "stale.js"), []byte("stale\n"), 0644); err != nil {
t.Fatal(err)
}
Expand All @@ -55,13 +65,37 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if string(stagedYAML) == "" || !contains(string(stagedYAML), "path: private/dist/app.wasm") {
if string(stagedYAML) == "" || !contains(string(stagedYAML), "wasm: private/dist/app.wasm") {
t.Fatalf("staged yaml = %s", stagedYAML)
}
if !strings.Contains(string(stagedYAML), "wasm: public/dist/app.wasm") {
t.Fatal("Minecraft release is missing its default public declaration")
}
for _, name := range []string{"dist/app.wasm", "dist/app.openui", "guest/Cargo.toml", "build.sh", "wit/client.wit"} {
if _, err := os.Stat(filepath.Join(destination, "public", name)); err != nil {
t.Fatal("default public project is incomplete", err)
}
}
if _, err := os.Stat(filepath.Join(destination, "private", "public")); !os.IsNotExist(err) {
t.Fatal("public project was duplicated inside the private project")
}
var stagedScroll map[string]any
if err := yaml.Unmarshal(stagedYAML, &stagedScroll); err != nil {
t.Fatal(err)
}
ui := stagedScroll["ui"].(map[string]any)
commands := stagedScroll["commands"].(map[string]any)
for _, scope := range []string{"private", "public"} {
declaration := ui[scope].(map[string]any)
if declaration["openui"] != scope+"/dist/app.openui" || declaration["buildcommand"] != "build_ui_"+scope {
t.Fatalf("incomplete declaration: %#v", declaration)
}
command := commands[declaration["buildcommand"].(string)].(map[string]any)
procedure := command["procedures"].([]any)[0].(map[string]any)
if procedure["image"] != "rust:1.99-slim-bookworm" || !strings.Contains(fmt.Sprint(procedure["command"]), "sh ./build.sh") {
t.Fatalf("Rust build recipe missing: %#v", procedure)
}
}
if chunks, exists := stagedScroll["chunks"]; exists {
t.Fatalf("automatic data chunking was replaced by explicit chunks: %#v", chunks)
}
Expand Down Expand Up @@ -100,11 +134,114 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) {
if _, err := os.Stat(filepath.Join(destination, "private", "node_modules")); !os.IsNotExist(err) {
t.Fatalf("node_modules was packaged: %v", err)
}
for _, name := range []string{"guest/src/lib.rs", "guest/Cargo.toml", "guest/Cargo.lock", "build.sh", "wit/client.wit"} {
if _, err := os.Stat(filepath.Join(destination, "private", name)); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(filepath.Join(destination, "private", "guest", "target")); !os.IsNotExist(err) {
t.Fatalf("Rust build output was packaged: %v", err)
}
if _, err := os.Stat(filepath.Join(destination, "data", "server.properties")); !os.IsNotExist(err) {
t.Fatalf("active config was unexpectedly created beside packaged default: %v", err)
}
}

func TestStagePreservesDeclaredPublicUI(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "scrolls", "minecraft", "vanilla", "1.20.4")
if err := os.MkdirAll(filepath.Join(source, "public", "custom"), 0755); err != nil {
t.Fatal(err)
}
content := "name: fixture\ndesc: fixture\nversion: 1.0.0\napp_version: 1.20.4\ncommands: {}\nchunks:\n - name: data\n path: data\nui:\n public:\n wasm: public/custom/view.wasm\n openui: public/custom/layout.openui\n"
if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
for _, name := range []string{"view.wasm", "layout.openui"} {
if err := os.WriteFile(filepath.Join(source, "public", "custom", name), []byte("public fixture"), 0644); err != nil {
t.Fatal(err)
}
}
_, bundle := writeTestUISource(t)
destination := filepath.Join(root, "staged")
if err := stage(source, destination, bundle); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(destination, "scroll.yaml"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "public/custom/view.wasm") || !strings.Contains(string(data), "public-ui") {
t.Fatal("public declaration/chunk lost")
}
for _, name := range []string{"view.wasm", "layout.openui"} {
data, err := os.ReadFile(filepath.Join(destination, "public", "custom", name))
if err != nil || string(data) != "public fixture" {
t.Fatal(err)
}
}
if _, err := os.Stat(filepath.Join(destination, "public", "dist", "app.wasm")); !os.IsNotExist(err) {
t.Fatal("custom public UI was supplemented with the default")
}
}

func TestStageRequiresBothDefaultPublicArtifacts(t *testing.T) {
for _, missing := range []string{"app.wasm", "app.openui"} {
t.Run(missing, func(t *testing.T) {
uiSource, bundle := writeTestUISource(t)
if err := os.Remove(filepath.Join(uiSource, "public", "dist", missing)); err != nil {
t.Fatal(err)
}
source := filepath.Join(t.TempDir(), "minecraft", "fixture")
if err := os.MkdirAll(source, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: fixture\ncommands: {}\n"), 0644); err != nil {
t.Fatal(err)
}
if err := stage(source, filepath.Join(t.TempDir(), "staged"), bundle); err == nil {
t.Fatal("staged an incomplete default public UI")
}
})
}
}

func TestStageRustProjectWithoutNpm(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "scrolls", "minecraft", "minecraft-vanilla", "1.20.4")
if err := os.MkdirAll(source, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: test\ndesc: test\nversion: 0.0.1\napp_version: 1.20.4\ncommands: {}\n"), 0644); err != nil {
t.Fatal(err)
}
uiSource, bundle := writeTestUISource(t)
if err := os.Remove(filepath.Join(uiSource, "package.json")); err != nil {
t.Fatal(err)
}
for _, name := range []string{"build.sh", "guest/Cargo.toml", "guest/Cargo.lock", "wit/client.wit"} {
path := filepath.Join(uiSource, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("fixture"), 0755); err != nil {
t.Fatal(err)
}
}
destination := filepath.Join(root, "staged")
if err := stage(source, destination, bundle); err != nil {
t.Fatal(err)
}
for _, name := range []string{"build.sh", "guest/Cargo.toml", "guest/Cargo.lock", "wit/client.wit", "dist/app.wasm", "dist/app.openui"} {
if _, err := os.Stat(filepath.Join(destination, "private", name)); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(filepath.Join(destination, "private", "package.json")); !os.IsNotExist(err) {
t.Fatal("Rust staging must not require or synthesize an npm manifest", err)
}
}

func TestStageAppendsPrivateDataChunkToExistingExplicitChunks(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "scrolls", "lgsm", "pzserver")
Expand Down Expand Up @@ -366,6 +503,15 @@ func TestEveryCheckedInGameServerScrollStagesAsACompleteUIPackage(t *testing.T)
t.Errorf("stage %s: %v", path, err)
return nil
}
publicWASM := filepath.Join(destination, "public", "dist", "app.wasm")
_, publicErr := os.Stat(publicWASM)
minecraft := strings.Contains(filepath.ToSlash(path), "/minecraft/")
if minecraft && publicErr != nil {
t.Errorf("%s is missing its default public UI: %v", path, publicErr)
}
if !minecraft && !os.IsNotExist(publicErr) {
t.Errorf("%s unexpectedly received a Minecraft public UI", path)
}
manifestBytes, err := os.ReadFile(filepath.Join(destination, "data", ".druid", "config-editor.manifest.json"))
if err != nil {
t.Errorf("manifest %s: %v", path, err)
Expand Down Expand Up @@ -426,6 +572,15 @@ func writeTestUISource(t *testing.T) (string, string) {
if err := os.WriteFile(bundle, []byte("wasm"), 0644); err != nil {
t.Fatal(err)
}
for _, name := range []string{"build.sh", "wit/client.wit", "public/dist/app.wasm", "public/dist/app.openui", "public/guest/Cargo.toml"} {
file := filepath.Join(uiSource, name)
if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, []byte("public fixture"), 0644); err != nil {
t.Fatal(err)
}
}
return uiSource, bundle
}

Expand Down
9 changes: 9 additions & 0 deletions scripts/tests/fixtures/fake-stage-go.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
[[ "$1" == run && "$2" == ./scripts/stage-scroll-ui ]]
case "$3" in
*/minecraft/*) [[ "$5" == */ui-minecraft/dist/app.wasm ]] ;;
*) [[ "$5" == */ui/dist/app.wasm ]] ;;
esac
printf '%s|%s\n' "$3" "$5" >> "$FAKE_DRUID_STATE_DIR/staging.log"
mkdir -p "$4"
Loading
Loading