laneyard 0.1.0 → 0.3.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.
Files changed (74) hide show
  1. package/README.md +238 -32
  2. package/dist/src/cli/detect.js +60 -16
  3. package/dist/src/cli/prompt.js +36 -0
  4. package/dist/src/cli/secret.js +133 -0
  5. package/dist/src/cli/setup.js +282 -0
  6. package/dist/src/cli/style.js +31 -0
  7. package/dist/src/cli/user.js +127 -0
  8. package/dist/src/config/accounts.js +163 -0
  9. package/dist/src/config/load.js +61 -1
  10. package/dist/src/config/schema.js +47 -4
  11. package/dist/src/config/store.js +10 -0
  12. package/dist/src/config/yaml.js +14 -0
  13. package/dist/src/db/cache.js +8 -8
  14. package/dist/src/db/open.js +15 -0
  15. package/dist/src/db/runs.js +51 -6
  16. package/dist/src/db/schema.sql +20 -2
  17. package/dist/src/db/secrets.js +64 -0
  18. package/dist/src/fastfile/store.js +53 -0
  19. package/dist/src/git/workspace.js +95 -5
  20. package/dist/src/heuristics/blocking-actions.js +65 -0
  21. package/dist/src/heuristics/platforms.js +77 -0
  22. package/dist/src/heuristics/readiness.js +336 -0
  23. package/dist/src/logs/redact.js +86 -0
  24. package/dist/src/main.js +79 -9
  25. package/dist/src/runner/orchestrate.js +60 -10
  26. package/dist/src/runner/pty.js +27 -15
  27. package/dist/src/runner/queue.js +114 -0
  28. package/dist/src/secrets/cipher.js +28 -0
  29. package/dist/src/secrets/key.js +40 -0
  30. package/dist/src/secrets/vault.js +68 -0
  31. package/dist/src/server/app.js +135 -13
  32. package/dist/src/server/auth.js +85 -17
  33. package/dist/src/server/permissions.js +73 -0
  34. package/dist/src/server/routes/fastfile.js +131 -0
  35. package/dist/src/server/routes/projects.js +50 -1
  36. package/dist/src/server/routes/readiness.js +102 -0
  37. package/dist/src/server/routes/runs.js +27 -45
  38. package/dist/src/server/routes/secrets.js +51 -0
  39. package/dist/src/server/routes/users.js +64 -0
  40. package/dist/src/sidecar/bridge.js +17 -1
  41. package/dist/src/sidecar/lanes.js +2 -2
  42. package/dist/src/sidecar/uses.js +40 -0
  43. package/dist/web/assets/Editor-DNFBA4gv.js +14 -0
  44. package/dist/web/assets/index-De6sxx6G.css +1 -0
  45. package/dist/web/assets/index-TWRQ1cJg.js +62 -0
  46. package/dist/web/index.html +2 -2
  47. package/package.json +13 -5
  48. package/ruby/introspect.rb +80 -0
  49. package/dist/tests/cli/add.test.js +0 -64
  50. package/dist/tests/cli/detect.test.js +0 -63
  51. package/dist/tests/config/load.test.js +0 -68
  52. package/dist/tests/config/resolve.test.js +0 -44
  53. package/dist/tests/config/store.test.js +0 -58
  54. package/dist/tests/db/runs.test.js +0 -54
  55. package/dist/tests/e2e/full-thread.test.js +0 -65
  56. package/dist/tests/fixtures/repos.js +0 -30
  57. package/dist/tests/git/workspace.test.js +0 -66
  58. package/dist/tests/heuristics/error-summary.test.js +0 -31
  59. package/dist/tests/logs/store.test.js +0 -38
  60. package/dist/tests/main.test.js +0 -27
  61. package/dist/tests/ruby/introspect.test.js +0 -59
  62. package/dist/tests/runner/artifacts.test.js +0 -49
  63. package/dist/tests/runner/live-steps.test.js +0 -35
  64. package/dist/tests/runner/orchestrate.test.js +0 -124
  65. package/dist/tests/runner/pty.test.js +0 -53
  66. package/dist/tests/runner/report.test.js +0 -91
  67. package/dist/tests/server/api.test.js +0 -132
  68. package/dist/tests/server/auth.test.js +0 -53
  69. package/dist/tests/server/ws.test.js +0 -43
  70. package/dist/tests/sidecar/lanes.test.js +0 -52
  71. package/dist/tests/sidecar/ruby-env.test.js +0 -25
  72. package/dist/tests/smoke.test.js +0 -7
  73. package/dist/web/assets/index-583x0xuo.css +0 -1
  74. package/dist/web/assets/index-BEpABKPS.js +0 -61
