| author | |
| committer | |
| log | 6fc79737c3854344d05c1ac3d0dd10e143a19fa9 |
| tree | 8fe51d06d8d24498c0274b75e8b1f1eefb05b255 |
| parent | cdd03591efb09baf06299e3764ddbd01c313a936 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>2 files changed, 238 insertions(+), 30 deletions(-)
scripts/mobile-native-static-check.test.ts+136-12| ... | ... | @@ -1,18 +1,142 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import * as HostProcess from "@t3tools/shared/hostProcess"; | |
| 1 | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as PlatformError from "effect/PlatformError"; | |
| 9 | import * as Sink from "effect/Sink"; | |
| 10 | import * as Stream from "effect/Stream"; | |
| 11 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 2 | 12 | |
| 3 | import { NativeStaticCheckCommandError } from "./mobile-native-static-check.ts"; | |
| 13 | import { collectSources, runCommand } from "./mobile-native-static-check.ts"; | |
| 4 | 14 | |
| 5 | it("describes failed native static-analysis commands structurally", () => { | |
| 6 | const error = new NativeStaticCheckCommandError({ | |
| 7 | command: "swiftlint", | |
| 8 | args: ["lint", "--strict"], | |
| 9 | cwd: "/repo/apps/mobile", | |
| 10 | exitCode: 2, | |
| 15 | const processHandle = ( | |
| 16 | exitCode: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>, | |
| 17 | ) => | |
| 18 | ChildProcessSpawner.makeHandle({ | |
| 19 | pid: ChildProcessSpawner.ProcessId(1), | |
| 20 | exitCode, | |
| 21 | isRunning: Effect.succeed(false), | |
| 22 | kill: () => Effect.void, | |
| 23 | unref: Effect.succeed(Effect.void), | |
| 24 | stdin: Sink.drain, | |
| 25 | stdout: Stream.empty, | |
| 26 | stderr: Stream.empty, | |
| 27 | all: Stream.empty, | |
| 28 | getInputFd: () => Sink.drain, | |
| 29 | getOutputFd: () => Stream.empty, | |
| 11 | 30 | }); |
| 12 | 31 | |
| 13 | assert.equal(error.command, "swiftlint"); | |
| 14 | assert.deepStrictEqual(error.args, ["lint", "--strict"]); | |
| 15 | assert.equal(error.cwd, "/repo/apps/mobile"); | |
| 16 | assert.equal(error.exitCode, 2); | |
| 17 | assert.equal(error.message, "Native static check command 'swiftlint' exited with code 2."); | |
| 32 | const provideSpawner = (spawn: ChildProcessSpawner.ChildProcessSpawner["Service"]["spawn"]) => | |
| 33 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(spawn)); | |
| 34 | ||
| 35 | const runSwiftLint = runCommand("swiftlint", ["lint", "--strict"], "/repo/apps/mobile").pipe( | |
| 36 | Effect.provideService(HostProcess.HostProcessPlatform, "linux"), | |
| 37 | ); | |
| 38 | ||
| 39 | it.layer(NodeServices.layer)("mobile native source discovery", (it) => { | |
| 40 | it.effect("preserves the failed discovery operation, path, and exact cause", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const fs = yield* FileSystem.FileSystem; | |
| 43 | const path = yield* Path.Path; | |
| 44 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "mobile-native-static-check-" }); | |
| 45 | const missingDirectory = path.join(root, "missing"); | |
| 46 | ||
| 47 | const error = yield* collectSources(missingDirectory, root).pipe(Effect.flip); | |
| 48 | ||
| 49 | assert.equal(error._tag, "NativeStaticCheckSourceDiscoveryError"); | |
| 50 | assert.equal(error.operation, "read-directory"); | |
| 51 | assert.equal(error.path, missingDirectory); | |
| 52 | assert.instanceOf(error.cause, PlatformError.PlatformError); | |
| 53 | assert.equal(error.message, "Native source discovery operation 'read-directory' failed."); | |
| 54 | }), | |
| 55 | ); | |
| 56 | }); | |
| 57 | ||
| 58 | it.effect("preserves process spawn context and the exact cause", () => { | |
| 59 | const cause = PlatformError.systemError({ | |
| 60 | _tag: "NotFound", | |
| 61 | module: "ChildProcess", | |
| 62 | method: "spawn", | |
| 63 | description: "swiftlint was not found", | |
| 64 | }); | |
| 65 | ||
| 66 | return Effect.gen(function* () { | |
| 67 | const error = yield* runSwiftLint.pipe( | |
| 68 | Effect.provide(provideSpawner(() => Effect.fail(cause))), | |
| 69 | Effect.flip, | |
| 70 | ); | |
| 71 | ||
| 72 | if (error._tag !== "NativeStaticCheckProcessError") { | |
| 73 | return assert.fail(`Unexpected error: ${error._tag}`); | |
| 74 | } | |
| 75 | assert.equal(error.operation, "spawn"); | |
| 76 | assert.equal(error.command, "swiftlint"); | |
| 77 | assert.equal(error.argumentCount, 2); | |
| 78 | assert.equal(error.cwd, "/repo/apps/mobile"); | |
| 79 | assert.equal(error.shell, false); | |
| 80 | assert.equal(error.cause, cause); | |
| 81 | assert.equal( | |
| 82 | error.message, | |
| 83 | "Native static check process operation 'spawn' failed for command 'swiftlint'.", | |
| 84 | ); | |
| 85 | assert.notProperty(error, "args"); | |
| 86 | }); | |
| 18 | 87 | }); |
| 88 | ||
| 89 | it.effect("preserves process wait context and the exact cause", () => { | |
| 90 | const cause = PlatformError.systemError({ | |
| 91 | _tag: "Unknown", | |
| 92 | module: "ChildProcess", | |
| 93 | method: "exitCode", | |
| 94 | description: "status unavailable", | |
| 95 | }); | |
| 96 | ||
| 97 | return Effect.gen(function* () { | |
| 98 | const error = yield* runSwiftLint.pipe( | |
| 99 | Effect.provide(provideSpawner(() => Effect.succeed(processHandle(Effect.fail(cause))))), | |
| 100 | Effect.flip, | |
| 101 | ); | |
| 102 | ||
| 103 | if (error._tag !== "NativeStaticCheckProcessError") { | |
| 104 | return assert.fail(`Unexpected error: ${error._tag}`); | |
| 105 | } | |
| 106 | assert.equal(error.operation, "wait-for-exit"); | |
| 107 | assert.equal(error.command, "swiftlint"); | |
| 108 | assert.equal(error.argumentCount, 2); | |
| 109 | assert.equal(error.cwd, "/repo/apps/mobile"); | |
| 110 | assert.equal(error.shell, false); | |
| 111 | assert.equal(error.cause, cause); | |
| 112 | assert.equal( | |
| 113 | error.message, | |
| 114 | "Native static check process operation 'wait-for-exit' failed for command 'swiftlint'.", | |
| 115 | ); | |
| 116 | assert.notProperty(error, "args"); | |
| 117 | }); | |
| 118 | }); | |
| 119 | ||
| 120 | it.effect("reports non-zero exits without manufacturing a cause", () => | |
| 121 | Effect.gen(function* () { | |
| 122 | const error = yield* runSwiftLint.pipe( | |
| 123 | Effect.provide( | |
| 124 | provideSpawner(() => | |
| 125 | Effect.succeed(processHandle(Effect.succeed(ChildProcessSpawner.ExitCode(2)))), | |
| 126 | ), | |
| 127 | ), | |
| 128 | Effect.flip, | |
| 129 | ); | |
| 130 | ||
| 131 | if (error._tag !== "NativeStaticCheckCommandError") { | |
| 132 | return assert.fail(`Unexpected error: ${error._tag}`); | |
| 133 | } | |
| 134 | assert.equal(error.command, "swiftlint"); | |
| 135 | assert.equal(error.argumentCount, 2); | |
| 136 | assert.equal(error.cwd, "/repo/apps/mobile"); | |
| 137 | assert.equal(error.shell, false); | |
| 138 | assert.equal(error.exitCode, 2); | |
| 139 | assert.notProperty(error, "cause"); | |
| 140 | assert.notProperty(error, "args"); | |
| 141 | }), | |
| 142 | ); |
scripts/mobile-native-static-check.ts+102-18| ... | ... | @@ -8,7 +8,6 @@ import * as Effect from "effect/Effect"; |
| 8 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | 9 | import * as Logger from "effect/Logger"; |
| 10 | 10 | import * as Path from "effect/Path"; |
| 11 | import * as PlatformError from "effect/PlatformError"; | |
| 12 | 11 | import * as Schema from "effect/Schema"; |
| 13 | 12 | import { Command } from "effect/unstable/cli"; |
| 14 | 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| ... | ... | @@ -18,12 +17,44 @@ interface NativeStaticTool { |
| 18 | 17 | readonly installHint: string; |
| 19 | 18 | } |
| 20 | 19 | |
| 20 | const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | |
| 21 | ||
| 22 | export class NativeStaticCheckSourceDiscoveryError extends Schema.TaggedErrorClass<NativeStaticCheckSourceDiscoveryError>()( | |
| 23 | "NativeStaticCheckSourceDiscoveryError", | |
| 24 | { | |
| 25 | operation: Schema.Literals(["resolve-root", "read-directory", "stat-entry"]), | |
| 26 | path: Schema.String, | |
| 27 | cause: Schema.Defect(), | |
| 28 | }, | |
| 29 | ) { | |
| 30 | override get message(): string { | |
| 31 | return `Native source discovery operation '${this.operation}' failed.`; | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | export class NativeStaticCheckProcessError extends Schema.TaggedErrorClass<NativeStaticCheckProcessError>()( | |
| 36 | "NativeStaticCheckProcessError", | |
| 37 | { | |
| 38 | operation: Schema.Literals(["spawn", "wait-for-exit"]), | |
| 39 | command: Schema.String, | |
| 40 | argumentCount: NonNegativeInt, | |
| 41 | cwd: Schema.String, | |
| 42 | shell: Schema.Boolean, | |
| 43 | cause: Schema.Defect(), | |
| 44 | }, | |
| 45 | ) { | |
| 46 | override get message(): string { | |
| 47 | return `Native static check process operation '${this.operation}' failed for command '${this.command}'.`; | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 21 | 51 | export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass<NativeStaticCheckCommandError>()( |
| 22 | 52 | "NativeStaticCheckCommandError", |
| 23 | 53 | { |
| 24 | 54 | command: Schema.String, |
| 25 | args: Schema.Array(Schema.String), | |
| 55 | argumentCount: NonNegativeInt, | |
| 26 | 56 | cwd: Schema.String, |
| 57 | shell: Schema.Boolean, | |
| 27 | 58 | exitCode: Schema.Int, |
| 28 | 59 | }, |
| 29 | 60 | ) { |
| ... | ... | @@ -59,8 +90,17 @@ const excludedDirectories = new Set([ |
| 59 | 90 | ]); |
| 60 | 91 | const generatedNativeProjectDirectories = new Set(["android", "ios"]); |
| 61 | 92 | |
| 93 | const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url); | |
| 62 | 94 | const appRoot = Effect.service(Path.Path).pipe( |
| 63 | Effect.flatMap((path) => path.fromFileUrl(new URL("../apps/mobile", import.meta.url))), | |
| 95 | Effect.flatMap((path) => path.fromFileUrl(mobileAppRootUrl)), | |
| 96 | Effect.mapError( | |
| 97 | (cause) => | |
| 98 | new NativeStaticCheckSourceDiscoveryError({ | |
| 99 | operation: "resolve-root", | |
| 100 | path: mobileAppRootUrl.pathname, | |
| 101 | cause, | |
| 102 | }), | |
| 103 | ), | |
| 64 | 104 | ); |
| 65 | 105 | |
| 66 | 106 | const commandOutputOptions = { |
| ... | ... | @@ -77,7 +117,7 @@ const warnMissingTool = (tool: NativeStaticTool, checkName: string) => |
| 77 | 117 | `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`, |
| 78 | 118 | ); |
| 79 | 119 | |
| 80 | const runCommand = Effect.fn("runCommand")(function* ( | |
| 120 | export const runCommand = Effect.fn("runCommand")(function* ( | |
| 81 | 121 | command: string, |
| 82 | 122 | args: ReadonlyArray<string>, |
| 83 | 123 | cwd: string, |
| ... | ... | @@ -85,42 +125,86 @@ const runCommand = Effect.fn("runCommand")(function* ( |
| 85 | 125 | yield* Console.log(`$ ${[command, ...args].join(" ")}`); |
| 86 | 126 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 87 | 127 | const spawnCommand = yield* resolveSpawnCommand(command, args); |
| 88 | const child = yield* spawner.spawn( | |
| 89 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 90 | cwd, | |
| 91 | ...commandOutputOptions, | |
| 92 | shell: spawnCommand.shell, | |
| 93 | }), | |
| 128 | const processContext = { | |
| 129 | command, | |
| 130 | argumentCount: spawnCommand.args.length, | |
| 131 | cwd, | |
| 132 | shell: spawnCommand.shell, | |
| 133 | } as const; | |
| 134 | const child = yield* spawner | |
| 135 | .spawn( | |
| 136 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 137 | cwd, | |
| 138 | ...commandOutputOptions, | |
| 139 | shell: spawnCommand.shell, | |
| 140 | }), | |
| 141 | ) | |
| 142 | .pipe( | |
| 143 | Effect.mapError( | |
| 144 | (cause) => | |
| 145 | new NativeStaticCheckProcessError({ | |
| 146 | ...processContext, | |
| 147 | operation: "spawn", | |
| 148 | cause, | |
| 149 | }), | |
| 150 | ), | |
| 151 | ); | |
| 152 | const exitCode = Number( | |
| 153 | yield* child.exitCode.pipe( | |
| 154 | Effect.mapError( | |
| 155 | (cause) => | |
| 156 | new NativeStaticCheckProcessError({ | |
| 157 | ...processContext, | |
| 158 | operation: "wait-for-exit", | |
| 159 | cause, | |
| 160 | }), | |
| 161 | ), | |
| 162 | ), | |
| 94 | 163 | ); |
| 95 | const exitCode = Number(yield* child.exitCode); | |
| 96 | 164 | |
| 97 | 165 | if (exitCode !== 0) { |
| 98 | 166 | return yield* new NativeStaticCheckCommandError({ |
| 99 | command, | |
| 100 | args, | |
| 101 | cwd, | |
| 167 | ...processContext, | |
| 102 | 168 | exitCode, |
| 103 | 169 | }); |
| 104 | 170 | } |
| 105 | 171 | }); |
| 106 | 172 | |
| 107 | function collectSources( | |
| 173 | export function collectSources( | |
| 108 | 174 | directory: string, |
| 109 | 175 | root: string, |
| 110 | 176 | ): Effect.Effect< |
| 111 | 177 | ReadonlyArray<string>, |
| 112 | PlatformError.PlatformError, | |
| 178 | NativeStaticCheckSourceDiscoveryError, | |
| 113 | 179 | FileSystem.FileSystem | Path.Path |
| 114 | 180 | > { |
| 115 | 181 | return Effect.gen(function* () { |
| 116 | 182 | const fs = yield* FileSystem.FileSystem; |
| 117 | 183 | const path = yield* Path.Path; |
| 118 | const entries = yield* fs.readDirectory(directory); | |
| 184 | const entries = yield* fs.readDirectory(directory).pipe( | |
| 185 | Effect.mapError( | |
| 186 | (cause) => | |
| 187 | new NativeStaticCheckSourceDiscoveryError({ | |
| 188 | operation: "read-directory", | |
| 189 | path: directory, | |
| 190 | cause, | |
| 191 | }), | |
| 192 | ), | |
| 193 | ); | |
| 119 | 194 | const sources: Array<string> = []; |
| 120 | 195 | |
| 121 | 196 | for (const entry of entries) { |
| 122 | 197 | const entryPath = path.join(directory, entry); |
| 123 | const stat = yield* fs.stat(entryPath); | |
| 198 | const stat = yield* fs.stat(entryPath).pipe( | |
| 199 | Effect.mapError( | |
| 200 | (cause) => | |
| 201 | new NativeStaticCheckSourceDiscoveryError({ | |
| 202 | operation: "stat-entry", | |
| 203 | path: entryPath, | |
| 204 | cause, | |
| 205 | }), | |
| 206 | ), | |
| 207 | ); | |
| 124 | 208 | |
| 125 | 209 | if (stat.type === "Directory") { |
| 126 | 210 | const isGeneratedNativeProjectDirectory = |