From 473bd6e36ef7f257d01c118f8dce5a8381790c1a Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:18:22 -0700 Subject: [PATCH 1/6] ci(end-to-end): add new job using `https_proxy` env variable --- .github/workflows/test.yml | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 762de81..005f50f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: package.json - cache: 'npm' + cache: "npm" - run: npm ci - run: npm test @@ -39,11 +39,13 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: package.json - cache: 'npm' + cache: "npm" - run: npm ci - run: npm run build - uses: ./ # Uses the action in the root directory id: test + env: + https_proxy: https://example.com with: app-id: ${{ vars.TEST_APP_ID }} private-key: ${{ secrets.TEST_APP_PRIVATE_KEY }} @@ -54,3 +56,30 @@ jobs: with: route: GET /installation/repositories - run: echo '${{ steps.get-repository.outputs.data }}' + + end-to-end-proxy: + name: End-to-End test using proxy + runs-on: ubuntu-latest + # do not run from forks, as forks don’t have access to repository secrets + if: github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: "npm" + - run: npm ci + - run: npm run build + - uses: ./ # Uses the action in the root directory + id: test + env: + https_proxy: https://example.com + with: + app-id: ${{ vars.TEST_APP_ID }} + private-key: ${{ secrets.TEST_APP_PRIVATE_KEY }} + + # fail CI if prior step succeeds and vice versa + - if: ${{ success() }} + run: exit 1 + - if: ${{ failure() }} + run: echo "action failed as expected" From 38b2e046866444041139e83dc94d13c5cacaf56c Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:28:01 -0700 Subject: [PATCH 2/6] refactor: wrap `main()` code into async function --- main.js | 62 ++++++++++++++++++++++++++++++--------------------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/main.js b/main.js index 7670378..8052905 100644 --- a/main.js +++ b/main.js @@ -15,32 +15,36 @@ if (!process.env.GITHUB_REPOSITORY_OWNER) { throw new Error("GITHUB_REPOSITORY_OWNER missing, must be set to ''"); } -const appId = core.getInput("app-id"); -const privateKey = core.getInput("private-key"); -const owner = core.getInput("owner"); -const repositories = core - .getInput("repositories") - .split(/[\n,]+/) - .map((s) => s.trim()) - .filter((x) => x !== ""); - -const skipTokenRevoke = core.getBooleanInput("skip-token-revoke"); - -const permissions = getPermissionsFromInputs(process.env); - -// Export promise for testing -export default main( - appId, - privateKey, - owner, - repositories, - permissions, - core, - createAppAuth, - request, - skipTokenRevoke, -).catch((error) => { - /* c8 ignore next 3 */ - console.error(error); - core.setFailed(error.message); -}); +async function run() { + const appId = core.getInput("app-id"); + const privateKey = core.getInput("private-key"); + const owner = core.getInput("owner"); + const repositories = core + .getInput("repositories") + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter((x) => x !== ""); + + const skipTokenRevoke = core.getBooleanInput("skip-token-revoke"); + + const permissions = getPermissionsFromInputs(process.env); + + // Export promise for testing + return main( + appId, + privateKey, + owner, + repositories, + permissions, + core, + createAppAuth, + request, + skipTokenRevoke, + ).catch((error) => { + /* c8 ignore next 3 */ + console.error(error); + core.setFailed(error.message); + }); +} + +export default run(); From 7830ec20d5d358f39cda3d2629e87e5de23cac4f Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:35:45 -0700 Subject: [PATCH 3/6] spawn subprocess for `main.js` if enabled using http proxy env variables --- main.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/main.js b/main.js index 8052905..03a124f 100644 --- a/main.js +++ b/main.js @@ -15,7 +15,31 @@ if (!process.env.GITHUB_REPOSITORY_OWNER) { throw new Error("GITHUB_REPOSITORY_OWNER missing, must be set to ''"); } +import { spawn } from "node:child_process"; +// export for testing +export { spawn } from "node:child_process"; + async function run() { + // spawn a child process if proxy is set + const httpProxyEnvVars = [ + "https_proxy", + "HTTPS_PROXY", + "http_proxy", + "HTTP_PROXY", + ]; + const nodeHasProxySupportEnabled = process.env.NODE_USE_ENV_PROXY === "1"; + const shouldUseProxy = httpProxyEnvVars.some((v) => process.env[v]); + + if (!nodeHasProxySupportEnabled && shouldUseProxy) { + // spawn itself with NODE_USE_ENV_PROXY=1 + const child = spawn(process.execPath, process.argv.slice(1), { + env: { ...process.env, NODE_USE_ENV_PROXY: "1" }, + stdio: "inherit", + }); + child.on("exit", (code) => process.exit(code)); + return; + } + const appId = core.getInput("app-id"); const privateKey = core.getInput("private-key"); const owner = core.getInput("owner"); From b02407398335d0584147ae46bdb76d5605c140a0 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:58:44 -0700 Subject: [PATCH 4/6] wip native node module mocking to test spawning sub child --- main.js | 6 ++---- .../main-spawns-sub-process-for-proxy.test.js | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 tests/main-spawns-sub-process-for-proxy.test.js diff --git a/main.js b/main.js index 03a124f..a53361c 100644 --- a/main.js +++ b/main.js @@ -15,10 +15,6 @@ if (!process.env.GITHUB_REPOSITORY_OWNER) { throw new Error("GITHUB_REPOSITORY_OWNER missing, must be set to ''"); } -import { spawn } from "node:child_process"; -// export for testing -export { spawn } from "node:child_process"; - async function run() { // spawn a child process if proxy is set const httpProxyEnvVars = [ @@ -31,12 +27,14 @@ async function run() { const shouldUseProxy = httpProxyEnvVars.some((v) => process.env[v]); if (!nodeHasProxySupportEnabled && shouldUseProxy) { + const { spawn } = await import("node:child_process"); // spawn itself with NODE_USE_ENV_PROXY=1 const child = spawn(process.execPath, process.argv.slice(1), { env: { ...process.env, NODE_USE_ENV_PROXY: "1" }, stdio: "inherit", }); child.on("exit", (code) => process.exit(code)); + // TODO: return promise that resolves once sub process exits (for testing) return; } diff --git a/tests/main-spawns-sub-process-for-proxy.test.js b/tests/main-spawns-sub-process-for-proxy.test.js new file mode 100644 index 0000000..d349cdc --- /dev/null +++ b/tests/main-spawns-sub-process-for-proxy.test.js @@ -0,0 +1,21 @@ +import test from "node:test"; + +test("spawns a child process if proxy is set and NODE_USE_ENV_PROXY is not set", async (t) => { + // https://nodejs.org/api/test.html#class-mocktracker + // TODO: why u not work + t.mock.module("node:child_process", { + namedExports: { + spawn() { + throw new Error("----- nope!!! -----"); + }, + }, + }); + + process.env.GITHUB_REPOSITORY = "foo/bar"; + process.env.GITHUB_REPOSITORY_OWNER = "foo"; + process.env.https_proxy = "http://example.com"; + + await import("../main.js"); + + await new Promise((resolve) => setTimeout(resolve, 1000)); +}); From e8f719d4a662c257ad79f942b0c6e29981d26a1b Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:29:43 -0700 Subject: [PATCH 5/6] add comment --- main.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.js b/main.js index a53361c..e9fe8bc 100644 --- a/main.js +++ b/main.js @@ -26,6 +26,8 @@ async function run() { const nodeHasProxySupportEnabled = process.env.NODE_USE_ENV_PROXY === "1"; const shouldUseProxy = httpProxyEnvVars.some((v) => process.env[v]); + // There is no other way to enable proxy support in Node.js as of 2025-09-19 + // https://github.com/nodejs/node/blob/4612c793cb9007a91cb3fd82afe518440473826e/lib/internal/process/pre_execution.js#L168-L187 if (!nodeHasProxySupportEnabled && shouldUseProxy) { const { spawn } = await import("node:child_process"); // spawn itself with NODE_USE_ENV_PROXY=1 From 470e131ac3cdd705941db311dfdd423bad6ece94 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:22:09 -0700 Subject: [PATCH 6/6] wip - tests failing due to time differences in snapshots --- main.js | 23 ++++-- package-lock.json | 18 +++++ package.json | 1 + tests/index.js | 10 ++- .../main-spawns-sub-process-for-proxy.test.js | 75 +++++++++++++++++- tests/snapshots/index.js.md | 20 +++++ tests/snapshots/index.js.snap | Bin 1392 -> 1587 bytes 7 files changed, 134 insertions(+), 13 deletions(-) diff --git a/main.js b/main.js index e9fe8bc..502288f 100644 --- a/main.js +++ b/main.js @@ -29,15 +29,22 @@ async function run() { // There is no other way to enable proxy support in Node.js as of 2025-09-19 // https://github.com/nodejs/node/blob/4612c793cb9007a91cb3fd82afe518440473826e/lib/internal/process/pre_execution.js#L168-L187 if (!nodeHasProxySupportEnabled && shouldUseProxy) { - const { spawn } = await import("node:child_process"); - // spawn itself with NODE_USE_ENV_PROXY=1 - const child = spawn(process.execPath, process.argv.slice(1), { - env: { ...process.env, NODE_USE_ENV_PROXY: "1" }, - stdio: "inherit", + return new Promise(async (resolve, reject) => { + const { spawn } = await import("node:child_process"); + // spawn itself with NODE_USE_ENV_PROXY=1 + const child = spawn(process.execPath, process.argv.slice(1), { + env: { ...process.env, NODE_USE_ENV_PROXY: "1" }, + stdio: "inherit", + }); + child.on("exit", (code) => { + process.exitCode = code; + if (code !== 0) { + reject(new Error(`Child process exited with code ${code}`)); + } else { + resolve(); + } + }); }); - child.on("exit", (code) => process.exit(code)); - // TODO: return promise that resolves once sub process exits (for testing) - return; } const appId = core.getInput("app-id"); diff --git a/package-lock.json b/package-lock.json index fe32d4b..d73f82e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@octokit/openapi": "^19.1.0", "@sinonjs/fake-timers": "^14.0.0", + "@types/node": "^24.5.2", "ava": "^6.4.1", "c8": "^10.1.3", "dotenv": "^17.2.1", @@ -943,6 +944,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", + "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.12.0" + } + }, "node_modules/@types/retry": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", @@ -3823,6 +3834,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", + "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", diff --git a/package.json b/package.json index a2e5ffa..5a73edb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "devDependencies": { "@octokit/openapi": "^19.1.0", "@sinonjs/fake-timers": "^14.0.0", + "@types/node": "^24.5.2", "ava": "^6.4.1", "c8": "^10.1.3", "dotenv": "^17.2.1", diff --git a/tests/index.js b/tests/index.js index f300270..ed9f3d1 100644 --- a/tests/index.js +++ b/tests/index.js @@ -22,7 +22,15 @@ for (const file of testFiles) { GITHUB_OUTPUT: undefined, GITHUB_STATE: undefined, }; - const { stderr, stdout } = await execa("node", [`tests/${file}`], { env }); + const { stderr, stdout } = await execa( + "node", + [ + "--experimental-test-module-mocks", + "--disable-warning=ExperimentalWarning", + `tests/${file}`, + ], + { env }, + ); t.snapshot(stderr, "stderr"); t.snapshot(stdout, "stdout"); }); diff --git a/tests/main-spawns-sub-process-for-proxy.test.js b/tests/main-spawns-sub-process-for-proxy.test.js index d349cdc..8335e4e 100644 --- a/tests/main-spawns-sub-process-for-proxy.test.js +++ b/tests/main-spawns-sub-process-for-proxy.test.js @@ -1,12 +1,76 @@ import test from "node:test"; +import assert from "node:assert"; test("spawns a child process if proxy is set and NODE_USE_ENV_PROXY is not set", async (t) => { + let spawnCalled = false; + // https://nodejs.org/api/test.html#class-mocktracker - // TODO: why u not work t.mock.module("node:child_process", { namedExports: { spawn() { - throw new Error("----- nope!!! -----"); + spawnCalled = true; + return { + on(event, callback) { + callback(0); + }, + }; + }, + }, + }); + + process.env.GITHUB_REPOSITORY = "foo/bar"; + process.env.GITHUB_REPOSITORY_OWNER = "foo"; + process.env.https_proxy = "http://example.com"; + + const { default: runPromise } = await import("../main.js?" + Math.random()); + await runPromise; + + assert(spawnCalled, "spawn was called"); + assert.equal(process.exitCode, 0, "process exit code is 0"); +}); + +test("child process throws error", async (t) => { + let spawnCalled = false; + + // https://nodejs.org/api/test.html#class-mocktracker + t.mock.module("node:child_process", { + namedExports: { + spawn() { + spawnCalled = true; + return { + on(event, callback) { + callback(1); + }, + }; + }, + }, + }); + + process.env.GITHUB_REPOSITORY = "foo/bar"; + process.env.GITHUB_REPOSITORY_OWNER = "foo"; + process.env.https_proxy = "http://example.com"; + + const { default: runPromise } = await import("../main.js?" + Math.random()); + await runPromise.catch((error) => { + assert.equal( + error.message, + "Child process exited with code 1", + "error message is correct", + ); + }); + + assert(spawnCalled, "spawn was called"); + assert.equal(process.exitCode, 1, "process exit code is 0"); + process.exitCode = 0; // reset for other tests +}); + +test("does not spawn a child process if proxy is set and NODE_USE_ENV_PROXY is set", async (t) => { + let mainCalled = false; + + t.mock.module("../lib/main.js", { + namedExports: { + async main() { + mainCalled = true; }, }, }); @@ -14,8 +78,11 @@ test("spawns a child process if proxy is set and NODE_USE_ENV_PROXY is not set", process.env.GITHUB_REPOSITORY = "foo/bar"; process.env.GITHUB_REPOSITORY_OWNER = "foo"; process.env.https_proxy = "http://example.com"; + process.env.NODE_USE_ENV_PROXY = "1"; + process.env["INPUT_SKIP-TOKEN-REVOKE"] = "false"; - await import("../main.js"); + const { default: runPromise } = await import("../main.js?" + Math.random()); + await runPromise; - await new Promise((resolve) => setTimeout(resolve, 1000)); + assert(mainCalled, "main was called"); }); diff --git a/tests/snapshots/index.js.md b/tests/snapshots/index.js.md index e419536..88aa2ee 100644 --- a/tests/snapshots/index.js.md +++ b/tests/snapshots/index.js.md @@ -109,6 +109,26 @@ Generated by [AVA](https://avajs.dev). POST /app/installations/123456/access_tokens␊ {"repositories":["failed-repo"]}` +## main-spawns-sub-process-for-proxy.test.js + +> stderr + + '' + +> stdout + + `✔ spawns a child process if proxy is set and NODE_USE_ENV_PROXY is not set (87.402375ms)␊ + ✔ child process throws error (4.003417ms)␊ + ✔ does not spawn a child process if proxy is set and NODE_USE_ENV_PROXY is set (3.248625ms)␊ + ℹ tests 3␊ + ℹ suites 0␊ + ℹ pass 3␊ + ℹ fail 0␊ + ℹ cancelled 0␊ + ℹ skipped 0␊ + ℹ todo 0␊ + ℹ duration_ms 97.942333` + ## main-token-get-owner-set-fail-response.test.js > stderr diff --git a/tests/snapshots/index.js.snap b/tests/snapshots/index.js.snap index e66c3d55e1416e7ac7aff4b1d2b5c4e6ce80213f..c372f52f02906a9a9eed037b35b08f1ebfae67ec 100644 GIT binary patch literal 1587 zcmV-32F&?ERzV%HI00000000B+T1{^oMHo(k5RwrR+z=pmwL)?N8GkoURt3=(H_d@0bsANH*RE2LOlRgb(mn-@G z1J|XBe(}n}YYY0f`0B!23+T}ndTps(6k>h>9;$Ixt52CvoKPy>Cmp7`VT;g!kx+Q& z&wIS|W4y@HU}?*=1oC|7qsRmGUC0XzxMYe8hB6OSAV^dNxF(P)=668x9{SMc0#p~F z%{`9~_49Z=I|t^MBT@IsqND5Mq%8q4fpWG@U6=H!?AdmwE1TuDYjvzuu2(iH&e}$~ z(x}(VC1<_5Uai%yV7b12wMKI}+m@&Z4^NlIH(rQuoTZ2g<`zvx!s-pNqrgwiGO8r)GI~mI4EV) z)R(!tR($*|`<68$m38~xRK~w!7~f}7GQUF%A!joEXfw{^#f{CagByoen|qDjo&BwY zoxS7c&ez+GJ&2;MLO+xWT87D>IOH#z_@Cy_C(aKNP*2-^G8^xW1YY|Qyk{aUUe;;O z3%Fna2Z9asi1hG;44LW@lnxEhB|Z*4=3@#+J6>W5XSw;9&f;@5$6c75n92z(pTXgk zLgpnG^1d=mJ%Fa6HQTu$*U9T7vDkTctNElKH%mgmvZhgib#d}N@X{froCAVRdsT*Mn zuIz4<Gi!6REHt-3tVlwc9ArSI|0vY6&Dd+;?RG_E3qF(S-YLh*E*pvH>uz;vi7R z;i0e4)lw=hwv`7yA0n>`C_QNoo-jl>ch+uU~(LQ7)k1beZQuln+=NOgdoF%*9#`+dH2( znuq(1W@G!S=I-9k(XswFn!{juyzFf)a(8-9ZEBH_X3c&>|*9xUlwN|c= z%eow+P#RAPI0j0!P^qo2RYpjD_#JegNvN7@88U?uO6De@a{OG6PW05FzJs2JZgeku zEC^2T6nA-a>4w5kpxKwOQ7>%ND%EOr&Jx5>gLJf*w961JE1j1JlmYi8rdfgv5~JH0 z8QQ}hHW{#~Hby-!VP2qdy4r$epF2Gd3%Tot#d>iznxkU1t*DR%%Ty`s2MWq7cHHiD zZaSdMqb(0J@4G1P3ndC!j7DRM-_7~|i9F5^J#Q-At260t7Aa4S^M58d|Ht#1#OG-u z?)sT7vNgdIuL(BhYe`=yPWse88SiA%DC)O_q8_{mugE4P6z5)#sd$|(Iz1k$OfxV4 z-gniC#@|}C#^XLqH08$@O(vF1EdO({lpaegJl(`HOlYP1B4{O(!AmcL8I1pUKI21Q z^DYJ1Ur&(z-^Hi97mqqNd*Oe<8|X2CFJOZ_oq&dSr>2{RfDgaj-sY!HYmV>r>$CxL z(JjECK6wKd+cfl)A(?=D)b#w+J?%X6&0IHg-C3?n&LVwk-_4F;GlD-yf?F|y2Pn)} z4P2k(q+|8{1gn3_Se>!@VpzRu#aL_EcP(>@FD=^|3Z~FnwSv&|n$gL-elC!@*1hR! z$Ol|%V;}GyMxuC1FaD1zi4TfCwCy1kKJz=aePB4QlRBnTVDyI7hUs5j8HginqtSCy zpju0yS{^}(!mf@^5hpU7K%DwTM+{gh_c(et0qTuO|Mbv3vrnRv&lKrDN~C}9k>Szb libPqaqIo-pCgt+r==}tiH%C||yRbz5{{V93{?-XA002XH5{Cc) literal 1392 zcmV-$1&{hcRzV2cAp#eV`4rF+~)(D+`!8) z#NI{WyB~`P00000000B+THS8rL=5tp)YH=>wIGH); zn={|{jmMK0?Uv(NJ^uSK2qqkON~k56=jt~20a#RktuxmT1uqHUqVywY+@(J6dV>FQ zY}-`O@7|hwXHLH6-=6zm4jkG7r_F^8K`^_47mAatTAwmkw?ZzwzTRP?8@6=nGd&EP ztLHsj_%+#NVX&~RMna0-(1oB#sB4pA0KUhW@B#)LKMH_w5G8Ui04kWKVC<9I{)d9t5bAnH}nE)>>_?wo=~!qrP@`g%+?Eg3_^Ih@ZH$4_~Wj*+MXv3+gyj%{Q}6)2P-~ zzFM8sRO07O*ujMHfTCf_rgQ*01`=>JU}q!1Q=bLEH-u?cE5@o`sq2Q(tX559#WX69 z3c9Y7-NyI(jlJd`!HdFXqe;q0m-3)iR?|{GXBNuIp%)(R?2Y?lo7A_w990EFTEO{{ z>dy<$wB-D2=3{M2D(%VhiH!e9F}}|@M>6S(kW-m{u$koX{QBm0b8G+Z(QX5gZ#Q># z507?!c+l7-akOR9MU;{ZLJs9ajoE06oMSb)$7lsaVA%mkGF0hodJi4H>xSp8dE{u;&WkeRw;qX!^ z_mZ3Pk_;wtEABB+1eY;%-h<(EA^wx1>ECn3ZO%im2^B?RIh6IuVv*s+(y0)sOSWAB z9S9^Dv2@@@M8(BVus;6jQlYLW=Mcz2k|E`UslcVe<8M6+p)*mYA!-gs@`P+Mu@$z+ z#>4F>gj5F~aRL4K8l_>gb{hgrMp`3MtDsCQ&$W4p?1BhJ(SrL^h~p$u%L>3r7Yid; zmKP#yTS}=kQL#U#O5xw;y+E5X2YpAj$~AjCnoX2Et<%f2&8X=_+-Y|K(MqKv=p9*e zNk&~(Uv|c>+{c8FWu+RVc;AV5|1#Ba>6x`Q;m}hYClaSEmCF0LdvKssxUU8)*YL>7 z61}y!k%nuUK+*ex7)xf-6=ZHYzzDSEKrWG+l1PY#&CBs@OpwKl?=K|htNSZ^gi+t(0^wP0DTTAkd@Q^L;=w!o_MNxmG6m|SMJV_b1 z5T57Y9cQvn7pxu@E7#1MzeQTs;`zsSC-Xi{G{%KRlZz!6%l}*~l?#c*%eJr#Q(Ec1 z4qC}&@W#tv3gf?=&G^ujyvsoLcT;5lcm8_!;&EZ~=fXFI}Jn)!2n2wOCJb_P_dDO8IQlsN1%;XUU2V;PPiPCWQe0L$ba2Op