@@ -4,8 +4,8 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>laneyard</title>
7
- <script type="module" crossorigin src="/assets/index-BEpABKPS.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-583x0xuo.css">
7
+ <script type="module" crossorigin src="/assets/index-TWRQ1cJg.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-De6sxx6G.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laneyard",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "laneyard": "dist/src/main.js"
@@ -26,19 +26,26 @@
26
26
  "better-sqlite3": "^11.7.0",
27
27
  "fastify": "^5.2.0",
28
28
  "node-pty": "^1.0.0",
29
- "react": "^19.2.7",
30
- "react-dom": "^19.2.7",
31
- "react-router-dom": "^7.18.1",
32
29
  "tinyglobby": "^0.2.10",
33
30
  "yaml": "^2.7.0",
34
31
  "zod": "^3.24.0"
35
32
  },
36
33
  "devDependencies": {
34
+ "@codemirror/commands": "^6.10.4",
35
+ "@codemirror/language": "^6.12.4",
36
+ "@codemirror/legacy-modes": "^6.5.3",
37
+ "@codemirror/search": "^6.7.1",
38
+ "@codemirror/state": "^6.7.1",
39
+ "@codemirror/view": "^6.43.6",
40
+ "@lezer/highlight": "^1.2.3",
37
41
  "@types/better-sqlite3": "^7.6.12",
38
42
  "@types/node": "^22.10.0",
39
43
  "@types/react": "^19.2.17",
40
44
  "@types/react-dom": "^19.2.3",
41
45
  "@vitejs/plugin-react": "^4.7.0",
46
+ "react": "^19.2.7",
47
+ "react-dom": "^19.2.7",
48
+ "react-router-dom": "^7.18.1",
42
49
  "tsx": "^4.19.0",
43
50
  "typescript": "^5.7.0",
44
51
  "vite": "^5.4.21",
@@ -51,7 +58,8 @@
51
58
  "url": "https://github.com/martinfrouin/laneyard.git"
52
59
  },
