laneyard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/src/cli/add.js +81 -0
- package/dist/src/cli/detect.js +70 -0
- package/dist/src/config/load.js +46 -0
- package/dist/src/config/resolve.js +29 -0
- package/dist/src/config/schema.js +55 -0
- package/dist/src/config/store.js +71 -0
- package/dist/src/db/cache.js +22 -0
- package/dist/src/db/open.js +12 -0
- package/dist/src/db/runs.js +97 -0
- package/dist/src/db/schema.sql +45 -0
- package/dist/src/git/workspace.js +106 -0
- package/dist/src/heuristics/error-summary.js +43 -0
- package/dist/src/logs/store.js +66 -0
- package/dist/src/main.js +124 -0
- package/dist/src/runner/artifacts.js +59 -0
- package/dist/src/runner/live-steps.js +38 -0
- package/dist/src/runner/orchestrate.js +140 -0
- package/dist/src/runner/pty.js +83 -0
- package/dist/src/runner/report.js +86 -0
- package/dist/src/server/app.js +84 -0
- package/dist/src/server/auth.js +81 -0
- package/dist/src/server/routes/projects.js +35 -0
- package/dist/src/server/routes/runs.js +100 -0
- package/dist/src/server/ws.js +66 -0
- package/dist/src/sidecar/bridge.js +44 -0
- package/dist/src/sidecar/lanes.js +40 -0
- package/dist/src/sidecar/ruby-env.js +63 -0
- package/dist/tests/cli/add.test.js +64 -0
- package/dist/tests/cli/detect.test.js +63 -0
- package/dist/tests/config/load.test.js +68 -0
- package/dist/tests/config/resolve.test.js +44 -0
- package/dist/tests/config/store.test.js +58 -0
- package/dist/tests/db/runs.test.js +54 -0
- package/dist/tests/e2e/full-thread.test.js +65 -0
- package/dist/tests/fixtures/repos.js +30 -0
- package/dist/tests/git/workspace.test.js +66 -0
- package/dist/tests/heuristics/error-summary.test.js +31 -0
- package/dist/tests/logs/store.test.js +38 -0
- package/dist/tests/main.test.js +27 -0
- package/dist/tests/ruby/introspect.test.js +59 -0
- package/dist/tests/runner/artifacts.test.js +49 -0
- package/dist/tests/runner/live-steps.test.js +35 -0
- package/dist/tests/runner/orchestrate.test.js +124 -0
- package/dist/tests/runner/pty.test.js +53 -0
- package/dist/tests/runner/report.test.js +91 -0
- package/dist/tests/server/api.test.js +132 -0
- package/dist/tests/server/auth.test.js +53 -0
- package/dist/tests/server/ws.test.js +43 -0
- package/dist/tests/sidecar/lanes.test.js +52 -0
- package/dist/tests/sidecar/ruby-env.test.js +25 -0
- package/dist/tests/smoke.test.js +7 -0
- package/dist/web/assets/index-583x0xuo.css +1 -0
- package/dist/web/assets/index-BEpABKPS.js +61 -0
- package/dist/web/index.html +13 -0
- package/package.json +70 -0
- package/ruby/introspect.rb +87 -0
- package/scripts/fix-node-pty-permissions.mjs +26 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { Workspace } from "../../src/git/workspace.js";
|
|
5
|
+
import { commitTo, makeOriginRepo, tmpDir } from "../fixtures/repos.js";
|
|
6
|
+
describe("Workspace", () => {
|
|
7
|
+
it("clones on first access then declares itself ready", async () => {
|
|
8
|
+
const origin = await makeOriginRepo({ "README.md": "hello" });
|
|
9
|
+
const ws = new Workspace(join(await tmpDir(), "p"), origin);
|
|
10
|
+
expect(await ws.exists()).toBe(false);
|
|
11
|
+
await ws.prepare("main");
|
|
12
|
+
expect(await ws.exists()).toBe(true);
|
|
13
|
+
expect(await readFile(join(ws.path, "README.md"), "utf8")).toBe("hello");
|
|
14
|
+
}, 30_000);
|
|
15
|
+
it("fetches new commits on the next run", async () => {
|
|
16
|
+
const origin = await makeOriginRepo({ "a.txt": "v1" });
|
|
17
|
+
const ws = new Workspace(join(await tmpDir(), "p"), origin);
|
|
18
|
+
await ws.prepare("main");
|
|
19
|
+
const sha = await commitTo(origin, "a.txt", "v2");
|
|
20
|
+
await ws.prepare("main");
|
|
21
|
+
expect(await readFile(join(ws.path, "a.txt"), "utf8")).toBe("v2");
|
|
22
|
+
expect(await ws.headSha()).toBe(sha);
|
|
23
|
+
}, 30_000);
|
|
24
|
+
it("refuses to prepare over uncommitted changes", async () => {
|
|
25
|
+
const origin = await makeOriginRepo({ "a.txt": "v1" });
|
|
26
|
+
const ws = new Workspace(join(await tmpDir(), "p"), origin);
|
|
27
|
+
await ws.prepare("main");
|
|
28
|
+
await writeFile(join(ws.path, "a.txt"), "edited by hand", "utf8");
|
|
29
|
+
expect(await ws.isDirty()).toBe(true);
|
|
30
|
+
await expect(ws.prepare("main")).rejects.toThrow(/uncommitted/i);
|
|
31
|
+
}, 30_000);
|
|
32
|
+
it("doesn't declare itself dirty for untracked files left by a build", async () => {
|
|
33
|
+
const origin = await makeOriginRepo({ "a.txt": "v1" });
|
|
34
|
+
const ws = new Workspace(join(await tmpDir(), "p"), origin);
|
|
35
|
+
await ws.prepare("main");
|
|
36
|
+
// What a real run produces: fastlane rewrites its README, the build outputs a binary.
|
|
37
|
+
await writeFile(join(ws.path, "README-fastlane.md"), "generated", "utf8");
|
|
38
|
+
expect(await ws.isDirty()).toBe(false);
|
|
39
|
+
// And the next run must be able to prepare the workspace.
|
|
40
|
+
await expect(ws.prepare("main")).resolves.toMatch(/^[0-9a-f]{40}$/);
|
|
41
|
+
}, 30_000);
|
|
42
|
+
it("fails readably on an unknown branch", async () => {
|
|
43
|
+
const origin = await makeOriginRepo({ "a.txt": "v1" });
|
|
44
|
+
const ws = new Workspace(join(await tmpDir(), "p"), origin);
|
|
45
|
+
await expect(ws.prepare("does-not-exist")).rejects.toThrow(/does-not-exist/);
|
|
46
|
+
}, 30_000);
|
|
47
|
+
it("clones on demand without switching branch", async () => {
|
|
48
|
+
const origin = await makeOriginRepo({ "laneyard.yml": "runtime: system\n" });
|
|
49
|
+
const ws = new Workspace(join(await tmpDir(), "p"), origin);
|
|
50
|
+
await ws.ensureCloned();
|
|
51
|
+
expect(await ws.exists()).toBe(true);
|
|
52
|
+
// Idempotent: a second call redoes nothing and doesn't throw.
|
|
53
|
+
await ws.ensureCloned();
|
|
54
|
+
expect(await ws.exists()).toBe(true);
|
|
55
|
+
}, 30_000);
|
|
56
|
+
it("never copies the repository URL into an error message", async () => {
|
|
57
|
+
// An HTTPS URL can carry a token; these messages end up in the run's log.
|
|
58
|
+
const secret = "https://user:ghp_TRESSECRET@example.invalid/m/demo.git";
|
|
59
|
+
const ws = new Workspace(join(await tmpDir(), "p"), secret);
|
|
60
|
+
await expect(ws.prepare("main")).rejects.toThrow();
|
|
61
|
+
await ws.prepare("main").catch((err) => {
|
|
62
|
+
expect(err.message).not.toContain("ghp_TRESSECRET");
|
|
63
|
+
expect(err.message).toContain("<repository>");
|
|
64
|
+
});
|
|
65
|
+
}, 30_000);
|
|
66
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { summarizeFailure } from "../../src/heuristics/error-summary.js";
|
|
3
|
+
/** Output tail observed on a real failed run, ANSI sequences included. */
|
|
4
|
+
const REAL_TAIL = [
|
|
5
|
+
"[13:19:32]: [31mCalled from Fastfile at line 7[0m",
|
|
6
|
+
'[13:19:32]: => 7:\t UI.user_error!("code signature rejected by the keychain")',
|
|
7
|
+
"[13:19:32]: [31mfastlane finished with errors[0m",
|
|
8
|
+
"[31m[!] code signature rejected by the keychain[0m",
|
|
9
|
+
].join("\n");
|
|
10
|
+
describe("summarizeFailure", () => {
|
|
11
|
+
it("keeps the cause marked by fastlane, without ANSI or marker", () => {
|
|
12
|
+
expect(summarizeFailure(REAL_TAIL, 1)).toBe("code signature rejected by the keychain");
|
|
13
|
+
});
|
|
14
|
+
it("discards the generic closing message, which teaches nothing", () => {
|
|
15
|
+
const log = "[13:19:32]: Compiling\n[13:19:32]: fastlane finished with errors";
|
|
16
|
+
expect(summarizeFailure(log, 1)).not.toMatch(/finished with errors/);
|
|
17
|
+
});
|
|
18
|
+
it("falls back to a line mentioning an error when the marker is missing", () => {
|
|
19
|
+
const log = "[10:00:00]: Compiling\n[10:00:01]: error: no signing certificate found\n[10:00:02]: bye";
|
|
20
|
+
expect(summarizeFailure(log, 65)).toBe("error: no signing certificate found");
|
|
21
|
+
});
|
|
22
|
+
it("falls back to the exit code when the output teaches nothing", () => {
|
|
23
|
+
expect(summarizeFailure("[10:00:00]: all good", 65)).toBe("fastlane stopped with exit code 65");
|
|
24
|
+
});
|
|
25
|
+
it("stays readable when the run produced no output", () => {
|
|
26
|
+
expect(summarizeFailure("", null)).toBe("The run failed with no usable message");
|
|
27
|
+
});
|
|
28
|
+
it("truncates an oversized cause rather than flooding the list", () => {
|
|
29
|
+
expect(summarizeFailure(`[!] ${"x".repeat(900)}`, 1)).toHaveLength(500);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { LogStore } from "../../src/logs/store.js";
|
|
4
|
+
import { tmpDir } from "../fixtures/repos.js";
|
|
5
|
+
describe("LogStore", () => {
|
|
6
|
+
it("writes and reads back in full", async () => {
|
|
7
|
+
const store = new LogStore(await tmpDir("laneyard-logs-"));
|
|
8
|
+
const w = await store.open(1);
|
|
9
|
+
await w.append("first line\n");
|
|
10
|
+
await w.append("second line\n");
|
|
11
|
+
await w.close();
|
|
12
|
+
expect(await store.read(1)).toBe("first line\nsecond line\n");
|
|
13
|
+
});
|
|
14
|
+
it("reads back from a byte offset", async () => {
|
|
15
|
+
const store = new LogStore(await tmpDir("laneyard-logs-"));
|
|
16
|
+
const w = await store.open(2);
|
|
17
|
+
await w.append("abcdef");
|
|
18
|
+
await w.close();
|
|
19
|
+
expect(await store.read(2, 3)).toBe("def");
|
|
20
|
+
});
|
|
21
|
+
it("exposes the current offset after every write", async () => {
|
|
22
|
+
const store = new LogStore(await tmpDir("laneyard-logs-"));
|
|
23
|
+
const w = await store.open(3);
|
|
24
|
+
expect(w.offset).toBe(0);
|
|
25
|
+
await w.append("hüllo"); // 6 bytes in UTF-8, not 5
|
|
26
|
+
expect(w.offset).toBe(6);
|
|
27
|
+
await w.close();
|
|
28
|
+
});
|
|
29
|
+
it("returns an empty string for a run with no log", async () => {
|
|
30
|
+
const store = new LogStore(await tmpDir("laneyard-logs-"));
|
|
31
|
+
expect(await store.read(999)).toBe("");
|
|
32
|
+
});
|
|
33
|
+
it("places the file in the configured folder", async () => {
|
|
34
|
+
const dir = await tmpDir("laneyard-logs-");
|
|
35
|
+
const store = new LogStore(dir);
|
|
36
|
+
expect(store.pathFor(7)).toBe(join(dir, "7.log"));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { createServerFromConfig } from "../src/main.js";
|
|
5
|
+
import { hashPassword } from "../src/server/auth.js";
|
|
6
|
+
import { openDatabase } from "../src/db/open.js";
|
|
7
|
+
import { RunStore } from "../src/db/runs.js";
|
|
8
|
+
import { tmpDir } from "./fixtures/repos.js";
|
|
9
|
+
describe("createServerFromConfig", () => {
|
|
10
|
+
it("refuses to start if the configuration is invalid", async () => {
|
|
11
|
+
const root = await tmpDir("laneyard-main-");
|
|
12
|
+
await writeFile(join(root, "config.yml"), "projects: [", "utf8");
|
|
13
|
+
await expect(createServerFromConfig(root)).rejects.toThrow(/configuration/i);
|
|
14
|
+
});
|
|
15
|
+
it("marks runs still active at startup as interrupted", async () => {
|
|
16
|
+
const root = await tmpDir("laneyard-main-");
|
|
17
|
+
await mkdir(root, { recursive: true });
|
|
18
|
+
await writeFile(join(root, "config.yml"), `server: { password_hash: "${hashPassword("x")}" }\nprojects: []\n`, "utf8");
|
|
19
|
+
const dbPath = join(root, "laneyard.db");
|
|
20
|
+
const runs = new RunStore(openDatabase(dbPath));
|
|
21
|
+
const id = runs.create({ projectSlug: "p", lane: "a", platform: null, params: {} });
|
|
22
|
+
runs.markRunning(id, { branch: "main", commitSha: "x" });
|
|
23
|
+
const { app, db } = await createServerFromConfig(root);
|
|
24
|
+
await app.close();
|
|
25
|
+
expect(new RunStore(db).get(id)?.status).toBe("interrupted");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { resolveRubyEnv } from "../../src/sidecar/ruby-env.js";
|
|
7
|
+
import { tmpDir } from "../fixtures/repos.js";
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const SCRIPT = join(process.cwd(), "ruby", "introspect.rb");
|
|
10
|
+
async function projectWithFastfile(content) {
|
|
11
|
+
const dir = await tmpDir("laneyard-fl-");
|
|
12
|
+
await mkdir(join(dir, "fastlane"), { recursive: true });
|
|
13
|
+
await writeFile(join(dir, "fastlane", "Fastfile"), content, "utf8");
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
async function introspect(dir, cmd) {
|
|
17
|
+
// The sidecar runs here without bundle: it needs the resolved environment.
|
|
18
|
+
const ruby = await resolveRubyEnv();
|
|
19
|
+
if (!ruby)
|
|
20
|
+
throw new Error("fastlane not found for the current Ruby");
|
|
21
|
+
const { stdout } = await exec("ruby", [SCRIPT, cmd, "--fastlane-dir", "fastlane"], {
|
|
22
|
+
cwd: dir,
|
|
23
|
+
env: ruby.env,
|
|
24
|
+
timeout: 180_000,
|
|
25
|
+
});
|
|
26
|
+
return JSON.parse(stdout);
|
|
27
|
+
}
|
|
28
|
+
describe("introspect.rb lanes", () => {
|
|
29
|
+
it("lists lanes with platform and description", async () => {
|
|
30
|
+
const dir = await projectWithFastfile(`
|
|
31
|
+
platform :ios do
|
|
32
|
+
desc "Push a new beta build to TestFlight"
|
|
33
|
+
lane :beta do
|
|
34
|
+
increment_build_number
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private_lane :helper do
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
lane :global do
|
|
42
|
+
end
|
|
43
|
+
`);
|
|
44
|
+
const res = (await introspect(dir, "lanes"));
|
|
45
|
+
expect(res.ok).toBe(true);
|
|
46
|
+
const beta = res.lanes.find((l) => l.name === "beta");
|
|
47
|
+
expect(beta).toBeDefined();
|
|
48
|
+
expect(beta.platform).toBe("ios");
|
|
49
|
+
expect(beta.description).toBe("Push a new beta build to TestFlight");
|
|
50
|
+
expect(res.lanes.find((l) => l.name === "global")?.platform).toBeNull();
|
|
51
|
+
expect(res.lanes.find((l) => l.name === "helper")?.private).toBe(true);
|
|
52
|
+
}, 180_000);
|
|
53
|
+
it("returns a structured error on an invalid Fastfile", async () => {
|
|
54
|
+
const dir = await projectWithFastfile("lane :beta do\n # never closed\n");
|
|
55
|
+
const res = (await introspect(dir, "lanes"));
|
|
56
|
+
expect(res.ok).toBe(false);
|
|
57
|
+
expect(res.error.length).toBeGreaterThan(0);
|
|
58
|
+
}, 180_000);
|
|
59
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { collectArtifacts, guessKind } from "../../src/runner/artifacts.js";
|
|
5
|
+
import { tmpDir } from "../fixtures/repos.js";
|
|
6
|
+
async function workspaceWith(files) {
|
|
7
|
+
const dir = await tmpDir("laneyard-art-");
|
|
8
|
+
for (const f of files) {
|
|
9
|
+
await mkdir(join(dir, f, ".."), { recursive: true });
|
|
10
|
+
await writeFile(join(dir, f), "content", "utf8");
|
|
11
|
+
}
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
describe("guessKind", () => {
|
|
15
|
+
it("recognizes the common types", () => {
|
|
16
|
+
expect(guessKind("Sample.ipa")).toBe("ipa");
|
|
17
|
+
expect(guessKind("app-release.aab")).toBe("aab");
|
|
18
|
+
expect(guessKind("app.apk")).toBe("apk");
|
|
19
|
+
expect(guessKind("Sample.app.dSYM.zip")).toBe("dsym");
|
|
20
|
+
expect(guessKind("notes.txt")).toBe("other");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe("collectArtifacts", () => {
|
|
24
|
+
it("moves files matching the patterns out of the workspace", async () => {
|
|
25
|
+
const ws = await workspaceWith(["build/Sample.ipa", "build/notes.txt"]);
|
|
26
|
+
const dest = await tmpDir("laneyard-dest-");
|
|
27
|
+
const found = await collectArtifacts(ws, ["build/**/*.ipa"], dest);
|
|
28
|
+
expect(found).toHaveLength(1);
|
|
29
|
+
expect(found[0].filename).toBe("Sample.ipa");
|
|
30
|
+
expect(found[0].kind).toBe("ipa");
|
|
31
|
+
expect(found[0].size).toBeGreaterThan(0);
|
|
32
|
+
expect(await readdir(dest)).toEqual(["Sample.ipa"]);
|
|
33
|
+
});
|
|
34
|
+
it("returns nothing when no pattern is configured", async () => {
|
|
35
|
+
const ws = await workspaceWith(["build/Sample.ipa"]);
|
|
36
|
+
expect(await collectArtifacts(ws, [], await tmpDir())).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
it("disambiguates two files with the same name", async () => {
|
|
39
|
+
const ws = await workspaceWith(["a/app.apk", "b/app.apk"]);
|
|
40
|
+
const dest = await tmpDir("laneyard-dest-");
|
|
41
|
+
const found = await collectArtifacts(ws, ["**/*.apk"], dest);
|
|
42
|
+
expect(found).toHaveLength(2);
|
|
43
|
+
expect(new Set(found.map((f) => f.filename)).size).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
it("ignores a pattern that matches nothing without failing", async () => {
|
|
46
|
+
const ws = await workspaceWith(["build/Sample.ipa"]);
|
|
47
|
+
expect(await collectArtifacts(ws, ["does-not-exist/**/*.zip"], await tmpDir())).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { LiveStepTracker } from "../../src/runner/live-steps.js";
|
|
3
|
+
describe("LiveStepTracker", () => {
|
|
4
|
+
it("spots a step and keeps its offset", () => {
|
|
5
|
+
const t = new LiveStepTracker();
|
|
6
|
+
t.consume("[09:41:02]: noise before\n", 0);
|
|
7
|
+
t.consume("[09:41:03]: ------ Step: build_app ------\n", 30);
|
|
8
|
+
expect(t.steps()).toEqual([{ name: "build_app", logOffset: 30 }]);
|
|
9
|
+
});
|
|
10
|
+
it("spots several steps in order of appearance", () => {
|
|
11
|
+
const t = new LiveStepTracker();
|
|
12
|
+
t.consume("[t]: --- Step: match ---\n[t]: --- Step: build_app ---\n", 100);
|
|
13
|
+
expect(t.steps().map((s) => s.name)).toEqual(["match", "build_app"]);
|
|
14
|
+
expect(t.steps()[0].logOffset).toBe(100);
|
|
15
|
+
expect(t.steps()[1].logOffset).toBeGreaterThan(100);
|
|
16
|
+
});
|
|
17
|
+
it("rejoins a line split across two fragments", () => {
|
|
18
|
+
const t = new LiveStepTracker();
|
|
19
|
+
t.consume("[t]: ------ Step: buil", 0);
|
|
20
|
+
t.consume("d_app ------\n", 17);
|
|
21
|
+
expect(t.steps().map((s) => s.name)).toEqual(["build_app"]);
|
|
22
|
+
});
|
|
23
|
+
it("spots a real fastlane separator, ANSI colors included", () => {
|
|
24
|
+
const t = new LiveStepTracker();
|
|
25
|
+
// Line copied from a real run: the name of a `sh` action is the
|
|
26
|
+
// entire command, spaces included.
|
|
27
|
+
t.consume("[13:14:00]: [32m--- Step: mkdir -p ../build && echo x > y.ipa ---[0m\r\n", 0);
|
|
28
|
+
expect(t.steps().map((s) => s.name)).toEqual(["mkdir -p ../build && echo x > y.ipa"]);
|
|
29
|
+
});
|
|
30
|
+
it("ignores a line that mentions Step without being a separator", () => {
|
|
31
|
+
const t = new LiveStepTracker();
|
|
32
|
+
t.consume("The word Step: appears here with no dashes\n", 0);
|
|
33
|
+
expect(t.steps()).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { openDatabase } from "../../src/db/open.js";
|
|
4
|
+
import { RunStore } from "../../src/db/runs.js";
|
|
5
|
+
import { LogStore } from "../../src/logs/store.js";
|
|
6
|
+
import { executeRun } from "../../src/runner/orchestrate.js";
|
|
7
|
+
import { makeOriginRepo, tmpDir } from "../fixtures/repos.js";
|
|
8
|
+
const FAKE_DIR = join(process.cwd(), "tests", "fixtures", "fake-fastlane");
|
|
9
|
+
const SETTINGS = {
|
|
10
|
+
fastlane_dir: "fastlane",
|
|
11
|
+
runtime: "system",
|
|
12
|
+
timeout_minutes: 5,
|
|
13
|
+
interactive_default: false,
|
|
14
|
+
artifact_globs: ["build/**/*.ipa"],
|
|
15
|
+
required_secrets: [],
|
|
16
|
+
};
|
|
17
|
+
async function harness(scenario) {
|
|
18
|
+
const origin = await makeOriginRepo({
|
|
19
|
+
"fastlane/Fastfile": "lane :beta do\nend\n",
|
|
20
|
+
// build/ is ignored: the artifact is produced by the fake fastlane during
|
|
21
|
+
// the run, just like the real one. Nothing tracked by git is moved, so
|
|
22
|
+
// the workspace stays clean for the next run.
|
|
23
|
+
".gitignore": "build/\n",
|
|
24
|
+
});
|
|
25
|
+
const root = await tmpDir("laneyard-root-");
|
|
26
|
+
const db = openDatabase(":memory:");
|
|
27
|
+
const runs = new RunStore(db);
|
|
28
|
+
const logs = new LogStore(join(root, "logs"));
|
|
29
|
+
const runId = runs.create({ projectSlug: "p", lane: "beta", platform: null, params: {} });
|
|
30
|
+
const result = await executeRun({
|
|
31
|
+
runId,
|
|
32
|
+
runs,
|
|
33
|
+
logs,
|
|
34
|
+
workspacePath: join(root, "workspaces", "p"),
|
|
35
|
+
artifactsDir: join(root, "artifacts", String(runId)),
|
|
36
|
+
gitUrl: origin,
|
|
37
|
+
branch: "main",
|
|
38
|
+
resolveSettings: async () => SETTINGS,
|
|
39
|
+
env: { PATH: `${FAKE_DIR}:${process.env["PATH"]}`, FAKE_FASTLANE_SCENARIO: scenario },
|
|
40
|
+
onChunk: () => { },
|
|
41
|
+
});
|
|
42
|
+
return { runId, runs, logs, result };
|
|
43
|
+
}
|
|
44
|
+
describe("executeRun", () => {
|
|
45
|
+
it("carries a run through to success end to end", async () => {
|
|
46
|
+
const { runId, runs, logs } = await harness("success");
|
|
47
|
+
const run = runs.get(runId);
|
|
48
|
+
expect(run.status).toBe("success");
|
|
49
|
+
expect(run.exitCode).toBe(0);
|
|
50
|
+
expect(run.commitSha).toMatch(/^[0-9a-f]{40}$/);
|
|
51
|
+
expect(run.startedAt).not.toBeNull();
|
|
52
|
+
expect(await logs.read(runId)).toContain("Step: build_app");
|
|
53
|
+
}, 60_000);
|
|
54
|
+
it("records the report's steps with the live-spotting offset", async () => {
|
|
55
|
+
const { runId, runs } = await harness("success");
|
|
56
|
+
const steps = runs.steps(runId);
|
|
57
|
+
expect(steps.map((s) => s.name)).toEqual(["match", "build_app"]);
|
|
58
|
+
expect(steps[0].source).toBe("report");
|
|
59
|
+
expect(steps[0].durationMs).toBe(1500);
|
|
60
|
+
expect(steps[1].logOffset).toBeGreaterThan(0);
|
|
61
|
+
}, 60_000);
|
|
62
|
+
it("collects the artifacts matching the patterns", async () => {
|
|
63
|
+
const { runId, runs } = await harness("success");
|
|
64
|
+
const arts = runs.artifacts(runId);
|
|
65
|
+
expect(arts).toHaveLength(1);
|
|
66
|
+
expect(arts[0].filename).toBe("Sample.ipa");
|
|
67
|
+
expect(arts[0].kind).toBe("ipa");
|
|
68
|
+
}, 60_000);
|
|
69
|
+
it("marks the failure and keeps an error summary", async () => {
|
|
70
|
+
const { runId, runs } = await harness("failure");
|
|
71
|
+
const run = runs.get(runId);
|
|
72
|
+
expect(run.status).toBe("failed");
|
|
73
|
+
expect(run.exitCode).toBe(1);
|
|
74
|
+
expect(runs.steps(runId).find((s) => s.name === "build_app")?.status).toBe("failed");
|
|
75
|
+
}, 60_000);
|
|
76
|
+
it("fails cleanly if resolving settings throws", async () => {
|
|
77
|
+
const origin = await makeOriginRepo({ "fastlane/Fastfile": "lane :beta do\nend\n" });
|
|
78
|
+
const root = await tmpDir("laneyard-root-");
|
|
79
|
+
const runs = new RunStore(openDatabase(":memory:"));
|
|
80
|
+
const logs = new LogStore(join(root, "logs"));
|
|
81
|
+
const runId = runs.create({ projectSlug: "p", lane: "beta", platform: null, params: {} });
|
|
82
|
+
await executeRun({
|
|
83
|
+
runId,
|
|
84
|
+
runs,
|
|
85
|
+
logs,
|
|
86
|
+
workspacePath: join(root, "ws"),
|
|
87
|
+
artifactsDir: join(root, "art"),
|
|
88
|
+
gitUrl: origin,
|
|
89
|
+
branch: "main",
|
|
90
|
+
// Real case: the project disappeared from config.yml during preparation.
|
|
91
|
+
resolveSettings: async () => {
|
|
92
|
+
throw new Error("unknown project");
|
|
93
|
+
},
|
|
94
|
+
env: {},
|
|
95
|
+
onChunk: () => { },
|
|
96
|
+
});
|
|
97
|
+
const run = runs.get(runId);
|
|
98
|
+
expect(run.status).toBe("failed");
|
|
99
|
+
expect(run.errorSummary).toMatch(/unknown project/);
|
|
100
|
+
}, 60_000);
|
|
101
|
+
it("fails before launch if the repository is unreachable", async () => {
|
|
102
|
+
const root = await tmpDir("laneyard-root-");
|
|
103
|
+
const runs = new RunStore(openDatabase(":memory:"));
|
|
104
|
+
const logs = new LogStore(join(root, "logs"));
|
|
105
|
+
const runId = runs.create({ projectSlug: "p", lane: "beta", platform: null, params: {} });
|
|
106
|
+
await executeRun({
|
|
107
|
+
runId,
|
|
108
|
+
runs,
|
|
109
|
+
logs,
|
|
110
|
+
workspacePath: join(root, "ws"),
|
|
111
|
+
artifactsDir: join(root, "art"),
|
|
112
|
+
gitUrl: "/nexiste/pas/depot.git",
|
|
113
|
+
branch: "main",
|
|
114
|
+
resolveSettings: async () => SETTINGS,
|
|
115
|
+
env: {},
|
|
116
|
+
onChunk: () => { },
|
|
117
|
+
});
|
|
118
|
+
const run = runs.get(runId);
|
|
119
|
+
expect(run.status).toBe("failed");
|
|
120
|
+
expect(run.errorSummary).toMatch(/git|repository|clone/i);
|
|
121
|
+
// A run that never reached fastlane has no steps.
|
|
122
|
+
expect(runs.steps(runId)).toEqual([]);
|
|
123
|
+
}, 60_000);
|
|
124
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { runInPty } from "../../src/runner/pty.js";
|
|
4
|
+
import { tmpDir } from "../fixtures/repos.js";
|
|
5
|
+
const FAKE_DIR = join(process.cwd(), "tests", "fixtures", "fake-fastlane");
|
|
6
|
+
describe("runInPty", () => {
|
|
7
|
+
it("streams the output and returns a zero exit code on success", async () => {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
const res = await runInPty({
|
|
10
|
+
command: "fastlane",
|
|
11
|
+
args: ["beta"],
|
|
12
|
+
cwd: await tmpDir(),
|
|
13
|
+
env: { PATH: `${FAKE_DIR}:${process.env["PATH"]}`, FAKE_FASTLANE_SCENARIO: "success" },
|
|
14
|
+
onData: (c) => chunks.push(c),
|
|
15
|
+
});
|
|
16
|
+
expect(res.exitCode).toBe(0);
|
|
17
|
+
expect(chunks.join("")).toContain("Step: build_app");
|
|
18
|
+
});
|
|
19
|
+
it("reports the exit code of a failure", async () => {
|
|
20
|
+
const res = await runInPty({
|
|
21
|
+
command: "fastlane",
|
|
22
|
+
args: ["beta"],
|
|
23
|
+
cwd: await tmpDir(),
|
|
24
|
+
env: { PATH: `${FAKE_DIR}:${process.env["PATH"]}`, FAKE_FASTLANE_SCENARIO: "failure" },
|
|
25
|
+
onData: () => { },
|
|
26
|
+
});
|
|
27
|
+
expect(res.exitCode).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
it("kills the process past the allotted timeout", async () => {
|
|
30
|
+
const res = await runInPty({
|
|
31
|
+
command: "fastlane",
|
|
32
|
+
args: ["beta"],
|
|
33
|
+
cwd: await tmpDir(),
|
|
34
|
+
env: { PATH: `${FAKE_DIR}:${process.env["PATH"]}`, FAKE_FASTLANE_SCENARIO: "slow" },
|
|
35
|
+
onData: () => { },
|
|
36
|
+
timeoutMs: 1000,
|
|
37
|
+
});
|
|
38
|
+
expect(res.timedOut).toBe(true);
|
|
39
|
+
// Killed by signal: the code must reflect the violent death, not be 0.
|
|
40
|
+
expect(res.exitCode).not.toBe(0);
|
|
41
|
+
expect(res.signal).not.toBeNull();
|
|
42
|
+
}, 20_000);
|
|
43
|
+
it("fails cleanly if the command doesn't exist", async () => {
|
|
44
|
+
const res = await runInPty({
|
|
45
|
+
command: "nonexistent-command-xyz",
|
|
46
|
+
args: [],
|
|
47
|
+
cwd: await tmpDir(),
|
|
48
|
+
env: { PATH: "/does-not-exist" },
|
|
49
|
+
onData: () => { },
|
|
50
|
+
});
|
|
51
|
+
expect(res.exitCode).not.toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { readReport } from "../../src/runner/report.js";
|
|
5
|
+
import { tmpDir } from "../fixtures/repos.js";
|
|
6
|
+
// Real form observed: successful actions are self-closing.
|
|
7
|
+
const OK = `<?xml version="1.0" encoding="UTF-8"?>
|
|
8
|
+
<testsuites>
|
|
9
|
+
<testsuite name="fastlane.lanes">
|
|
10
|
+
<testcase classname="fastlane.lanes" name="0: match" time="11.5"/>
|
|
11
|
+
<testcase classname="fastlane.lanes" name="1: build_app" time="238.25"/>
|
|
12
|
+
</testsuite>
|
|
13
|
+
</testsuites>`;
|
|
14
|
+
// Mixed report: this is the case that traps a badly ordered pattern.
|
|
15
|
+
const FAILED = `<?xml version="1.0" encoding="UTF-8"?>
|
|
16
|
+
<testsuites>
|
|
17
|
+
<testsuite name="fastlane.lanes">
|
|
18
|
+
<testcase classname="fastlane.lanes" name="0: match" time="1.0"/>
|
|
19
|
+
<testcase classname="fastlane.lanes" name="1: build_app" time="12.0">
|
|
20
|
+
<failure message="Error building the application"></failure>
|
|
21
|
+
</testcase>
|
|
22
|
+
</testsuite>
|
|
23
|
+
</testsuites>`;
|
|
24
|
+
describe("readReport", () => {
|
|
25
|
+
it("extracts name, index, and duration for each action", async () => {
|
|
26
|
+
const dir = await tmpDir("laneyard-rep-");
|
|
27
|
+
await writeFile(join(dir, "report.xml"), OK, "utf8");
|
|
28
|
+
const steps = await readReport(join(dir, "report.xml"));
|
|
29
|
+
expect(steps).toEqual([
|
|
30
|
+
{ idx: 0, name: "match", durationMs: 11_500, status: "success" },
|
|
31
|
+
{ idx: 1, name: "build_app", durationMs: 238_250, status: "success" },
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
it("attributes the failure only to the action concerned in a mixed report", async () => {
|
|
35
|
+
const dir = await tmpDir("laneyard-rep-");
|
|
36
|
+
await writeFile(join(dir, "report.xml"), FAILED, "utf8");
|
|
37
|
+
const steps = await readReport(join(dir, "report.xml"));
|
|
38
|
+
// Narrows the type as much as it verifies: the rest of the test indexes the array.
|
|
39
|
+
if (!steps)
|
|
40
|
+
throw new Error("expected report");
|
|
41
|
+
expect(steps).toHaveLength(2);
|
|
42
|
+
expect(steps[0].status).toBe("success");
|
|
43
|
+
expect(steps[1].name).toBe("build_app");
|
|
44
|
+
expect(steps[1].status).toBe("failed");
|
|
45
|
+
});
|
|
46
|
+
it("decodes the XML entities in an action's name", async () => {
|
|
47
|
+
const dir = await tmpDir("laneyard-rep-");
|
|
48
|
+
// Real case: a `sh` action's name is the command, escaped by the report.
|
|
49
|
+
await writeFile(join(dir, "report.xml"), `<testsuites><testsuite name="fastlane.lanes">
|
|
50
|
+
<testcase classname="fastlane.lanes" name="0: mkdir -p b && echo x > y" time="0.1"/>
|
|
51
|
+
</testsuite></testsuites>`, "utf8");
|
|
52
|
+
const steps = await readReport(join(dir, "report.xml"));
|
|
53
|
+
expect(steps?.[0]?.name).toBe("mkdir -p b && echo x > y");
|
|
54
|
+
});
|
|
55
|
+
it("returns null if the report doesn't exist", async () => {
|
|
56
|
+
const dir = await tmpDir("laneyard-rep-");
|
|
57
|
+
expect(await readReport(join(dir, "report.xml"))).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
it("returns null on an unreadable report rather than throwing", async () => {
|
|
60
|
+
const dir = await tmpDir("laneyard-rep-");
|
|
61
|
+
await writeFile(join(dir, "report.xml"), "<testsuites", "utf8");
|
|
62
|
+
expect(await readReport(join(dir, "report.xml"))).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("readReport — unreliable indexes", () => {
|
|
66
|
+
it("renumbers duplicate indexes rather than letting them into the database", async () => {
|
|
67
|
+
const dir = await tmpDir("laneyard-rep-");
|
|
68
|
+
// Two testsuites, each renumbering from 0: run_step's primary key
|
|
69
|
+
// (run_id, idx) would refuse the insert.
|
|
70
|
+
await writeFile(join(dir, "report.xml"), `<testsuites>
|
|
71
|
+
<testsuite name="fastlane.lanes">
|
|
72
|
+
<testcase classname="fastlane.lanes" name="0: match" time="1"/>
|
|
73
|
+
</testsuite>
|
|
74
|
+
<testsuite name="fastlane.lanes">
|
|
75
|
+
<testcase classname="fastlane.lanes" name="0: build_app" time="2"/>
|
|
76
|
+
<testcase classname="fastlane.lanes" name="no index" time="3"/>
|
|
77
|
+
</testsuite>
|
|
78
|
+
</testsuites>`, "utf8");
|
|
79
|
+
const steps = await readReport(join(dir, "report.xml"));
|
|
80
|
+
if (!steps)
|
|
81
|
+
throw new Error("expected report");
|
|
82
|
+
expect(steps).toHaveLength(3);
|
|
83
|
+
expect(steps.map((s) => s.idx)).toEqual([0, 1, 2]);
|
|
84
|
+
expect(new Set(steps.map((s) => s.idx)).size).toBe(3);
|
|
85
|
+
});
|
|
86
|
+
it("lets an out-of-range numeric entity through without throwing", async () => {
|
|
87
|
+
const dir = await tmpDir("laneyard-rep-");
|
|
88
|
+
await writeFile(join(dir, "report.xml"), '<testsuites><testsuite name="l"><testcase name="0: �" time="1"/></testsuite></testsuites>', "utf8");
|
|
89
|
+
await expect(readReport(join(dir, "report.xml"))).resolves.toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
});
|