From 3640b8e66624f1472cac19610b16958cb9fb65ee Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 14:45:24 -0400 Subject: [PATCH] ci(windows): pull e2e-windows scaffolding out to its own branch The Windows installer E2E scaffolding (e2e-windows.yml + AutoHotkey helper + button screenshots) lands in its own draft PR (ethie/windows-e2e) targeting this branch, so it can be reviewed + iterated independently of the desktop E2E suite. Both jobs remain `if: false` until the installer E2E is ready to run. --- .github/workflows/e2e-windows.yml | 396 ------------------------- e2e/windows/install-button.png | Bin 1200 -> 0 bytes e2e/windows/install-hermes-desktop.ahk | 126 -------- e2e/windows/launch-button.png | Bin 1485 -> 0 bytes 4 files changed, 522 deletions(-) delete mode 100644 .github/workflows/e2e-windows.yml delete mode 100644 e2e/windows/install-button.png delete mode 100644 e2e/windows/install-hermes-desktop.ahk delete mode 100644 e2e/windows/launch-button.png diff --git a/.github/workflows/e2e-windows.yml b/.github/workflows/e2e-windows.yml deleted file mode 100644 index 852d39dc59a..00000000000 --- a/.github/workflows/e2e-windows.yml +++ /dev/null @@ -1,396 +0,0 @@ -name: E2E Windows Desktop - -on: - push: - branches: [ethie/e2e] - workflow_dispatch: - -concurrency: - group: e2e-windows-${{ github.ref }} - cancel-in-progress: true - -jobs: - # this is separated so we don't have node.js and stuff polluting the system - build-installer: - if: false # NOTE: build-installer is disabled for now, since we don't ship updates to the installer. when we do, re-enable it. - name: Build Hermes-Setup.exe - runs-on: windows-latest - timeout-minutes: 30 - - steps: - - name: checkout cache inputs - uses: actions/checkout@v4 - with: - sparse-checkout: | - package-lock.json - apps/bootstrap-installer - sparse-checkout-cone-mode: true - - # The cache key is the exact installer build fingerprint. A hit means - # this package-lock + bootstrap-installer source combo was already built, - # so we can skip the entire Node/Rust/toolchain dance and just upload it. - - name: Restore installer build cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - id: installer-cache - with: - path: Hermes-Setup.exe - key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }} - - - name: Setup Node.js - if: steps.installer-cache.outputs.cache-hit != 'true' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - cache: npm - - - name: Setup Rust - if: steps.installer-cache.outputs.cache-hit != 'true' - uses: dtolnay/rust-toolchain@1.96.0 # stable - - name: checkout full tree on cache miss - if: steps.installer-cache.outputs.cache-hit != 'true' - uses: actions/checkout@v4 - - - name: Install npm dependencies - if: steps.installer-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Build dev installer for this branch - if: steps.installer-cache.outputs.cache-hit != 'true' - run: npm run tauri:build - working-directory: apps/bootstrap-installer - timeout-minutes: 10 - - # Only runs on cache miss. Pick the exe the Tauri build produced and - # normalize its name so downstream jobs always know what to download. - - name: Normalize installer artifact name - if: steps.installer-cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - $candidates = @( - 'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes.exe', - 'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64.exe', - 'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64-setup.exe', - 'apps/bootstrap-installer/src-tauri/target/release/Hermes.exe' - ) - $installer = $null - foreach ($c in $candidates) { - if (Test-Path $c) { - $installer = Resolve-Path $c - break - } - } - if (-not $installer) { - $installer = Get-ChildItem -Path 'apps/bootstrap-installer/src-tauri/target/release' ` - -Recurse -Filter '*.exe' | Where-Object { $_.Name -notlike '*setup*' -or $true } | Select-Object -First 1 -ExpandProperty FullName - } - if (-not $installer) { - throw 'Could not find built Hermes-Setup.exe' - } - Copy-Item -Path $installer -Destination 'Hermes-Setup.exe' -Force - Write-Host "Normalized installer: Hermes-Setup.exe (from $installer)" - - e2e: - name: Install this commit as latest main - if: false # NOTE: this job isn't used yet. - # needs: build-installer - runs-on: windows-latest - timeout-minutes: 60 - - env: - # Isolated install directory so the real install flow doesn't touch the - # runner's user profile. Kept under the workspace for easy cleanup. - HERMES_HOME: ${{ github.workspace }}\.e2e-hermes-home - INSTALL_DIR: ${{ github.workspace }}\.e2e-hermes-home\hermes-agent - - steps: - - uses: actions/checkout@v4 - with: - path: source - - - name: Restore installer from build cache - if: false # build-installer is since we don't update the installer right now. instead, we download the latest installer from the website - id: installer-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: Hermes-Setup.exe - key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }} - - - name: Download latest production installer - id: download-installer - shell: pwsh - run: | - Invoke-WebRequest https://hermes-assets.nousresearch.com/Hermes-Setup.exe -OutFile Hermes-Setup.exe - - - name: Restore cached test tools - id: test-tools-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: test-bins - key: test-bins-${{ runner.os }}-v1 - - - name: Install AutoHotkey v2 and ffmpeg - if: steps.test-tools-cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - # Install fresh when the cache missed. - - New-Item -ItemType Directory -Path test-bins\autohotkey, test-bins\ffmpeg -Force | Out-Null - - # AutoHotkey: copy its whole v2 directory so helper exes/dlls come along. - winget install -e --id AutoHotkey.AutoHotkey --silent --accept-source-agreements --accept-package-agreements --disable-interactivity - $ahkDir = "$env:ProgramW6432\AutoHotkey\v2" - if (-not (Test-Path $ahkDir)) { - throw "AutoHotkey install directory not found: $ahkDir" - } - Copy-Item -Path "$ahkDir\*" -Destination test-bins\autohotkey -Recurse -Force - - # ffmpeg : just install into dir - winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location ffmpeg_dir - Copy-Item -Path "ffmpeg_dir\*\*" -Destination test-bins\ffmpeg -Recurse -Force - - - name: Add test-bins to PATH - shell: pwsh - run: | - ls "$PWD\test-bins\ffmpeg" - Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\autohotkey" - Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\ffmpeg\bin" - - # ── Prepare an isolated HERMES_HOME and copy checked-out repo ────── - # actions/checkout already has the right commit; just mirror it into the - # isolated home so the installer doesn't need to reach GitHub. - - name: Move checked-out workspace into isolated HERMES_HOME - shell: pwsh - run: | - New-Item -ItemType Directory -Path $env:INSTALL_DIR -Force - Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $env:INSTALL_DIR -Force - Write-Host "Isolated install dir ready: $env:INSTALL_DIR" - - # ── Run the headed installer + AHK helper ─────────────────────── - - name: Launch Hermes-Setup.exe and install it - shell: pwsh - timeout-minutes: 10 - env: - HERMES_SETUP_DEV_REPO_ROOT: ${{ env.INSTALL_DIR }} - run: | - $installer = "Hermes-Setup.exe" - - # ── Start screen recording (live stdin pipe) ────────────────── - # ffmpeg must be started, fed, and stopped from the SAME step: the - # graceful-stop signal is the character 'q' written to ffmpeg's live - # stdin. A separate teardown step can't do this because the process - # that owns the writable stdin pipe dies when this step ends. - # - # Start-Process / -RedirectStandardInput does NOT work: it - # hands ffmpeg a file handle opened once at EOF, so appending 'q' to - # the file on disk never reaches the running process. We need a real - # writable pipe, which only System.Diagnostics.Process exposes. - # - # -pix_fmt yuv420p keeps - # the output broadly playable. - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = "ffmpeg" - $psi.Arguments = "-y -f gdigrab -framerate 15 -i desktop " + - "-hide_banner -loglevel error " + - "-c:v libx264 -preset ultrafast -pix_fmt yuv420p recording.mkv" - $psi.RedirectStandardInput = $true - $psi.UseShellExecute = $false - $ffmpeg = [System.Diagnostics.Process]::Start($psi) - $ffmpeg.Id | Out-File ffmpeg.pid - # Note: stderr is intentionally left attached to the console so it is - # captured in the step log. Do NOT redirect a stream we don't drain -- - # ffmpeg's progress output would fill the pipe buffer and block. - Write-Host "ffmpeg recording started (pid $($ffmpeg.Id ))" - - $e2eDir = "$env:RUNNER_TEMP\e2e-windows" - - Copy-Item -Path "$env:INSTALL_DIR\e2e\windows" -Destination $e2eDir -Force -Recurse - - $installerSuccess = $false - try { - # Launch the real installer - $proc = Start-Process -FilePath $installer -PassThru -NoNewWindow - $proc.Id | Out-File installer.pid - - $ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" ` - -ArgumentList "$e2eDir\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow - - # Wait for AHK helper to finish, and tail logs. - - $logReader = $null - $logStream = $null - $logPath = Join-Path $env:HERMES_HOME "logs\bootstrap-installer.log" - # can take a long time for installer! - $deadline = (Get-Date).AddSeconds(60 * 8) - - try { - while ((Get-Date) -lt $deadline -and -not $ahkProc.HasExited) { - if (-not $logReader) { - if (Test-Path $logPath) { - Write-Host "Found bootstrap-installer.log; tailing..." - # FileShare.ReadWrite is required: the installer almost - # certainly still has the file open for writing, and a - # plain Get-Content/File.Open would throw or lock it out. - $logStream = [System.IO.File]::Open( - $logPath, 'Open', 'Read', 'ReadWrite') - $logReader = New-Object System.IO.StreamReader($logStream) - } - } else { - $line = $logReader.ReadLine() - while ($null -ne $line) { - Write-Host "[bootstrap] $line" - $line = $logReader.ReadLine() - } - } - Start-Sleep -Milliseconds 500 - } - - # Drain anything written in the final tick before exit/timeout. - if ($logReader) { - $line = $logReader.ReadLine() - while ($null -ne $line) { - Write-Host "[bootstrap] $line" - $line = $logReader.ReadLine() - } - } - } - finally { - if ($logReader) { $logReader.Dispose() } - if ($logStream) { $logStream.Dispose() } - } - - if (-not $ahkProc.HasExited) { - Write-Host "AHK helper is still running; stopping it" - Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue - } else { - Write-Host "autohotkey helper exited" - } - } - - finally { - # Gracefully stop ffmpeg by writing 'q' to its LIVE stdin pipe, so - # the container header/index are finalized and the mkv is playable. - # This runs in the same step that owns the pipe, even on failure. - if ($ffmpeg -and -not $ffmpeg.HasExited) { - Write-Host "Stopping ffmpeg gracefully (q on stdin)" - try { - $ffmpeg.StandardInput.Write("q") - $ffmpeg.StandardInput.Close() - } catch { - Write-Host "Failed to write q to ffmpeg stdin: $_" - } - if (-not $ffmpeg.WaitForExit(15000)) { - Write-Host "ffmpeg did not exit after 15s; killing" - $ffmpeg.Kill() - } - } - Write-Host "ffmpeg stopped" - - # Installer should have exited - if ($proc.HasExited) { - # TODO check exit code once we add exit code in installer - $installerSuccess = $true - } else { - Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue - throw "Installer is still running. Install did not succeed." - } - if (-not $installerSuccess) { - throw "Installer did not exit after autohotkey script finished. Check installer logs!" - } - } - - # ── Run Playwright against the installed binary ───────────────── - # (placeholder: will be enabled once installer completes successfully.) - - name: Launch installed app and run e2e - if: false - working-directory: source/apps/desktop - run: npm exec playwright test e2e/ --reporter=list - env: - # Point the e2e spec at the desktop binary that the installer built. - HERMES_E2E_INSTALL_ROOT: ${{ env.HERMES_HOME }}\hermes-agent - - # ── Teardown & artifacts ──────────────────────────────────────── - # ffmpeg is normally started AND gracefully stopped inside the launch - # step (so 'q' reaches its live stdin pipe). This step is only a - # safety net: if the launch step timed out or crashed before its - # finally block ran, force-kill any orphaned ffmpeg so the runner can - # release recording.mkv for upload. The mkv container survives a hard - # kill (only the trailing seek index is lost), so the artifact is still - # usable for coordinate discovery even on this fallback path. - - name: Stop orphaned screen recording (safety net) - if: always() - shell: pwsh - run: | - $ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue - if ($ffmpegpid) { - $proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue - if ($proc) { - Write-Host "Orphaned ffmpeg (pid $ffmpegpid) still running; force-stopping" - Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue - Start-Sleep -Seconds 2 - } else { - Write-Host "ffmpeg already exited cleanly; nothing to do" - } - } - - - name: Burn debug overlay into recording - if: always() - shell: pwsh - run: | - $logPath = Join-Path $PWD 'ahk.log' - $x = $y = $w = $h = $null - if (Test-Path $logPath) { - $line = Get-Content $logPath -Raw | Select-String -Pattern 'Window found at x=(\d+) y=(\d+) w=(\d+) h=(\d+)' -AllMatches | Select-Object -Last 1 - if ($line) { - $x = [int]$line.Matches[0].Groups[1].Value - $y = [int]$line.Matches[0].Groups[2].Value - $w = [int]$line.Matches[0].Groups[3].Value - $h = [int]$line.Matches[0].Groups[4].Value - Write-Host "Parsed window rect: x=$x y=$y w=$w h=$h" - } else { - Write-Host "no window rect found in ahk.log; only timestamp will be burned" - } - } else { - Write-Host "ahk.log not found; only timestamp will be burned" - } - - # Build the timestamp overlay - $vf = "drawtext=text='%{pts\:hms}':fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=20:fontcolor=white:box=1:boxcolor=black@0.5:x=8:y=8" - - if ($x -ne $null -and $y -ne $null -and $w -ne $null -and $h -ne $null) { - # Window border + 16px grid only over the window + axis labels - $grid = "drawbox=x=$x`:y=$y`:w=$w`:hf=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y`:$y" - } - - Write-Host "Overlay filter: $vf" - - ffmpeg -y -i recording.mkv -vf "$vf" -c:v libx264 -preset veryfast -pix_fmt yuv420p recording-overlay.mkv - - if ($LASTEXITCODE -ne 0) { - throw "ffmpeg overlay failed" - } - - Move-Item -Path recording-overlay.mkv -Destination recording.mkv -Force - Write-Host "Overlayed recording saved as recording.mkv" - - - name: Upload screen recording - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - id: upload-recording - if: always() - with: - name: screen-recording-${{ github.sha }} - path: recording.mkv - retention-days: 1 - archive: false - overwrite: true - - - name: Bootstrap Installer log - if: always() - shell: pwsh - run: | - Get-Content "$env:HERMES_HOME\logs\bootstrap-installer.log" - - - name: Autohotkey log - if: always() - shell: pwsh - run: | - Get-Content ahk.log diff --git a/e2e/windows/install-button.png b/e2e/windows/install-button.png deleted file mode 100644 index feed47bd98fb3ffe464025c6f52553205ed89751..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1200 zcmX9;Ycv!H6rNFQVwr43op!QisWz%piB^c1W3(lqj7@2#u)7|yN|b~R&4`s_WcAQP zsV0vZjgg@envmCE!X}TXd5}p{G45q&cklTg=bm%#x#xU8F3Z!y%}{@-K7l|mbf>v` zX+1|vt9f&@TAIPD)fzF%%WXTcr@`usw$Y2A`9%>3UoZTZL_*%>CE9@k&z(Nh$*Gwu zRp@yRmmWcaH}(wAxF(`qEN(rCc@L4Ah>MwM_A6S1qJJiie*{TC^)KN;6aMuOKB!@26li|{ zM+gsFvAP`|OJGt1j&a!ZHyG~6s&=fC!nH?GBt~Hi#@xis(fH~u49LMD4nKZ|kUZ>u z11m!@?ly#7#)=k{kHfap=#dKf)!-Zt={$Jc0R=U{$VOH%q}MF_aLtUBd(%! z2u>HGUj{z!(JsR9DD;j%N)h(GgM0NeyHc>de}?3X2K%*lXN0l2ECNC2mbqBro^8-rYqMXuvo?d}atT~~FV z&z{X4YpWL&@F_u?%FRQ{mN3ic|G{AB89%ZnbA)JeLiU0i>M-Fu>Ex~WM3(2%&!99I>XmJ$I2a?r}x9-m6nNn4ome4c4RL5 zr1^=mja6vy@s^Xn{7`koQl8mNT0H13|81Z*ajz9KjN_{zXLK>_qq1+l@~QWN=OUdP zsg5KzzMQg2K7Mdr#Av#aYS~j^XmzD=K$||Pja;!0LrFb_vK(=pNU;L$tjCcGE-1PbO7Q;JLf~#Fw>(v1c6!H+q!`M{ft3BwzrCVC>@-Y;#GA!%@pONb9xbfzG zedk-q{cp%sv_N^UO&K+u$}Di$SdnZIlDyOVyASOp$#ZxP9<%$q4?4aT2l49uN267m zX*ylK{H`*whkU^Fb<@~4Yi$l(IUy+PaH}|K6X;zTA(oH^q)h?7Cu#O{dm~-#*CDu5 LJzR^oA4>WMWp}7g diff --git a/e2e/windows/install-hermes-desktop.ahk b/e2e/windows/install-hermes-desktop.ahk deleted file mode 100644 index 6d54729a0ec..00000000000 --- a/e2e/windows/install-hermes-desktop.ahk +++ /dev/null @@ -1,126 +0,0 @@ -#Requires AutoHotkey v2.0 -#SingleInstance Force - - -logPath := A_Args.Length >= 1 ? A_Args[1] : "ahk.log" - -Log(text) { - msg := Format("[autohotkey] {}`n", text) - ToolTip(text) - FileAppend(msg, '*') - FileAppend(msg, logPath) -} - -OnError(LogError) - -LogError(err, mode) { - Log(Format("Unhandled error: {}", err.Message)) - ExitApp(1) - return -1 ; suppress the standard error dialog -} - -SetWorkingDir(A_ScriptDir) -CoordMode("Pixel", "Screen") -CoordMode("Mouse", "Screen") - - -ClickWithMarker(x, y, button := "Left") { - Click(x, y, button) - - Sleep(10) - MouseMove(30, 30) - Log(Format("Clicking at {1}, {2}", x, y)) - size := 20 - g := Gui("-Caption +AlwaysOnTop +ToolWindow") - g.BackColor := "Red" - g.Show(Format( - "x{} y{} w{} h{} NoActivate" - , x - size // 2 - , y - size // 2 - , size - , size - )) - hRegion := DllCall( - "CreateEllipticRgn" - , "Int", 0 - , "Int", 0 - , "Int", size - , "Int", size - , "Ptr" - ) - DllCall("SetWindowRgn", "Ptr", g.Hwnd, "Ptr", hRegion, "Int", true) - WinSetTransparent(255, g.Hwnd) - SetTimer(() => g.Destroy(), -500) -} - -FindImageInWindow(winTitle, imageFile, &outX, &outY, timeoutMs := 10000, intervalMs := 250) -{ - WinGetPos(&wx, &wy, &ww, &wh, winTitle) - - hBitmap := LoadPicture(imageFile) - - if !hBitmap { - throw Error("LoadPicture failed: " imageFile) - } - bm := Buffer(32, 0) ; BITMAP structure on x64 - DllCall("GetObject", "Ptr", hBitmap, "Int", bm.Size, "Ptr", bm) - - width := NumGet(bm, 4, "Int") - height := NumGet(bm, 8, "Int") - - - startTime := A_TickCount - - timeLeft := 1 - - Log(Format("Searching for button file {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2))) - searchImage := Format("*10 {}", imageFile) - Log("SearchImage: " searchImage) - while (timeLeft > 0) - { - if ImageSearch(&x, &y, wx, wy, wx + ww, wy + wh, searchImage) - { - outX := x + Floor(width / 2) - outY := y + Floor(height / 2) - Log("Found button!") - return - } - - Sleep intervalMs - timeLeft := timeoutMs - (A_TickCount - startTime) - ToolTip(Format("Searching for button {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2))) - } - - throw Error(Format("Failed to find button {} in window {}", imageFile, winTitle)) -} - -ClickCenterOfImageInWindow(winTitle, imageFile, timeoutMs := 10000, intervalMs := 250) -{ - FindImageInWindow(winTitle, imageFile, &x, &y, timeoutMs, intervalMs) - ClickWithMarker(x, y) -} - - -Log("Waiting for the installer window to appear...") -winTitle := "Hermes" -try { - WinWait(winTitle, , 30) -} catch { - throw Error("Hermes installer window did not appear within 30s") -} -WinGetPos(&x, &y, &w, &h, winTitle) -Log(Format("Window found at x={1} y={2} w={3} h={4}`n", x, y, w, h)) - -ClickCenterOfImageInWindow(winTitle, A_ScriptDir "\install-button.png") - -; wait for install to finish -FindImageInWindow(winTitle, A_ScriptDir "\launch-button.png", &launchX, &launchY, 1000 * 60 * 8) - -; after we find the install button, close the window so we don't launch hermes. -WinClose(winTitle) - -; yay :D -Sleep(2000) - -; done -ExitApp(0) \ No newline at end of file diff --git a/e2e/windows/launch-button.png b/e2e/windows/launch-button.png deleted file mode 100644 index 6ab89a75ca32480987264e3ee359063cb11f6446..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1485 zcmWkuc{G#@9Q{-xm5TO7#jA*VQxr+Ba?sdHQkJLX*++UaDn=?Klp@|JDU((qktI~3 zv1A|XC}SBzgc(T^!}t5md-Lu&_ug~=y64RZ1a)Ow#)W*urR5B%fUWTV`ODX4Z zg&TGbNRLIMRRmgJ!zUcHkH#&&xXufAT|kR)T%GuQJaD7{bV(t+;a&TNx10@+TFx2V)9cjjE;j(B63<_LIP1)@T>$412LbA zQQ7$U3p5JIvR2Sx;Mgyy?I3?nz%&UvnRqk`Jrgi44|+wgnT|XB@oGA1{*ApNvak_W zxuelVX#p30jKkD4RJDo<5* z4E#PApMbVL(DjqPZGiX!=pP2*5U6{jS*WzRo2=^q|0kGSii0B%n+pwH&^-t#Wl+;j z7Sv;E8Azs}vJC>0AuJu)g)lM(lM=|N#1bAP6~THMHVeszMZkQDL*K#m4hAM;S~=K6 z%666(H)CZRc_$xkWZ|z#SvC33Ylz8#lPsV=z=08H7D8h;zO2CaO?W#WPO$J+4qQ%= zo_mCOb!cz_)jaW46|@LRb|DV`fH(CJl!E*|vTFcn_ki08nU&DdPi{SnjRLT}flb}y zg=ZL(3mV>7+6rc&m{TJ?&cfCn=~^0UoJPSQ&I?>OLJ%`;%uV*&1+c%hounQ)yjo%O zXD;`i$MdaAmhd;WY(L{|n#obK(=)zdd)MLK;qg0}3+xl2nFoh=`XSGw=qvE@OaAybf+m>9iCpe?z~FB-U5aQV$ZY0)I%;u}Ka_%!u| zgwd|=r_5>Yc|)n!t1$7&iZA_9kr0tEZ(u>mmQUNc6x9*>z6lL0?YeS*#n>`Ri;`~7 z{29HJXi8#}Jo}C#$1!m#Dpy0lt7KQ%a?hQeDqUX)udmInv5t&-Uar!-4!;I1ufV!5 zM%RaR$J89!O&3Ur(ig$fM}#mgsAJi_E3wNvUgIJ5zY=w0F@)Q!3wpKIZO+!z?#TO1 zOvQ{T&7j)torda1B&`~+ulN}0mWI#Ni(ncm z@3wr5UVre8OP#8|f_}JHR;{MUc|=bqk-`IEAqm2l*Y3d#YFO z*`i2KZOHXk*)%po-rgbhrAi)^$CP&&?LDvfO2pWs+I^nBn=0`hJ{-N_g}(agF7fO} zVahkOK}K!TDtgF-I}zC^nSyIe%we(5@#sIZ}Frpd1=*Iqvkxj4W(|uPZm-w z!)`sk7cb1|F>~=y?)-Yqv#vZj&nIa01^HT0`IVt%V;g;G1^K*MZcnf1*sK%k!4Mq)=rnhkV|2X<_Pn&Y8)$Fv{Z=$k)A563t&_XmW^%wh_x4v*HR=XCSv1^LA z!&B*VJpagR>xAGj_O^rO%ebOHVwQPZGICPO6xj975up! zEbWq9o zi|RJRl|2PUl|D-upFHF|&$Qig{bj72ewS0j+@3*+V;PKj)HENle|ObMH8UjO`TC8v h%6~e3;57(9p257kTi97NOLobGx#>ZZOyg6b{{c0JSjYeX