53
60
  "files": [
54
- "dist",
61
+ "dist/src",
62
+ "dist/web",
55
63
  "ruby",
56
64
  "scripts",
57
65
  "README.md",
@@ -50,6 +50,12 @@ rescue LoadError => e
50
50
  end
51
51
 
52
52
  def collect_lanes(fastfile_path)
53
+ # Loading a Fastfile *runs* it, and its top level may call actions —
54
+ # `default_platform(:ios)` is on the first line of most real Fastfiles. Without
55
+ # the action catalogue loaded first, fastlane raises "Could not find action,
56
+ # lane or variable" and the whole lane list is lost over an ordinary line.
57
+ Fastlane.load_actions
58
+
53
59
  ff = Fastlane::FastFile.new(fastfile_path)
54
60
  lanes = []
55
61
  ff.runner.lanes.each do |platform, platform_lanes|
@@ -65,6 +71,71 @@ def collect_lanes(fastfile_path)
65
71
  lanes
66
72
  end
67
73
 
74
+ require "prism"
75
+
76
+ # Literal values only, resolved by node type through a table.
77
+ #
78
+ # A Fastfile is arbitrary Ruby: `ENV["X"]` or a method call has no value until
79
+ # the lane runs, and a checklist that guesses is worse than one that stays quiet
80
+ # — it would be believed. Deciding from the node type rather than the converted
81
+ # value is also what keeps `false` and `nil` from being mistaken for absence.
82
+ LITERALS = {
83
+ Prism::StringNode => ->(n) { n.unescaped },
84
+ Prism::SymbolNode => ->(n) { n.unescaped },
85
+ Prism::IntegerNode => ->(n) { n.value },
86
+ Prism::FloatNode => ->(n) { n.value },
87
+ Prism::TrueNode => ->(_) { true },
88
+ Prism::FalseNode => ->(_) { false },
89
+ Prism::NilNode => ->(_) { nil }
90
+ }.freeze
91
+
92
+ def literal_args(call)
93
+ hash = (call.arguments&.arguments || []).find { |a| a.is_a?(Prism::KeywordHashNode) }
94
+ return {} unless hash
95
+
96
+ hash.elements.each_with_object({}) do |el, out|
97
+ next unless el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode)
98
+ reader = LITERALS[el.value.class]
99
+ out[el.key.unescaped] = reader.call(el.value) if reader
100
+ end
101
+ end
102
+
103
+ # Every call inside a lane's block, however deeply nested: a call inside an `if`
104
+ # is still a call the lane may make, and the checklist cares about what could
105
+ # happen, not only about what always happens.
106
+ def calls_within(node, out = [])
107
+ node.compact_child_nodes.each do |child|
108
+ if child.is_a?(Prism::CallNode) && child.receiver.nil?
109
+ out << { name: child.name.to_s, args: literal_args(child) }
110
+ end
111
+ calls_within(child, out)
112
+ end
113
+ out
114
+ end
115
+
116
+ def collect_uses(fastfile_path)
117
+ result = Prism.parse(File.read(fastfile_path))
118
+ raise "Fastfile could not be parsed" unless result.success?
119
+
120
+ lanes = []
121
+ walk = lambda do |node|
122
+ node.compact_child_nodes.each do |child|
123
+ if child.is_a?(Prism::CallNode) && %w[lane private_lane].include?(child.name.to_s)
124
+ name = child.arguments&.arguments&.first
125
+ lanes << {
126
+ lane: name.is_a?(Prism::SymbolNode) ? name.unescaped : "?",
127
+ actions: child.block ? calls_within(child.block) : []
128
+ }
129
+ else
130
+ # Not a lane: keep descending, so a `platform :ios do` block is seen through.
131
+ walk.call(child)
132
+ end
133
+ end
134
+ end
135
+ walk.call(result.value)
136
+ lanes
137
+ end
138
+
68
139
  case command
69
140
  when "lanes"
70
141
  begin
@@ -82,6 +153,15 @@ when "lanes"
82
153
  fail_with("Could not load the Fastfile: #{e.message}")
83
154
  end
84
155
  respond({ ok: true, lanes: lanes })
156
+ when "uses"
157
+ begin
158
+ lanes = collect_uses(fastfile_path)
159
+ rescue Exception => e # rubocop:disable Lint/RescueException
160
+ # Same shape as "lanes" above, and the same reason: parsing can raise
161
+ # anything, and `respond`'s `exit 0` must not be caught by this rescue.
162
+ fail_with("Could not parse the Fastfile: #{e.message}")
163
+ end
164
+ respond({ ok: true, lanes: lanes })
85
165
  else
86
166
  fail_with("Unknown command: #{command.inspect}")
87
167
  end
@@ -1,64 +0,0 @@
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
- });
@@ -1,63 +0,0 @@
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
- });
@@ -1,68 +0,0 @@
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
- });
@@ -1,44 +0,0 @@
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
- });
@@ -1,58 +0,0 @@
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
- });
@@ -1,54 +0,0 @@
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
- });
@@ -1,65 +0,0 @@
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
- });
@@ -1,30 +0,0 @@
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
- }