Author SHA1 Message Date
sanspie 9612140432 ci: add opt-in Stage web diagnostics 2026-11-07 10:36:17 +03:00
sanspie 31f2da9f79 ci: make native timing and installer smoke independent
Use a deterministic headless frame clock and check the actual 2D and 3D rotation. Create a guarded fresh project with the installed CLI even when preceding tests failed.
2026-11-07 06:43:53 +03:00
sanspie a10633ec8d ci: add opt-in cross-platform Aseprite diagnostics
Run real fixture and headless editor-import tests for an exact Stage commit without changing ordinary native CI.
2026-11-07 06:07:41 +03:00
sanspie ee267286a3 ci: include ancestor ACLs in Windows diagnostics 2026-11-07 05:19:37 +03:00
sanspie e3d7b8c8a5 ci: add isolated native remote store diagnostics
Only an explicit workflow_dispatch with an exact Stage commit runs these tests; ordinary native CI remains unchanged.
2026-11-07 05:08:47 +03:00
sanspie 1ffd388350 ci: isolate native integration tests and retain installer diagnostics 2026-11-07 03:56:53 +03:00
sanspie 747b6bc3b9 fix(audio): measure Windows mix time with performance counter 2026-11-07 03:12:17 +03:00
sanspie 047132e05d fix(windows): preserve user PATH during install 2026-11-07 02:38:43 +03:00
15 changed files with 996 additions and 18 deletions
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
package pump
import (
"math/bits"
"time"
)
// counterDuration converts monotonic performance-counter ticks without losing
// sub-millisecond precision or overflowing on long intervals.
func counterDuration(ticks, frequency uint64) time.Duration {
if frequency == 0 {
return 0
}
const maxDuration = uint64(1<<63 - 1)
seconds := ticks / frequency
if seconds > maxDuration/uint64(time.Second) {
return time.Duration(maxDuration)
}
hi, lo := bits.Mul64(ticks%frequency, uint64(time.Second))
nanos, _ := bits.Div64(hi, lo, frequency)
whole := seconds * uint64(time.Second)
if nanos > maxDuration-whole {
return time.Duration(maxDuration)
}
return time.Duration(whole + nanos)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package pump
import "time"
type mixClockSample struct{ start time.Time }
func mixClockStart() mixClockSample { return mixClockSample{start: time.Now()} }
func mixClockElapsed(s mixClockSample) time.Duration { return time.Since(s.start) }
+54
View File
@@ -0,0 +1,54 @@
package pump
import (
"math"
"math/big"
"math/rand"
"testing"
"time"
)
func TestCounterDuration(t *testing.T) {
for _, tc := range []struct {
ticks, frequency uint64
want time.Duration
}{
{0, 10_000_000, 0},
{1, 0, 0},
{1, 10_000_000, 100 * time.Nanosecond},
{1, 3, 333_333_333 * time.Nanosecond},
{15_000_000, 10_000_000, 1500 * time.Millisecond},
{math.MaxUint64 - 1, math.MaxUint64, 999_999_999 * time.Nanosecond},
{math.MaxUint64, 1, time.Duration(math.MaxInt64)},
} {
if got := counterDuration(tc.ticks, tc.frequency); got != tc.want {
t.Errorf("counterDuration(%d, %d) = %v; want %v", tc.ticks, tc.frequency, got, tc.want)
}
}
}
func TestCounterDurationMatchesExactArithmetic(t *testing.T) {
rng := rand.New(rand.NewSource(125))
limit := big.NewInt(math.MaxInt64)
for i := 0; i < 10000; i++ {
ticks, frequency := rng.Uint64(), rng.Uint64()|1
want := new(big.Int).SetUint64(ticks)
want.Mul(want, big.NewInt(int64(time.Second)))
want.Quo(want, new(big.Int).SetUint64(frequency))
if want.Cmp(limit) > 0 {
want.Set(limit)
}
if got := counterDuration(ticks, frequency); int64(got) != want.Int64() {
t.Fatalf("ticks=%d frequency=%d: got %v, want %s ns", ticks, frequency, got, want)
}
}
}
func TestMixStatisticsRecordDurations(t *testing.T) {
p := &Pump{}
p.record(150 * time.Nanosecond)
p.record(450 * time.Nanosecond)
if p.st.Blocks != 2 || p.st.MixMax != 450*time.Nanosecond || p.st.MixAvg != 300*time.Nanosecond {
t.Fatalf("incorrect measured mixing statistics: %+v", p.st)
}
}
+59
View File
@@ -0,0 +1,59 @@
package pump
import (
"syscall"
"time"
"unsafe"
)
// Go's Windows monotonic time can advance in coarse interrupt-time steps, so
// short audio blocks otherwise report zero despite doing work. QPC is the
// Windows monotonic high-resolution interval clock; it does not change global
// timer resolution or require a new dependency.
var (
perfClock = syscall.NewLazyDLL("kernel32.dll")
perfCounterAddr = perfClock.NewProc("QueryPerformanceCounter").Addr()
perfFrequencyAddr = perfClock.NewProc("QueryPerformanceFrequency").Addr()
perfClockRate = readPerfFrequency()
)
func readPerfFrequency() uint64 {
var value int64
ok, _, _ := syscall.SyscallN(perfFrequencyAddr, uintptr(unsafe.Pointer(&value)))
if ok == 0 || value <= 0 {
return 0
}
return uint64(value)
}
func readPerfCounter() (int64, bool) {
var value int64
ok, _, _ := syscall.SyscallN(perfCounterAddr, uintptr(unsafe.Pointer(&value)))
return value, ok != 0
}
type mixClockSample struct {
counter int64
fallback time.Time
highRes bool
}
func mixClockStart() mixClockSample {
if perfClockRate != 0 {
if counter, ok := readPerfCounter(); ok {
return mixClockSample{counter: counter, highRes: true}
}
}
return mixClockSample{fallback: time.Now()}
}
func mixClockElapsed(s mixClockSample) time.Duration {
if !s.highRes {
return time.Since(s.fallback)
}
end, ok := readPerfCounter()
if !ok || end <= s.counter {
return 0
}
return counterDuration(uint64(end-s.counter), perfClockRate)
}
+26
View File
@@ -0,0 +1,26 @@
package pump
import "testing"
func TestWindowsHighResolutionMixClock(t *testing.T) {
if perfClockRate == 0 {
t.Fatal("QueryPerformanceFrequency is unavailable")
}
start := mixClockStart()
if !start.highRes {
t.Fatal("QueryPerformanceCounter is unavailable")
}
var elapsed bool
for i := 0; i < 10000; i++ {
if mixClockElapsed(start) > 0 {
elapsed = true
break
}
}
if !elapsed {
t.Fatal("the high-resolution performance counter did not advance")
}
if allocs := testing.AllocsPerRun(1000, func() { _ = mixClockElapsed(mixClockStart()) }); allocs != 0 {
t.Fatalf("the audio timer allocated: %v allocations per measurement", allocs)
}
}
+2 -2
View File
@@ -144,9 +144,9 @@ func (p *Pump) Fill() int {
}
n := 0
for q < target {
t := time.Now()
t := mixClockStart()
p.m.Mix(p.buf)
d := time.Since(t)
d := mixClockElapsed(t)
w := p.out.Write(p.buf)
p.record(d)
n++
+7 -3
View File
@@ -1,6 +1,7 @@
package integration
import (
"math"
"os"
"path/filepath"
"strings"
@@ -103,7 +104,7 @@ func openRoom(t *testing.T) *run.App {
if err := os.WriteFile(path, []byte(roomScene), 0o644); err != nil {
t.Fatal(err)
}
app, err := run.Open(p, run.Config{Headless: true, Logger: log.Nop(), ScenePath: path}, rotator3DModule{})
app, err := run.Open(p, run.Config{Headless: true, FrameTime: 1.0 / 60, Logger: log.Nop(), ScenePath: path}, rotator3DModule{})
if err != nil {
t.Fatal(err)
}
@@ -124,8 +125,11 @@ func TestRoomRendersAndRotates(t *testing.T) {
}
}
q := app.Scene.Local3D(cube).Rotation
if q.Y <= 0 || mathx.Abs(q.X) > 1e-6 || mathx.Abs(q.Z) > 1e-6 {
t.Fatalf("cube must rotate about +Y: %v", q)
// The first rendered frame has dt=0; the remaining 119 frames must each
// advance by 1/60 s, independent of host speed and timer resolution.
angle := 1.2 * 119.0 / 60.0
if math.Abs(float64(q.Y)-math.Sin(angle/2)) > 1e-4 || math.Abs(float64(q.W)-math.Cos(angle/2)) > 1e-4 || mathx.Abs(q.X) > 1e-6 || mathx.Abs(q.Z) > 1e-6 {
t.Fatalf("cube must rotate %.3f radians about +Y after 119 timed frames: %v", angle, q)
}
st := app.Runner.Stats()
if st.Views != 1 || st.Meshes != 3 {
+5 -2
View File
@@ -7,6 +7,7 @@ import (
"aego/app/run"
"aego/core/log"
"aego/core/mathx"
"aego/modules/render2d"
"aego/project"
"aego/scene"
@@ -89,6 +90,7 @@ func TestNativeComponentDrivesTheScene(t *testing.T) {
p := newRotatorProject(t)
app, err := run.Open(p, run.Config{
Headless: true,
FrameTime: 1.0 / 60,
Frames: 120,
Logger: log.Nop(),
ScenePath: filepath.Join(p.ScenesDir(), "main.aescene"),
@@ -114,8 +116,9 @@ func TestNativeComponentDrivesTheScene(t *testing.T) {
}
}
after := app.Scene.Local(player).Rotation
if after <= before {
t.Fatalf("rotation did not advance: %v -> %v", before, after)
wantDelta := float32(1.5 * 119.0 / 60.0) // first frame has zero dt
if mathx.Abs((after-before)-wantDelta) > 1e-4 {
t.Fatalf("rotation did not advance by %.3f radians: %v -> %v", wantDelta, before, after)
}
}
+44 -4
View File
@@ -27,6 +27,24 @@ $desktop = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Aego.lnk'
if ((Test-Path $install) -or (Test-Path $startMenu) -or (Test-Path $desktop)) {
throw 'Install directory or shortcuts already exist.'
}
. (Join-Path $PSScriptRoot '../release/windows/user-path.ps1')
function Get-UserPathState {
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment')
if ($null -eq $key) {
return [pscustomobject]@{ Present = $false; Value = ''; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString }
}
try { return Read-AegoUserPath $key } finally { $key.Dispose() }
}
function Describe-UserPathState($State) {
# Log structural metadata only: never disclose the user's PATH entries.
return "present=$($State.Present), kind=$($State.Kind), length=$($State.Value.Length), segments=$(@($State.Value.Split([char]';')).Count)"
}
function Assert-SameUserPath($Before, $After, [string]$Message) {
if ($Before.Present -ne $After.Present -or ($Before.Present -and ($Before.Kind -ne $After.Kind -or $Before.Value -cne $After.Value))) {
throw "${Message}: before ($(Describe-UserPathState $Before)), after ($(Describe-UserPathState $After))"
}
}
$beforeRawPath = Get-UserPathState
$beforePath = [Environment]::GetEnvironmentVariable('Path', 'User')
$workspace = $env:GITHUB_WORKSPACE
$portableZip = Join-Path $workspace '.cache\github-ci\dist\portable\aego-windows-amd64-portable.zip'
@@ -45,7 +63,7 @@ try {
$setup = Join-Path $workspace '.cache\github-ci\dist\installer\aego-windows-amd64-setup.exe'
$proc = Start-Process -FilePath $setup -ArgumentList @('/S', "/D=$install") -Wait -PassThru
if ($proc.ExitCode -ne 0) { throw "Installer returned $($proc.ExitCode)" }
foreach ($file in @('aego.exe', 'aego-editor.exe', 'VERSION', 'README.txt', 'setup.exe', 'uninstall.exe')) {
foreach ($file in @('aego.exe', 'aego-editor.exe', 'VERSION', 'README.txt', 'user-path.ps1', 'setup.exe', 'uninstall.exe')) {
if (-not (Test-Path (Join-Path $install $file) -PathType Leaf)) { throw "Missing installed $file" }
}
foreach ($shortcut in @((Join-Path $startMenu 'Aego.lnk'), (Join-Path $startMenu 'Uninstall Aego.lnk'), $desktop)) {
@@ -64,13 +82,34 @@ foreach ($association in @(@('.aego', 'Aego.Project'), @('.aescene', 'Aego.Scene
if (([Environment]::GetEnvironmentVariable('Path', 'User') -split ';') -notcontains $install) {
throw 'Installer did not add install directory to user PATH.'
}
$installedRawPath = Get-UserPathState
$expectedPath = Add-AegoPath $beforeRawPath.Value $install
if (-not $installedRawPath.Present -or $installedRawPath.Value -cne $expectedPath.Value -or $installedRawPath.Kind -ne $beforeRawPath.Kind) {
throw "Installer changed unexpected PATH data: before ($(Describe-UserPathState $beforeRawPath)), after ($(Describe-UserPathState $installedRawPath))"
}
# Updating the same installation must not duplicate its entry or lose ownership.
$proc = Start-Process -FilePath $setup -ArgumentList @('/S', "/D=$install") -Wait -PassThru
if ($proc.ExitCode -ne 0) { throw "Installer update returned $($proc.ExitCode)" }
Assert-SameUserPath $installedRawPath (Get-UserPathState) 'Update changed user PATH'
$previousProcessPath = $env:Path
try {
$env:Path = "$env:SystemRoot\System32;$env:SystemRoot"
& (Join-Path $install 'aego.exe') version
if ($LASTEXITCODE -ne 0) { throw 'Installed CLI did not start with system-only PATH.' }
} finally { $env:Path = $previousProcessPath }
& (Join-Path $install 'aego.exe') run (Join-Path $workspace '.cache\github-ci\project') --headless --frames 60
$installedCLI = Join-Path $install 'aego.exe'
# This diagnostic runs even after an unrelated test failure, in which case the
# earlier CLI-smoke step is skipped. Create a fresh project using the installed
# binary instead of depending on that step's workspace or overwriting a project.
$installedProject = Join-Path $env:RUNNER_TEMP 'aego-ci-installed-project'
if (Test-Path $installedProject) { throw 'Installed CLI project directory already exists.' }
& $installedCLI new $installedProject
if ($LASTEXITCODE -ne 0) { throw 'Installed CLI could not create a project.' }
& $installedCLI import $installedProject
if ($LASTEXITCODE -ne 0) { throw 'Installed CLI could not import the project.' }
& $installedCLI check $installedProject
if ($LASTEXITCODE -ne 0) { throw 'Installed CLI could not check the project.' }
& $installedCLI run $installedProject --headless --frames 60
if ($LASTEXITCODE -ne 0) { throw 'Installed headless runtime failed.' }
$userData = Join-Path $install 'user-data'
New-Item -ItemType Directory -Path $userData | Out-Null
@@ -78,11 +117,12 @@ Set-Content -Path (Join-Path $userData 'keep.txt') -Value 'keep user data'
$proc = Start-Process -FilePath (Join-Path $install 'uninstall.exe') -ArgumentList '/S' -Wait -PassThru
if ($proc.ExitCode -ne 0) { throw "Uninstaller returned $($proc.ExitCode)" }
for ($i = 0; $i -lt 30 -and (Test-Path (Join-Path $install 'uninstall.exe')); $i++) { Start-Sleep -Seconds 1 }
foreach ($file in @('aego.exe', 'aego-editor.exe', 'VERSION', 'README.txt', 'setup.exe', 'uninstall.exe')) {
foreach ($file in @('aego.exe', 'aego-editor.exe', 'VERSION', 'README.txt', 'user-path.ps1', 'setup.exe', 'uninstall.exe')) {
if (Test-Path (Join-Path $install $file)) { throw "Uninstaller left $file" }
}
if (-not (Test-Path (Join-Path $userData 'keep.txt'))) { throw 'Uninstaller removed user data.' }
foreach ($shortcut in @($startMenu, $desktop)) { if (Test-Path $shortcut) { throw "Uninstaller left $shortcut" } }
foreach ($key in $registry) { if (Test-Path $key) { throw "Uninstaller left $key" } }
if ([Environment]::GetEnvironmentVariable('Path', 'User') -ne $beforePath) { throw 'User PATH was not restored.' }
Assert-SameUserPath $beforeRawPath (Get-UserPathState) 'User PATH registry state was not restored'
if ([Environment]::GetEnvironmentVariable('Path', 'User') -cne $beforePath) { throw 'User PATH environment value was not restored.' }
Write-Host 'Silent install/uninstall smoke passed. No GUI interaction or signature validation was performed.'
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Run all native Go tests, isolating integration groups without skipping tests.
The Go test timeout applies to an entire package, not to each test. The editor
integration package contains hundreds of independent tests and can exceed that
package-wide limit on hosted machines. Splitting its discovered top-level tests
keeps each default timeout and all assertions, and keeps its performance tests
separate from other simultaneously running packages. Each group is a fresh
process, so groups do not test for accidental global-state dependencies.
"""
import re
import subprocess
import sys
INTEGRATION = "aego/tests/integration"
GROUPS = 4
def command_output(args):
result = subprocess.run(args, text=True, stdout=subprocess.PIPE, check=True)
return result.stdout
def packages(output):
items = output.splitlines()
if len(items) < 2 or len(items) != len(set(items)) or items.count(INTEGRATION) != 1:
raise ValueError("go list did not return exactly one integration package")
if any(not item or any(c.isspace() for c in item) for item in items):
raise ValueError("go list returned an invalid package name")
return [item for item in items if item != INTEGRATION]
def tests(output):
names = []
for line in output.splitlines():
if line.startswith(("Test", "Example", "Fuzz")):
if not line.isidentifier():
raise ValueError("invalid discovered Go test name: " + line)
names.append(line)
if not names or len(names) != len(set(names)):
raise ValueError("integration test inventory is empty or has duplicates")
return names
def groups(names, count=GROUPS):
if count < 1 or not names or len(names) != len(set(names)):
raise ValueError("cannot partition an empty, duplicate or invalid inventory")
# Contiguous slices preserve discovery order within each subprocess.
count = min(count, len(names))
parts = [names[len(names) * i // count : len(names) * (i + 1) // count] for i in range(count)]
if [name for part in parts for name in part] != names or any(not part for part in parts):
raise ValueError("test partition lost or duplicated a test")
return parts
def test_pattern(part):
pattern = "^(?:" + "|".join(re.escape(name) for name in part) + ")$"
# Stay below Windows' command-line size limit; fail rather than drop tests.
if len(pattern.encode("utf-16-le")) // 2 > 30000:
raise ValueError("integration group exceeds the Windows command-line budget")
return pattern
def verify_patterns(parts, patterns, output):
for part, pattern in zip(parts, patterns):
actual = tests(output(["go", "test", "-list", pattern, "./tests/integration"]))
if actual != part:
raise ValueError("Go did not match the exact planned integration tests")
def run(execute=subprocess.run, output=command_output):
other = packages(output(["go", "list", "./..."]))
names = tests(output(["go", "test", "-list", "^(Test|Example|Fuzz)", "./tests/integration"]))
parts = groups(names)
patterns = [test_pattern(part) for part in parts]
verify_patterns(parts, patterns, output)
print(f"Native test inventory: {len(other) + 1} packages, {len(names)} integration tests in {len(parts)} groups", flush=True)
result = execute(["go", "test", "-count=1", *other]).returncode
for i, (part, pattern) in enumerate(zip(parts, patterns), 1):
print(f"Integration group {i}/{len(parts)}: {len(part)} tests, {part[0]} ... {part[-1]}", flush=True)
code = execute(["go", "test", "-count=1", "-v", "-run", pattern, "./tests/integration"]).returncode
if code:
result = code
return 1 if result else 0
if __name__ == "__main__":
try:
sys.exit(run())
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
print(f"Native test inventory failed: {exc}", file=sys.stderr)
sys.exit(1)
+97
View File
@@ -0,0 +1,97 @@
import types
import unittest
from pathlib import Path
import native_test_groups as native
class NativeTestGroups(unittest.TestCase):
def test_keeps_every_test_example_and_fuzz_once_in_order(self):
names = native.tests("TestAlpha\nTestBeta\nFuzzGamma\nExampleDelta\nok aego/tests/integration 0.02s\n")
parts = native.groups(names, 3)
self.assertEqual([n for part in parts for n in part], names)
for name in names:
self.assertEqual(sum(name in part for part in parts), 1)
def test_rejects_missing_or_duplicate_inventory(self):
for output in ("", "TestAlpha\nTestAlpha\n", "TestInvalid name\n"):
with self.subTest(output=output), self.assertRaises(ValueError):
native.tests(output)
for output in ("aego/app\n", "aego/tests/integration\n", "aego/tests/integration\naego/tests/integration\n"):
with self.subTest(output=output), self.assertRaises(ValueError):
native.packages(output)
def test_escapes_exact_names_and_anchors_the_pattern(self):
self.assertEqual(native.test_pattern(["TestA", "TestAB"]), "^(?:TestA|TestAB)$")
def test_runs_all_groups_even_after_failure_and_propagates_it(self):
calls = []
def output(args):
if args[1] == "list":
return "aego/app\naego/tests/integration\naego/ui\n"
if args[3] != "^(Test|Example|Fuzz)":
name = args[3][4:-2]
return name + "\nok aego/tests/integration 0.02s\n"
return "TestA\nTestB\nTestC\nTestD\nok aego/tests/integration 0.02s\n"
def execute(args):
calls.append(args)
return types.SimpleNamespace(returncode=1 if len(calls) == 2 else 0)
self.assertEqual(native.run(execute=execute, output=output), 1)
self.assertEqual(len(calls), 5)
self.assertEqual(calls[0], ["go", "test", "-count=1", "aego/app", "aego/ui"])
self.assertEqual([c[-1] for c in calls[1:]], ["./tests/integration"] * 4)
self.assertEqual([c[-2] for c in calls[1:]], ["^(?:TestA)$", "^(?:TestB)$", "^(?:TestC)$", "^(?:TestD)$"])
def test_fails_before_execution_when_go_matches_a_different_set(self):
def output(args):
return "TestOther\nok aego/tests/integration 0.02s\n"
with self.assertRaises(ValueError):
native.verify_patterns([["TestExpected"]], ["^(?:TestExpected)$"], output)
def test_installer_diagnostics_do_not_allow_failed_uploads(self):
workflow = (Path(__file__).resolve().parents[2] / ".github/workflows/github-native.yml").read_text(encoding="utf8")
package = workflow.split("- name: Build Windows portable and installer", 1)[1].split("- name: Native silent installer", 1)[0]
smoke = workflow.split("- name: Native silent installer and uninstaller", 1)[1].split("- name: Build macOS", 1)[0]
uploads = workflow.split("- name: Upload Windows portable", 1)[1].split("- name: Upload macOS", 1)[0]
self.assertIn("success() || failure()", package)
self.assertIn("steps.native_sdl.conclusion == 'success'", package)
self.assertIn("steps.native_build.conclusion == 'success'", package)
self.assertIn("steps.windows_package.conclusion == 'success'", smoke)
self.assertNotIn("continue-on-error", package + smoke)
self.assertEqual(uploads.count("runner.os == 'Windows' && success()"), 2)
def test_optional_stage_art_checks_do_not_replace_native_ci(self):
workflow = (Path(__file__).resolve().parents[2] / ".github/workflows/github-native.yml").read_text(encoding="utf8")
inputs = workflow.split("stage_art_checks:", 1)[1].split("permissions:", 1)[0]
self.assertIn("default: false", inputs)
self.assertIn("type: boolean", inputs)
native_job = workflow.split(" native:", 1)[1].split(" stage-remote-store:", 1)[0]
self.assertIn("steps.native_build.conclusion == 'success'", native_job)
stage = workflow.split(" stage-remote-store:", 1)[1].split(" gitea-result:", 1)[0]
self.assertIn('test "$(git rev-parse HEAD)" = "$STAGE_COMMIT"', stage)
art = stage.split("- name: Test Stage Aseprite import without a GUI", 1)[1]
self.assertIn("if: ${{ inputs.stage_art_checks }}", art)
for name in ("TestRealAsepriteFilesMatchLibreSpriteAndRoundTrip", "TestAsepriteImportCommandOpensEditableDocumentAndPreservesSource", "TestReadExternalFileIsBoundedAndRegular"):
self.assertIn(f"-list '^{name}$'", art)
self.assertIn(f"grep -Fx {name}", art)
self.assertNotIn("continue-on-error", art)
def test_installer_diagnostic_creates_its_own_guarded_project(self):
smoke = (Path(__file__).resolve().parent / "github-windows-installer-smoke.ps1").read_text(encoding="utf8")
self.assertIn("$installedProject = Join-Path $env:RUNNER_TEMP 'aego-ci-installed-project'", smoke)
self.assertIn("if (Test-Path $installedProject) { throw", smoke)
self.assertNotIn(".cache\\github-ci\\project", smoke)
steps = ("new $installedProject", "import $installedProject", "check $installedProject", "run $installedProject --headless --frames 60")
positions = [smoke.index("& $installedCLI " + step) for step in steps]
self.assertEqual(positions, sorted(positions))
for i, pos in enumerate(positions):
end = positions[i + 1] if i + 1 < len(positions) else smoke.index("$userData =", pos)
self.assertIn("if ($LASTEXITCODE -ne 0) { throw", smoke[pos:end])
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -6,6 +6,7 @@ RequestExecutionLevel user
!include "MUI2.nsh"
!include "FileFunc.nsh"
!include "WinMessages.nsh"
Name "Aego ${VERSION}"
OutFile "${OUT}"
@@ -55,6 +56,7 @@ Section "Aego" SecMain
File "${SRC}\aego-editor.exe"
File "${SRC}\VERSION"
File "${SRC}\README.txt"
File /oname=user-path.ps1 "${__FILEDIR__}\user-path.ps1"
WriteUninstaller "$INSTDIR\uninstall.exe"
; the next update keeps this installer in updates\previous, so Restore Previous Version can run it again
StrCmp "$EXEPATH" "$INSTDIR\setup.exe" +2
@@ -65,8 +67,14 @@ Section "Aego" SecMain
CreateShortcut "$SMPROGRAMS\Aego\Uninstall Aego.lnk" "$INSTDIR\uninstall.exe"
CreateShortcut "$DESKTOP\Aego.lnk" "$INSTDIR\aego-editor.exe"
; put aego.exe on the user PATH once
nsExec::Exec `powershell -NoProfile -ExecutionPolicy Bypass -Command "$$d='$INSTDIR'; $$p=[Environment]::GetEnvironmentVariable('Path','User'); if (-not $$p) { $$p='' }; if (($$p -split ';') -notcontains $$d) { [Environment]::SetEnvironmentVariable('Path', ($$p.TrimEnd(';') + ';' + $$d).TrimStart(';'), 'User') }"`
; The helper preserves empty segments and records whether we own the entry.
nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\user-path.ps1" -Action Install -InstallDir "$INSTDIR"'
Pop $0
StrCmp $0 0 path_install_done
SetErrorLevel 1
Abort
path_install_done:
SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000
!insertmacro Assoc ".aego" "Aego.Project" "Aego Project"
!insertmacro Assoc ".aescene" "Aego.Scene" "Aego Scene"
@@ -96,7 +104,13 @@ SectionEnd
Section "Uninstall"
nsExec::Exec 'taskkill /IM aego-editor.exe /F'
nsExec::Exec `powershell -NoProfile -ExecutionPolicy Bypass -Command "$$d='$INSTDIR'; $$p=[Environment]::GetEnvironmentVariable('Path','User'); if ($$p) { [Environment]::SetEnvironmentVariable('Path', (($$p -split ';') | Where-Object { $$_ -and $$_ -ne $$d }) -join ';', 'User') }"`
nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\user-path.ps1" -Action Uninstall -InstallDir "$INSTDIR"'
Pop $0
StrCmp $0 0 path_uninstall_done
SetErrorLevel 1
Abort
path_uninstall_done:
SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000
!insertmacro Unassoc ".aego" "Aego.Project"
!insertmacro Unassoc ".aescene" "Aego.Scene"
!insertmacro Unassoc ".aeprefab" "Aego.Prefab"
@@ -107,6 +121,7 @@ Section "Uninstall"
Delete "$INSTDIR\aego-editor.exe"
Delete "$INSTDIR\VERSION"
Delete "$INSTDIR\README.txt"
Delete "$INSTDIR\user-path.ps1"
Delete "$INSTDIR\uninstall.exe"
Delete "$INSTDIR\setup.exe"
RMDir "$INSTDIR"
File diff suppressed because it is too large Load Diff