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,44 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { FASTLANE_UNAVAILABLE, resolveRubyEnv } from "./ruby-env.js";
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "ruby", "introspect.rb");
|
|
8
|
+
/**
|
|
9
|
+
* Runs the sidecar in the project's context.
|
|
10
|
+
* In `bundle` mode, the invocation goes through `bundle exec` to see the
|
|
11
|
+
* right version of fastlane and the plugins the project declares.
|
|
12
|
+
*/
|
|
13
|
+
export function makeInvoke(runtime) {
|
|
14
|
+
return async (command, cwd, fastlaneDir) => {
|
|
15
|
+
const [bin, args] = runtime === "bundle"
|
|
16
|
+
? ["bundle", ["exec", "ruby", SCRIPT, command, "--fastlane-dir", fastlaneDir]]
|
|
17
|
+
: ["ruby", [SCRIPT, command, "--fastlane-dir", fastlaneDir]];
|
|
18
|
+
// In bundle mode, `bundle exec` already provides the right environment. In
|
|
19
|
+
// system mode, it has to be found: depending on the install, `ruby` may not see fastlane.
|
|
20
|
+
let env = process.env;
|
|
21
|
+
if (runtime === "system") {
|
|
22
|
+
const ruby = await resolveRubyEnv();
|
|
23
|
+
if (!ruby)
|
|
24
|
+
return { ok: false, error: FASTLANE_UNAVAILABLE };
|
|
25
|
+
env = ruby.env;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await exec(bin, args, {
|
|
29
|
+
cwd,
|
|
30
|
+
env,
|
|
31
|
+
timeout: 180_000,
|
|
32
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
return JSON.parse(stdout);
|
|
35
|
+
}
|
|
36
|
+
catch (cause) {
|
|
37
|
+
const err = cause;
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: `The Ruby sidecar failed: ${(err.stderr || err.message).trim()}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Hash of the whole fastlane folder, not just the Fastfile:
|
|
6
|
+
* an Appfile, a Pluginfile, or an imported file change the lanes just as much.
|
|
7
|
+
*/
|
|
8
|
+
async function hashFastlaneDir(root, fastlaneDir) {
|
|
9
|
+
const dir = join(root, fastlaneDir);
|
|
10
|
+
const hash = createHash("sha256");
|
|
11
|
+
const entries = (await readdir(dir, { withFileTypes: true, recursive: true }))
|
|
12
|
+
.filter((e) => e.isFile())
|
|
13
|
+
.map((e) => join(e.parentPath, e.name))
|
|
14
|
+
.sort();
|
|
15
|
+
for (const file of entries) {
|
|
16
|
+
hash.update(file);
|
|
17
|
+
hash.update(await readFile(file));
|
|
18
|
+
}
|
|
19
|
+
return hash.digest("hex");
|
|
20
|
+
}
|
|
21
|
+
export class LaneReader {
|
|
22
|
+
cache;
|
|
23
|
+
invoke;
|
|
24
|
+
constructor(cache, invoke) {
|
|
25
|
+
this.cache = cache;
|
|
26
|
+
this.invoke = invoke;
|
|
27
|
+
}
|
|
28
|
+
async read(slug, workspacePath, fastlaneDir) {
|
|
29
|
+
const hash = await hashFastlaneDir(workspacePath, fastlaneDir);
|
|
30
|
+
const cached = this.cache.get(slug, hash);
|
|
31
|
+
if (cached)
|
|
32
|
+
return cached;
|
|
33
|
+
const res = await this.invoke("lanes", workspacePath, fastlaneDir);
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
throw new Error(res.error);
|
|
36
|
+
const lanes = res["lanes"];
|
|
37
|
+
this.cache.put(slug, hash, lanes);
|
|
38
|
+
return lanes;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
async function canRequireFastlane(env) {
|
|
5
|
+
try {
|
|
6
|
+
await exec("ruby", ["-e", 'require "fastlane"'], { env, timeout: 180_000 });
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reconstructs the `fastlane` launcher's environment when it's a shell script.
|
|
15
|
+
*
|
|
16
|
+
* We don't run the launcher: we read back its `GEM_HOME` and `GEM_PATH`
|
|
17
|
+
* assignments and have bash evaluate them, since it knows how to expand
|
|
18
|
+
* `${HOME}` and default values. Deliberately narrow approach — two
|
|
19
|
+
* variables, nothing else.
|
|
20
|
+
*/
|
|
21
|
+
async function envFromLauncher() {
|
|
22
|
+
const script = `
|
|
23
|
+
shim=$(command -v fastlane) || exit 1
|
|
24
|
+
head -c 2 "$shim" | grep -q '#!' || exit 1
|
|
25
|
+
eval "$(grep -oE '(GEM_HOME|GEM_PATH)="[^"]*"' "$shim" | sed 's/^/export /')" || exit 1
|
|
26
|
+
[ -n "$GEM_HOME" ] || exit 1
|
|
27
|
+
printf '%s\\n%s\\n' "$GEM_HOME" "$GEM_PATH"
|
|
28
|
+
`;
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await exec("bash", ["-c", script], { timeout: 30_000 });
|
|
31
|
+
const [gemHome, gemPath] = stdout.split("\n");
|
|
32
|
+
if (!gemHome)
|
|
33
|
+
return null;
|
|
34
|
+
return { ...process.env, GEM_HOME: gemHome, GEM_PATH: gemPath || gemHome };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
let cached = null;
|
|
41
|
+
/**
|
|
42
|
+
* Finds an environment in which `ruby` can load fastlane, or null.
|
|
43
|
+
*
|
|
44
|
+
* The result is memoized: probing costs several seconds, since fastlane is
|
|
45
|
+
* slow to load, and the install doesn't change while the process runs.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveRubyEnv() {
|
|
48
|
+
cached ??= (async () => {
|
|
49
|
+
if (await canRequireFastlane(process.env)) {
|
|
50
|
+
return { env: process.env, source: "process" };
|
|
51
|
+
}
|
|
52
|
+
const env = await envFromLauncher();
|
|
53
|
+
if (env && (await canRequireFastlane(env))) {
|
|
54
|
+
return { env, source: "launcher" };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
})();
|
|
58
|
+
return cached;
|
|
59
|
+
}
|
|
60
|
+
/** Single message, so the problem isn't described differently in each place. */
|
|
61
|
+
export const FASTLANE_UNAVAILABLE = "Ruby cannot load fastlane. Install it for the current Ruby " +
|
|
62
|
+
"(`gem install fastlane`), or declare a Gemfile in the project and set " +
|
|
63
|
+
"the `runtime` setting to `bundle`.";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { addProjectToConfig } from "../../src/cli/add.js";
|
|
6
|
+
import { tmpDir } from "../fixtures/repos.js";
|
|
7
|
+
const EXISTING = `# My Laneyard configuration
|
|
8
|
+
server:
|
|
9
|
+
port: 7890
|
|
10
|
+
password_hash: "scrypt$a$b" # server password
|
|
11
|
+
|
|
12
|
+
projects:
|
|
13
|
+
- slug: deja-la
|
|
14
|
+
git_url: git@example.com:a.git
|
|
15
|
+
`;
|
|
16
|
+
async function configAt(content) {
|
|
17
|
+
const dir = await tmpDir("laneyard-add-");
|
|
18
|
+
const path = join(dir, "config.yml");
|
|
19
|
+
await writeFile(path, content, "utf8");
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
22
|
+
const entry = {
|
|
23
|
+
slug: "sample-ios",
|
|
24
|
+
name: "Sample iOS",
|
|
25
|
+
git_url: "git@example.com:sample.git",
|
|
26
|
+
default_branch: "main",
|
|
27
|
+
fastlane_dir: "fastlane",
|
|
28
|
+
runtime: "system",
|
|
29
|
+
artifact_globs: ["**/*.ipa"],
|
|
30
|
+
};
|
|
31
|
+
describe("addProjectToConfig", () => {
|
|
32
|
+
it("adds the project without removing existing projects", async () => {
|
|
33
|
+
const path = await configAt(EXISTING);
|
|
34
|
+
await addProjectToConfig(path, entry);
|
|
35
|
+
const parsed = parse(await readFile(path, "utf8"));
|
|
36
|
+
expect(parsed.projects.map((p) => p.slug)).toEqual(["deja-la", "sample-ios"]);
|
|
37
|
+
});
|
|
38
|
+
it("preserves the file's comments", async () => {
|
|
39
|
+
const path = await configAt(EXISTING);
|
|
40
|
+
await addProjectToConfig(path, entry);
|
|
41
|
+
const raw = await readFile(path, "utf8");
|
|
42
|
+
expect(raw).toContain("# My Laneyard configuration");
|
|
43
|
+
expect(raw).toContain("# server password");
|
|
44
|
+
});
|
|
45
|
+
it("refuses an already-taken slug", async () => {
|
|
46
|
+
const path = await configAt(EXISTING);
|
|
47
|
+
await expect(addProjectToConfig(path, { ...entry, slug: "deja-la" })).rejects.toThrow(/deja-la/);
|
|
48
|
+
});
|
|
49
|
+
it("creates the file and the server section if they don't exist", async () => {
|
|
50
|
+
const dir = await tmpDir("laneyard-add-");
|
|
51
|
+
const path = join(dir, "config.yml");
|
|
52
|
+
await addProjectToConfig(path, entry);
|
|
53
|
+
const parsed = parse(await readFile(path, "utf8"));
|
|
54
|
+
expect(parsed.projects).toHaveLength(1);
|
|
55
|
+
// A password must exist, otherwise the server would refuse every connection.
|
|
56
|
+
expect(parsed.server.password_hash).toMatch(/^scrypt\$/);
|
|
57
|
+
});
|
|
58
|
+
it("adds a projects section missing from an existing file", async () => {
|
|
59
|
+
const path = await configAt('server:\n password_hash: "scrypt$a$b"\n');
|
|
60
|
+
await addProjectToConfig(path, entry);
|
|
61
|
+
const parsed = parse(await readFile(path, "utf8"));
|
|
62
|
+
expect(parsed.projects).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { detectProject } from "../../src/cli/detect.js";
|
|
5
|
+
import { makeOriginRepo, tmpDir } from "../fixtures/repos.js";
|
|
6
|
+
async function projectDir(files) {
|
|
7
|
+
const dir = await tmpDir("laneyard-detect-");
|
|
8
|
+
for (const [name, content] of Object.entries(files)) {
|
|
9
|
+
await mkdir(join(dir, name, ".."), { recursive: true });
|
|
10
|
+
await writeFile(join(dir, name), content, "utf8");
|
|
11
|
+
}
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
describe("detectProject", () => {
|
|
15
|
+
it("finds the fastlane folder at the root", async () => {
|
|
16
|
+
const dir = await projectDir({ "fastlane/Fastfile": "lane :beta do\nend\n" });
|
|
17
|
+
const d = await detectProject(dir);
|
|
18
|
+
expect(d.fastlaneDir).toBe("fastlane");
|
|
19
|
+
});
|
|
20
|
+
it("finds a fastlane folder nested in a monorepo", async () => {
|
|
21
|
+
const dir = await projectDir({ "apps/ios/fastlane/Fastfile": "lane :beta do\nend\n" });
|
|
22
|
+
const d = await detectProject(dir);
|
|
23
|
+
expect(d.fastlaneDir).toBe("apps/ios/fastlane");
|
|
24
|
+
});
|
|
25
|
+
it("reports the absence of fastlane rather than guessing", async () => {
|
|
26
|
+
const dir = await projectDir({ "README.md": "nothing" });
|
|
27
|
+
const d = await detectProject(dir);
|
|
28
|
+
expect(d.fastlaneDir).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it("chooses bundle when a Gemfile is present, system otherwise", async () => {
|
|
31
|
+
const withGemfile = await projectDir({ "fastlane/Fastfile": "", Gemfile: 'gem "fastlane"' });
|
|
32
|
+
expect((await detectProject(withGemfile)).runtime).toBe("bundle");
|
|
33
|
+
const without = await projectDir({ "fastlane/Fastfile": "" });
|
|
34
|
+
expect((await detectProject(without)).runtime).toBe("system");
|
|
35
|
+
});
|
|
36
|
+
it("proposes iOS artifact patterns on an Xcode project", async () => {
|
|
37
|
+
const dir = await projectDir({ "fastlane/Fastfile": "", "Sample.xcodeproj/project.pbxproj": "" });
|
|
38
|
+
const d = await detectProject(dir);
|
|
39
|
+
expect(d.artifactGlobs).toContain("**/*.ipa");
|
|
40
|
+
expect(d.artifactGlobs.some((g) => g.includes("dSYM"))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
it("proposes Android patterns on a Gradle project", async () => {
|
|
43
|
+
const dir = await projectDir({ "fastlane/Fastfile": "", "app/build.gradle": "" });
|
|
44
|
+
const d = await detectProject(dir);
|
|
45
|
+
expect(d.artifactGlobs).toContain("**/*.apk");
|
|
46
|
+
expect(d.artifactGlobs).toContain("**/*.aab");
|
|
47
|
+
});
|
|
48
|
+
it("reads the remote's URL and the current branch", async () => {
|
|
49
|
+
const origin = await makeOriginRepo({ "fastlane/Fastfile": "" });
|
|
50
|
+
const clone = await tmpDir("laneyard-clone-");
|
|
51
|
+
const { execFile } = await import("node:child_process");
|
|
52
|
+
const { promisify } = await import("node:util");
|
|
53
|
+
await promisify(execFile)("git", ["clone", origin, clone]);
|
|
54
|
+
const d = await detectProject(clone);
|
|
55
|
+
expect(d.gitUrl).toBe(origin);
|
|
56
|
+
expect(d.defaultBranch).toBe("main");
|
|
57
|
+
});
|
|
58
|
+
it("derives a slug from the folder name", async () => {
|
|
59
|
+
const dir = await projectDir({ "fastlane/Fastfile": "" });
|
|
60
|
+
const d = await detectProject(dir);
|
|
61
|
+
expect(d.slug).toMatch(/^[a-z0-9][a-z0-9-]*$/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { loadServerConfig } from "../../src/config/load.js";
|
|
6
|
+
async function withConfig(yaml) {
|
|
7
|
+
const dir = await mkdtemp(join(tmpdir(), "laneyard-"));
|
|
8
|
+
const path = join(dir, "config.yml");
|
|
9
|
+
await writeFile(path, yaml, "utf8");
|
|
10
|
+
return path;
|
|
11
|
+
}
|
|
12
|
+
const minimal = `
|
|
13
|
+
server:
|
|
14
|
+
password_hash: "scrypt$aaa$bbb"
|
|
15
|
+
projects:
|
|
16
|
+
- slug: sample-ios
|
|
17
|
+
git_url: git@github.com:martin/sample.git
|
|
18
|
+
`;
|
|
19
|
+
describe("loadServerConfig", () => {
|
|
20
|
+
it("applies the server's default values", async () => {
|
|
21
|
+
const res = await loadServerConfig(await withConfig(minimal));
|
|
22
|
+
expect(res.ok).toBe(true);
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return;
|
|
25
|
+
expect(res.config.server.port).toBe(7890);
|
|
26
|
+
expect(res.config.server.bind).toBe("0.0.0.0");
|
|
27
|
+
expect(res.config.server.max_concurrent_runs).toBe(1);
|
|
28
|
+
expect(res.config.server.retention).toEqual({ runs: 50, artifact_days: 30 });
|
|
29
|
+
});
|
|
30
|
+
it("derives a project's name from its slug", async () => {
|
|
31
|
+
const res = await loadServerConfig(await withConfig(minimal));
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw new Error("expected valid");
|
|
34
|
+
expect(res.config.projects[0].name).toBe("sample-ios");
|
|
35
|
+
expect(res.config.projects[0].default_branch).toBe("main");
|
|
36
|
+
});
|
|
37
|
+
it("refuses two projects sharing the same slug", async () => {
|
|
38
|
+
const res = await loadServerConfig(await withConfig(`
|
|
39
|
+
server: { password_hash: "x" }
|
|
40
|
+
projects:
|
|
41
|
+
- { slug: a, git_url: u1 }
|
|
42
|
+
- { slug: a, git_url: u2 }
|
|
43
|
+
`));
|
|
44
|
+
expect(res.ok).toBe(false);
|
|
45
|
+
if (res.ok)
|
|
46
|
+
return;
|
|
47
|
+
expect(res.error).toMatch(/slug/i);
|
|
48
|
+
});
|
|
49
|
+
it("refuses a slug that isn't usable in a path", async () => {
|
|
50
|
+
const res = await loadServerConfig(await withConfig(`
|
|
51
|
+
server: { password_hash: "x" }
|
|
52
|
+
projects:
|
|
53
|
+
- { slug: "../evil", git_url: u }
|
|
54
|
+
`));
|
|
55
|
+
expect(res.ok).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it("reports a readable error on invalid YAML", async () => {
|
|
58
|
+
const res = await loadServerConfig(await withConfig("server: {"));
|
|
59
|
+
expect(res.ok).toBe(false);
|
|
60
|
+
if (res.ok)
|
|
61
|
+
return;
|
|
62
|
+
expect(res.error.length).toBeGreaterThan(0);
|
|
63
|
+
});
|
|
64
|
+
it("reports a missing file without throwing", async () => {
|
|
65
|
+
const res = await loadServerConfig("/does/not/exist/config.yml");
|
|
66
|
+
expect(res.ok).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveProjectSettings } from "../../src/config/resolve.js";
|
|
3
|
+
const entry = (over = {}) => ({
|
|
4
|
+
slug: "p",
|
|
5
|
+
name: "p",
|
|
6
|
+
git_url: "u",
|
|
7
|
+
default_branch: "main",
|
|
8
|
+
git_auth: { kind: "none" },
|
|
9
|
+
color: "green",
|
|
10
|
+
notify_browser: true,
|
|
11
|
+
...over,
|
|
12
|
+
});
|
|
13
|
+
describe("resolveProjectSettings", () => {
|
|
14
|
+
it("falls back to the defaults when nothing is set", () => {
|
|
15
|
+
const r = resolveProjectSettings(entry(), null);
|
|
16
|
+
expect(r.settings.fastlane_dir).toBe("fastlane");
|
|
17
|
+
expect(r.settings.timeout_minutes).toBe(60);
|
|
18
|
+
expect(r.provenance.fastlane_dir).toBe("default");
|
|
19
|
+
});
|
|
20
|
+
it("the project's block wins over the defaults", () => {
|
|
21
|
+
const r = resolveProjectSettings(entry({ timeout_minutes: 15 }), null);
|
|
22
|
+
expect(r.settings.timeout_minutes).toBe(15);
|
|
23
|
+
expect(r.provenance.timeout_minutes).toBe("server");
|
|
24
|
+
});
|
|
25
|
+
it("the repository wins over the project's block", () => {
|
|
26
|
+
const r = resolveProjectSettings(entry({ timeout_minutes: 15 }), { timeout_minutes: 90 });
|
|
27
|
+
expect(r.settings.timeout_minutes).toBe(90);
|
|
28
|
+
expect(r.provenance.timeout_minutes).toBe("repo");
|
|
29
|
+
});
|
|
30
|
+
it("mixes provenances field by field", () => {
|
|
31
|
+
const r = resolveProjectSettings(entry({ runtime: "system" }), {
|
|
32
|
+
artifact_globs: ["build/*.ipa"],
|
|
33
|
+
});
|
|
34
|
+
expect(r.settings.runtime).toBe("system");
|
|
35
|
+
expect(r.provenance.runtime).toBe("server");
|
|
36
|
+
expect(r.settings.artifact_globs).toEqual(["build/*.ipa"]);
|
|
37
|
+
expect(r.provenance.artifact_globs).toBe("repo");
|
|
38
|
+
expect(r.provenance.fastlane_dir).toBe("default");
|
|
39
|
+
});
|
|
40
|
+
it("treats an empty array as a defined value, not as an absence", () => {
|
|
41
|
+
const r = resolveProjectSettings(entry(), { artifact_globs: [] });
|
|
42
|
+
expect(r.provenance.artifact_globs).toBe("repo");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { ConfigStore } from "../../src/config/store.js";
|
|
5
|
+
import { tmpDir } from "../fixtures/repos.js";
|
|
6
|
+
const CONFIG = (slug) => `
|
|
7
|
+
server: { password_hash: "x" }
|
|
8
|
+
projects:
|
|
9
|
+
- slug: ${slug}
|
|
10
|
+
git_url: u
|
|
11
|
+
`;
|
|
12
|
+
async function configFile(content) {
|
|
13
|
+
const dir = await tmpDir("laneyard-store-");
|
|
14
|
+
const path = join(dir, "config.yml");
|
|
15
|
+
await writeFile(path, content, "utf8");
|
|
16
|
+
return path;
|
|
17
|
+
}
|
|
18
|
+
describe("ConfigStore", () => {
|
|
19
|
+
it("loads the configuration at startup", async () => {
|
|
20
|
+
const store = new ConfigStore(await configFile(CONFIG("sample")));
|
|
21
|
+
await store.load();
|
|
22
|
+
expect(store.projects().map((p) => p.slug)).toEqual(["sample"]);
|
|
23
|
+
});
|
|
24
|
+
it("finds a project by its slug", async () => {
|
|
25
|
+
const store = new ConfigStore(await configFile(CONFIG("sample")));
|
|
26
|
+
await store.load();
|
|
27
|
+
expect(store.project("sample")?.git_url).toBe("u");
|
|
28
|
+
expect(store.project("unknown")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it("takes a file change into account", async () => {
|
|
31
|
+
const path = await configFile(CONFIG("one"));
|
|
32
|
+
const store = new ConfigStore(path);
|
|
33
|
+
await store.load();
|
|
34
|
+
await writeFile(path, CONFIG("two"), "utf8");
|
|
35
|
+
await store.load();
|
|
36
|
+
expect(store.projects().map((p) => p.slug)).toEqual(["two"]);
|
|
37
|
+
});
|
|
38
|
+
it("keeps the last valid configuration if the file becomes invalid", async () => {
|
|
39
|
+
const path = await configFile(CONFIG("one"));
|
|
40
|
+
const store = new ConfigStore(path);
|
|
41
|
+
await store.load();
|
|
42
|
+
await writeFile(path, "projects: [", "utf8");
|
|
43
|
+
const res = await store.load();
|
|
44
|
+
expect(res.ok).toBe(false);
|
|
45
|
+
expect(store.projects().map((p) => p.slug)).toEqual(["one"]);
|
|
46
|
+
expect(store.lastError()).not.toBeNull();
|
|
47
|
+
});
|
|
48
|
+
it("clears the error once the file becomes valid again", async () => {
|
|
49
|
+
const path = await configFile(CONFIG("one"));
|
|
50
|
+
const store = new ConfigStore(path);
|
|
51
|
+
await store.load();
|
|
52
|
+
await writeFile(path, "projects: [", "utf8");
|
|
53
|
+
await store.load();
|
|
54
|
+
await writeFile(path, CONFIG("one"), "utf8");
|
|
55
|
+
await store.load();
|
|
56
|
+
expect(store.lastError()).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { openDatabase } from "../../src/db/open.js";
|
|
3
|
+
import { RunStore } from "../../src/db/runs.js";
|
|
4
|
+
function store() {
|
|
5
|
+
return new RunStore(openDatabase(":memory:"));
|
|
6
|
+
}
|
|
7
|
+
describe("RunStore", () => {
|
|
8
|
+
it("creates a queued run and reads it back", () => {
|
|
9
|
+
const s = store();
|
|
10
|
+
const id = s.create({ projectSlug: "p", lane: "beta", platform: "ios", params: { v: "1.2" } });
|
|
11
|
+
const run = s.get(id);
|
|
12
|
+
expect(run?.status).toBe("queued");
|
|
13
|
+
expect(run?.params).toEqual({ v: "1.2" });
|
|
14
|
+
expect(run?.startedAt).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
it("timestamps the transition to running and to a terminal state", () => {
|
|
17
|
+
const s = store();
|
|
18
|
+
const id = s.create({ projectSlug: "p", lane: "beta", platform: null, params: {} });
|
|
19
|
+
s.markRunning(id, { branch: "main", commitSha: "abc123" });
|
|
20
|
+
expect(s.get(id)?.startedAt).not.toBeNull();
|
|
21
|
+
expect(s.get(id)?.commitSha).toBe("abc123");
|
|
22
|
+
s.finish(id, { status: "success", exitCode: 0, errorSummary: null });
|
|
23
|
+
const done = s.get(id);
|
|
24
|
+
expect(done?.status).toBe("success");
|
|
25
|
+
expect(done?.finishedAt).not.toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it("lists a project's runs from most recent to oldest", () => {
|
|
28
|
+
const s = store();
|
|
29
|
+
const a = s.create({ projectSlug: "p", lane: "a", platform: null, params: {} });
|
|
30
|
+
const b = s.create({ projectSlug: "p", lane: "b", platform: null, params: {} });
|
|
31
|
+
s.create({ projectSlug: "other", lane: "c", platform: null, params: {} });
|
|
32
|
+
expect(s.listByProject("p").map((r) => r.id)).toEqual([b, a]);
|
|
33
|
+
});
|
|
34
|
+
it("marks any run still active as interrupted", () => {
|
|
35
|
+
const s = store();
|
|
36
|
+
const id = s.create({ projectSlug: "p", lane: "a", platform: null, params: {} });
|
|
37
|
+
s.markRunning(id, { branch: "main", commitSha: "x" });
|
|
38
|
+
expect(s.interruptActive()).toBe(1);
|
|
39
|
+
expect(s.get(id)?.status).toBe("interrupted");
|
|
40
|
+
expect(s.interruptActive()).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
it("records steps and artifacts attached to the run", () => {
|
|
43
|
+
const s = store();
|
|
44
|
+
const id = s.create({ projectSlug: "p", lane: "a", platform: null, params: {} });
|
|
45
|
+
s.replaceSteps(id, [
|
|
46
|
+
{ idx: 0, name: "match", durationMs: 1100, status: "success", logOffset: 42, source: "report" },
|
|
47
|
+
{ idx: 1, name: "build_app", durationMs: 90_000, status: "failed", logOffset: null, source: "report" },
|
|
48
|
+
]);
|
|
49
|
+
s.addArtifact(id, { filename: "P.ipa", path: "/tmp/P.ipa", size: 10, kind: "ipa" });
|
|
50
|
+
expect(s.steps(id)).toHaveLength(2);
|
|
51
|
+
expect(s.steps(id)[1].status).toBe("failed");
|
|
52
|
+
expect(s.artifacts(id)[0].kind).toBe("ipa");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { 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 { RunStore } from "../../src/db/runs.js";
|
|
7
|
+
import { makeOriginRepo, tmpDir } from "../fixtures/repos.js";
|
|
8
|
+
const FAKE_DIR = join(process.cwd(), "tests", "fixtures", "fake-fastlane");
|
|
9
|
+
describe("full thread", () => {
|
|
10
|
+
it("declares, clones, lists, launches, follows, and retrieves the artifact", async () => {
|
|
11
|
+
const origin = await makeOriginRepo({
|
|
12
|
+
"fastlane/Fastfile": "lane :beta do\nend\n",
|
|
13
|
+
"laneyard.yml": 'runtime: system\nartifact_globs: ["build/**/*.ipa"]\n',
|
|
14
|
+
".gitignore": "build/\n",
|
|
15
|
+
});
|
|
16
|
+
const root = await tmpDir("laneyard-e2e-");
|
|
17
|
+
await writeFile(join(root, "config.yml"), `
|
|
18
|
+
server:
|
|
19
|
+
password_hash: "${hashPassword("secret")}"
|
|
20
|
+
projects:
|
|
21
|
+
- slug: sample
|
|
22
|
+
name: Sample
|
|
23
|
+
git_url: ${origin}
|
|
24
|
+
`, "utf8");
|
|
25
|
+
process.env["PATH"] = `${FAKE_DIR}:${process.env["PATH"]}`;
|
|
26
|
+
process.env["FAKE_FASTLANE_SCENARIO"] = "success";
|
|
27
|
+
const { app, db } = await createServerFromConfig(root);
|
|
28
|
+
const session = (await app.inject({ method: "POST", url: "/api/login", payload: { password: "secret" } })).cookies[0].value;
|
|
29
|
+
const cookies = { laneyard_session: session };
|
|
30
|
+
const projects = await app.inject({ method: "GET", url: "/api/projects", cookies });
|
|
31
|
+
expect(projects.json()).toMatchObject([{ slug: "sample" }]);
|
|
32
|
+
const created = await app.inject({
|
|
33
|
+
method: "POST",
|
|
34
|
+
url: "/api/projects/sample/runs",
|
|
35
|
+
cookies,
|
|
36
|
+
payload: { lane: "beta", params: {} },
|
|
37
|
+
});
|
|
38
|
+
expect(created.statusCode).toBe(201);
|
|
39
|
+
const { id } = created.json();
|
|
40
|
+
// The run is asynchronous: we wait for it to reach a terminal state.
|
|
41
|
+
const runs = new RunStore(db);
|
|
42
|
+
const deadline = Date.now() + 60_000;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
const status = runs.get(id)?.status;
|
|
45
|
+
if (status === "success" || status === "failed")
|
|
46
|
+
break;
|
|
47
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
48
|
+
}
|
|
49
|
+
const detail = await app.inject({ method: "GET", url: `/api/runs/${id}`, cookies });
|
|
50
|
+
const body = detail.json();
|
|
51
|
+
expect(body.status).toBe("success");
|
|
52
|
+
expect(body.steps).toHaveLength(2);
|
|
53
|
+
expect(body.artifacts[0].filename).toBe("Sample.ipa");
|
|
54
|
+
const log = await app.inject({ method: "GET", url: `/api/runs/${id}/log`, cookies });
|
|
55
|
+
expect(log.body).toContain("Step: build_app");
|
|
56
|
+
const download = await app.inject({
|
|
57
|
+
method: "GET",
|
|
58
|
+
url: `/api/runs/${id}/artifacts/${body.artifacts[0].id}`,
|
|
59
|
+
cookies,
|
|
60
|
+
});
|
|
61
|
+
expect(download.statusCode).toBe(200);
|
|
62
|
+
expect(download.body.trim()).toBe("fake binary");
|
|
63
|
+
await app.close();
|
|
64
|
+
}, 120_000);
|
|
65
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
/** Creates a local git repository serving as a "remote" in the tests. */
|
|
8
|
+
export async function makeOriginRepo(files) {
|
|
9
|
+
const dir = await mkdtemp(join(tmpdir(), "laneyard-origin-"));
|
|
10
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: dir });
|
|
11
|
+
await run("git", ["config", "user.email", "test@example.com"], { cwd: dir });
|
|
12
|
+
await run("git", ["config", "user.name", "Test"], { cwd: dir });
|
|
13
|
+
for (const [name, content] of Object.entries(files)) {
|
|
14
|
+
await run("mkdir", ["-p", join(dir, name, "..")]).catch(() => { });
|
|
15
|
+
await writeFile(join(dir, name), content, "utf8");
|
|
16
|
+
}
|
|
17
|
+
await run("git", ["add", "-A"], { cwd: dir });
|
|
18
|
+
await run("git", ["commit", "-q", "-m", "initial"], { cwd: dir });
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
export async function commitTo(repo, name, content) {
|
|
22
|
+
await writeFile(join(repo, name), content, "utf8");
|
|
23
|
+
await run("git", ["add", "-A"], { cwd: repo });
|
|
24
|
+
await run("git", ["commit", "-q", "-m", `edit ${name}`], { cwd: repo });
|
|
25
|
+
const { stdout } = await run("git", ["rev-parse", "HEAD"], { cwd: repo });
|
|
26
|
+
return stdout.trim();
|
|
27
|
+
}
|
|
28
|
+
export async function tmpDir(prefix = "laneyard-ws-") {
|
|
29
|
+
return mkdtemp(join(tmpdir(), prefix));
|
|
30
|
+
}
|