laneyard 0.1.0 → 0.2.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/README.md +120 -21
- package/dist/src/cli/secret.js +133 -0
- package/dist/src/config/schema.js +21 -3
- package/dist/src/db/cache.js +8 -8
- package/dist/src/db/open.js +15 -0
- package/dist/src/db/runs.js +51 -6
- package/dist/src/db/schema.sql +20 -2
- package/dist/src/db/secrets.js +64 -0
- package/dist/src/fastfile/store.js +53 -0
- package/dist/src/git/workspace.js +95 -5
- package/dist/src/heuristics/blocking-actions.js +45 -0
- package/dist/src/heuristics/readiness.js +193 -0
- package/dist/src/logs/redact.js +86 -0
- package/dist/src/main.js +32 -3
- package/dist/src/runner/orchestrate.js +60 -10
- package/dist/src/runner/pty.js +27 -15
- package/dist/src/runner/queue.js +114 -0
- package/dist/src/secrets/cipher.js +28 -0
- package/dist/src/secrets/key.js +40 -0
- package/dist/src/secrets/vault.js +68 -0
- package/dist/src/server/app.js +70 -0
- package/dist/src/server/routes/fastfile.js +131 -0
- package/dist/src/server/routes/projects.js +8 -1
- package/dist/src/server/routes/readiness.js +89 -0
- package/dist/src/server/routes/runs.js +27 -45
- package/dist/src/server/routes/secrets.js +51 -0
- package/dist/src/sidecar/lanes.js +2 -2
- package/dist/src/sidecar/uses.js +40 -0
- package/dist/web/assets/Editor-D5Q4uj4n.js +14 -0
- package/dist/web/assets/index-D9_EL8LA.js +62 -0
- package/dist/web/assets/index-ZUqTX6d1.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +13 -5
- package/ruby/introspect.rb +80 -0
- package/dist/tests/cli/add.test.js +0 -64
- package/dist/tests/cli/detect.test.js +0 -63
- package/dist/tests/config/load.test.js +0 -68
- package/dist/tests/config/resolve.test.js +0 -44
- package/dist/tests/config/store.test.js +0 -58
- package/dist/tests/db/runs.test.js +0 -54
- package/dist/tests/e2e/full-thread.test.js +0 -65
- package/dist/tests/fixtures/repos.js +0 -30
- package/dist/tests/git/workspace.test.js +0 -66
- package/dist/tests/heuristics/error-summary.test.js +0 -31
- package/dist/tests/logs/store.test.js +0 -38
- package/dist/tests/main.test.js +0 -27
- package/dist/tests/ruby/introspect.test.js +0 -59
- package/dist/tests/runner/artifacts.test.js +0 -49
- package/dist/tests/runner/live-steps.test.js +0 -35
- package/dist/tests/runner/orchestrate.test.js +0 -124
- package/dist/tests/runner/pty.test.js +0 -53
- package/dist/tests/runner/report.test.js +0 -91
- package/dist/tests/server/api.test.js +0 -132
- package/dist/tests/server/auth.test.js +0 -53
- package/dist/tests/server/ws.test.js +0 -43
- package/dist/tests/sidecar/lanes.test.js +0 -52
- package/dist/tests/sidecar/ruby-env.test.js +0 -25
- package/dist/tests/smoke.test.js +0 -7
- package/dist/web/assets/index-583x0xuo.css +0 -1
- package/dist/web/assets/index-BEpABKPS.js +0 -61
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;--bg: #14161a;--bg-raised: #1b1e24;--bg-inset: #171a1f;--border: #262a31;--text: #c8ccd4;--text-dim: #676e7b;--text-bright: #e6e9ee;--accent: #7ee787;--ok: #7ee787;--running: #e3b341;--error: #f8746a;--info: #79c0ff;--term-bg: #0e1013;--term-text: #c9d1d9}:root[data-theme=light]{--bg: #f2efe6;--bg-raised: #e7e2d5;--bg-inset: #ebe7dc;--border: #d9d3c4;--text: #2f2b25;--text-dim: #8b8375;--text-bright: #14110c;--accent: #3f7d3f;--ok: #3f7d3f;--running: #a76c14;--error: #b03a34;--info: #2f6f9e}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font-mono);font-size:13px;line-height:1.6}.panel{background:var(--bg-raised);border:1px solid var(--border)}.status-success{color:var(--ok)}.status-running{color:var(--running)}.status-failed,.status-interrupted{color:var(--error)}.status-queued,.status-preparing,.dim{color:var(--text-dim)}.bright{color:var(--text-bright)}.accent{color:var(--accent)}.status-cancelled{color:var(--text-dim)}a{color:inherit;text-decoration:none}a:hover{color:var(--text-bright)}h1,h2,h3{font-size:13px;font-weight:400;margin:0}.section{color:var(--text-dim);text-transform:uppercase;letter-spacing:.14em;font-size:11px}button,input,select{font:inherit;color:var(--text);background:var(--bg-inset);border:1px solid var(--border);border-radius:0;padding:2px 8px}button{cursor:pointer}button:hover:not(:disabled){color:var(--text-bright);border-color:var(--text-dim)}button:disabled,input:disabled{color:var(--text-dim);cursor:default}input:focus,button:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}ul{list-style:none;margin:0;padding:0}code{color:var(--text-bright)}.shell{display:grid;grid-template-columns:240px minmax(0,1fr);grid-template-rows:32px 1fr;height:100vh}.shell>header{grid-column:1 / -1;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 12px;border-bottom:1px solid var(--border);background:var(--bg-raised)}.brand{color:var(--accent);letter-spacing:.12em}.shell>nav{border-right:1px solid var(--border);background:var(--bg-raised);overflow-y:auto;padding:12px 0}.shell>main{min-width:0;overflow:auto;padding:16px}.nav-head{padding:0 12px 6px}.nav-item{display:block;padding:2px 12px;border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.nav-item:hover{background:var(--bg-inset)}.nav-item.current{border-left-color:var(--accent);background:var(--bg-inset);color:var(--text-bright)}.login{height:100vh;display:flex;align-items:center;justify-content:center}.login form{width:320px;padding:20px;display:flex;flex-direction:column;gap:12px}.login input{width:100%;padding:4px 8px}.rows>li{display:flex;align-items:baseline;gap:8px;padding:4px 0;border-bottom:1px solid var(--border)}.rows>li:last-child{border-bottom:none}.grow{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mark{width:1ch;display:inline-block}.tabs{display:flex;gap:16px;border-bottom:1px solid var(--border);margin-bottom:12px}.tabs a{color:var(--text-dim);text-transform:uppercase;letter-spacing:.14em;font-size:11px;padding-bottom:4px;border-bottom:2px solid transparent}.tabs a:hover{color:var(--text)}.tabs a.current{color:var(--text-bright);border-bottom-color:var(--accent)}.secret-form{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;margin:8px 0 4px}.secret-form input[type=text],.secret-form input[type=password]{width:24ch}.secret-form label{display:flex;align-items:baseline;gap:6px;color:var(--text-dim)}.secret-form input[type=checkbox]{accent-color:var(--accent);width:auto;padding:0}table{border-collapse:collapse;width:100%}td,th{text-align:left;font-weight:400;padding:2px 12px 2px 0}.checks .grow{white-space:normal;overflow:visible}.editor-bar,.commit-bar{display:flex;align-items:baseline;gap:8px;margin:8px 0 4px}.commit-bar input{flex:1;max-width:48ch}.editor{height:calc(100vh - 420px);min-height:240px;overflow:hidden}.save-error{white-space:normal;margin:8px 0}.diff{margin:8px 0 0;padding:8px 12px;max-height:40vh;overflow:auto;background:var(--term-bg);color:var(--term-text);white-space:pre;font:inherit}.run-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px 20px;padding:8px 12px;margin-bottom:12px}.run-body{display:grid;grid-template-columns:260px minmax(0,1fr);gap:12px;align-items:start;min-width:0}.steps>li{display:flex;gap:8px;padding:2px 8px}.steps>li.clickable{cursor:pointer}.steps>li.clickable:hover{background:var(--bg-inset);color:var(--text-bright)}.steps>li.inert{color:var(--text-dim)}.pane-title{padding:6px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;gap:12px}.terminal{background:var(--term-bg);color:var(--term-text);display:flex;flex-direction:column;height:calc(100vh - 190px);min-height:260px}.terminal pre{margin:0;flex:1;overflow:auto;padding:8px 12px;white-space:pre;font:inherit}.terminal .a-black{color:#6e7681}.terminal .a-red{color:#ff7b72}.terminal .a-green{color:#7ee787}.terminal .a-yellow{color:#d29922}.terminal .a-blue{color:#79c0ff}.terminal .a-magenta{color:#d2a8ff}.terminal .a-cyan{color:#56d4dd}.terminal .a-white{color:#b1bac4}.terminal .a-bright-black{color:#8b949e}.terminal .a-bright-red{color:#ffa198}.terminal .a-bright-green{color:#56d364}.terminal .a-bright-yellow{color:#e3b341}.terminal .a-bright-blue{color:#a5d6ff}.terminal .a-bright-magenta{color:#e2c5ff}.terminal .a-bright-cyan{color:#b3f0ff}.terminal .a-bright-white{color:#f0f6fc}.terminal .a-bold{font-weight:700}.terminal .a-dim{opacity:.7}.terminal-input{display:flex;align-items:center;gap:8px;padding:4px 12px;border-top:1px solid var(--border)}.terminal-input input{flex:1;background:transparent;border:none;color:var(--term-text);padding:0}.terminal-input input:disabled{color:#4c525c}.terminal-input .prompt{color:var(--accent)}.terminal-input .reason{color:#676e7b}@media (max-width: 820px){.shell{grid-template-columns:1fr;grid-template-rows:32px auto 1fr;height:auto;min-height:100vh}.shell>nav{border-right:none;border-bottom:1px solid var(--border);display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding:6px 12px}.nav-head{padding:0;width:100%}.nav-item{border-left:none;border-bottom:2px solid transparent;padding:0 2px}.nav-item.current{border-left-color:transparent;border-bottom-color:var(--accent)}.run-body{grid-template-columns:1fr}.terminal{height:60vh}}
|
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-D9_EL8LA.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-ZUqTX6d1.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.
|
|
3
|
+
"version": "0.2.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",
|
package/ruby/introspect.rb
CHANGED
|
@@ -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
|
-
}
|