Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9612140432 | ||
|
|
31f2da9f79 | ||
|
|
a10633ec8d | ||
|
|
ee267286a3 | ||
|
|
e3d7b8c8a5 | ||
|
|
1ffd388350 | ||
|
|
747b6bc3b9 | ||
|
|
047132e05d |
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -144,9 +144,9 @@ func (p *Pump) Fill() int {
|
|||||||
}
|
}
|
||||||
n := 0
|
n := 0
|
||||||
for q < target {
|
for q < target {
|
||||||
t := time.Now()
|
t := mixClockStart()
|
||||||
p.m.Mix(p.buf)
|
p.m.Mix(p.buf)
|
||||||
d := time.Since(t)
|
d := mixClockElapsed(t)
|
||||||
w := p.out.Write(p.buf)
|
w := p.out.Write(p.buf)
|
||||||
p.record(d)
|
p.record(d)
|
||||||
n++
|
n++
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -103,7 +104,7 @@ func openRoom(t *testing.T) *run.App {
|
|||||||
if err := os.WriteFile(path, []byte(roomScene), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(roomScene), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -124,8 +125,11 @@ func TestRoomRendersAndRotates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
q := app.Scene.Local3D(cube).Rotation
|
q := app.Scene.Local3D(cube).Rotation
|
||||||
if q.Y <= 0 || mathx.Abs(q.X) > 1e-6 || mathx.Abs(q.Z) > 1e-6 {
|
// The first rendered frame has dt=0; the remaining 119 frames must each
|
||||||
t.Fatalf("cube must rotate about +Y: %v", q)
|
// 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()
|
st := app.Runner.Stats()
|
||||||
if st.Views != 1 || st.Meshes != 3 {
|
if st.Views != 1 || st.Meshes != 3 {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"aego/app/run"
|
"aego/app/run"
|
||||||
"aego/core/log"
|
"aego/core/log"
|
||||||
|
"aego/core/mathx"
|
||||||
"aego/modules/render2d"
|
"aego/modules/render2d"
|
||||||
"aego/project"
|
"aego/project"
|
||||||
"aego/scene"
|
"aego/scene"
|
||||||
@@ -89,6 +90,7 @@ func TestNativeComponentDrivesTheScene(t *testing.T) {
|
|||||||
p := newRotatorProject(t)
|
p := newRotatorProject(t)
|
||||||
app, err := run.Open(p, run.Config{
|
app, err := run.Open(p, run.Config{
|
||||||
Headless: true,
|
Headless: true,
|
||||||
|
FrameTime: 1.0 / 60,
|
||||||
Frames: 120,
|
Frames: 120,
|
||||||
Logger: log.Nop(),
|
Logger: log.Nop(),
|
||||||
ScenePath: filepath.Join(p.ScenesDir(), "main.aescene"),
|
ScenePath: filepath.Join(p.ScenesDir(), "main.aescene"),
|
||||||
@@ -114,8 +116,9 @@ func TestNativeComponentDrivesTheScene(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
after := app.Scene.Local(player).Rotation
|
after := app.Scene.Local(player).Rotation
|
||||||
if after <= before {
|
wantDelta := float32(1.5 * 119.0 / 60.0) // first frame has zero dt
|
||||||
t.Fatalf("rotation did not advance: %v -> %v", before, after)
|
if mathx.Abs((after-before)-wantDelta) > 1e-4 {
|
||||||
|
t.Fatalf("rotation did not advance by %.3f radians: %v -> %v", wantDelta, before, after)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ $desktop = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Aego.lnk'
|
|||||||
if ((Test-Path $install) -or (Test-Path $startMenu) -or (Test-Path $desktop)) {
|
if ((Test-Path $install) -or (Test-Path $startMenu) -or (Test-Path $desktop)) {
|
||||||
throw 'Install directory or shortcuts already exist.'
|
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')
|
$beforePath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||||
$workspace = $env:GITHUB_WORKSPACE
|
$workspace = $env:GITHUB_WORKSPACE
|
||||||
$portableZip = Join-Path $workspace '.cache\github-ci\dist\portable\aego-windows-amd64-portable.zip'
|
$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'
|
$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
|
$proc = Start-Process -FilePath $setup -ArgumentList @('/S', "/D=$install") -Wait -PassThru
|
||||||
if ($proc.ExitCode -ne 0) { throw "Installer returned $($proc.ExitCode)" }
|
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" }
|
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)) {
|
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) {
|
if (([Environment]::GetEnvironmentVariable('Path', 'User') -split ';') -notcontains $install) {
|
||||||
throw 'Installer did not add install directory to user PATH.'
|
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
|
$previousProcessPath = $env:Path
|
||||||
try {
|
try {
|
||||||
$env:Path = "$env:SystemRoot\System32;$env:SystemRoot"
|
$env:Path = "$env:SystemRoot\System32;$env:SystemRoot"
|
||||||
& (Join-Path $install 'aego.exe') version
|
& (Join-Path $install 'aego.exe') version
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Installed CLI did not start with system-only PATH.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Installed CLI did not start with system-only PATH.' }
|
||||||
} finally { $env:Path = $previousProcessPath }
|
} 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.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Installed headless runtime failed.' }
|
||||||
$userData = Join-Path $install 'user-data'
|
$userData = Join-Path $install 'user-data'
|
||||||
New-Item -ItemType Directory -Path $userData | Out-Null
|
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
|
$proc = Start-Process -FilePath (Join-Path $install 'uninstall.exe') -ArgumentList '/S' -Wait -PassThru
|
||||||
if ($proc.ExitCode -ne 0) { throw "Uninstaller returned $($proc.ExitCode)" }
|
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 }
|
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 (Test-Path (Join-Path $install $file)) { throw "Uninstaller left $file" }
|
||||||
}
|
}
|
||||||
if (-not (Test-Path (Join-Path $userData 'keep.txt'))) { throw 'Uninstaller removed user data.' }
|
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 ($shortcut in @($startMenu, $desktop)) { if (Test-Path $shortcut) { throw "Uninstaller left $shortcut" } }
|
||||||
foreach ($key in $registry) { if (Test-Path $key) { throw "Uninstaller left $key" } }
|
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.'
|
Write-Host 'Silent install/uninstall smoke passed. No GUI interaction or signature validation was performed.'
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -6,6 +6,7 @@ RequestExecutionLevel user
|
|||||||
|
|
||||||
!include "MUI2.nsh"
|
!include "MUI2.nsh"
|
||||||
!include "FileFunc.nsh"
|
!include "FileFunc.nsh"
|
||||||
|
!include "WinMessages.nsh"
|
||||||
|
|
||||||
Name "Aego ${VERSION}"
|
Name "Aego ${VERSION}"
|
||||||
OutFile "${OUT}"
|
OutFile "${OUT}"
|
||||||
@@ -55,6 +56,7 @@ Section "Aego" SecMain
|
|||||||
File "${SRC}\aego-editor.exe"
|
File "${SRC}\aego-editor.exe"
|
||||||
File "${SRC}\VERSION"
|
File "${SRC}\VERSION"
|
||||||
File "${SRC}\README.txt"
|
File "${SRC}\README.txt"
|
||||||
|
File /oname=user-path.ps1 "${__FILEDIR__}\user-path.ps1"
|
||||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
; the next update keeps this installer in updates\previous, so Restore Previous Version can run it again
|
; the next update keeps this installer in updates\previous, so Restore Previous Version can run it again
|
||||||
StrCmp "$EXEPATH" "$INSTDIR\setup.exe" +2
|
StrCmp "$EXEPATH" "$INSTDIR\setup.exe" +2
|
||||||
@@ -65,8 +67,14 @@ Section "Aego" SecMain
|
|||||||
CreateShortcut "$SMPROGRAMS\Aego\Uninstall Aego.lnk" "$INSTDIR\uninstall.exe"
|
CreateShortcut "$SMPROGRAMS\Aego\Uninstall Aego.lnk" "$INSTDIR\uninstall.exe"
|
||||||
CreateShortcut "$DESKTOP\Aego.lnk" "$INSTDIR\aego-editor.exe"
|
CreateShortcut "$DESKTOP\Aego.lnk" "$INSTDIR\aego-editor.exe"
|
||||||
|
|
||||||
; put aego.exe on the user PATH once
|
; The helper preserves empty segments and records whether we own the entry.
|
||||||
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') }"`
|
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 ".aego" "Aego.Project" "Aego Project"
|
||||||
!insertmacro Assoc ".aescene" "Aego.Scene" "Aego Scene"
|
!insertmacro Assoc ".aescene" "Aego.Scene" "Aego Scene"
|
||||||
@@ -96,7 +104,13 @@ SectionEnd
|
|||||||
|
|
||||||
Section "Uninstall"
|
Section "Uninstall"
|
||||||
nsExec::Exec 'taskkill /IM aego-editor.exe /F'
|
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 ".aego" "Aego.Project"
|
||||||
!insertmacro Unassoc ".aescene" "Aego.Scene"
|
!insertmacro Unassoc ".aescene" "Aego.Scene"
|
||||||
!insertmacro Unassoc ".aeprefab" "Aego.Prefab"
|
!insertmacro Unassoc ".aeprefab" "Aego.Prefab"
|
||||||
@@ -107,6 +121,7 @@ Section "Uninstall"
|
|||||||
Delete "$INSTDIR\aego-editor.exe"
|
Delete "$INSTDIR\aego-editor.exe"
|
||||||
Delete "$INSTDIR\VERSION"
|
Delete "$INSTDIR\VERSION"
|
||||||
Delete "$INSTDIR\README.txt"
|
Delete "$INSTDIR\README.txt"
|
||||||
|
Delete "$INSTDIR\user-path.ps1"
|
||||||
Delete "$INSTDIR\uninstall.exe"
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
Delete "$INSTDIR\setup.exe"
|
Delete "$INSTDIR\setup.exe"
|
||||||
RMDir "$INSTDIR"
|
RMDir "$INSTDIR"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user