@symbiosis-lab/moss-plugin-github 1.5.1
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/CHANGELOG.md +30 -0
- package/README.md +18 -0
- package/assets/icon.svg +3 -0
- package/assets/manifest.json +19 -0
- package/e2e/deploy-api.test.ts +1129 -0
- package/e2e/moss-cli.test.ts +478 -0
- package/features/auth/device-flow.feature +41 -0
- package/features/deploy/validation.feature +50 -0
- package/features/steps/auth.steps.ts +285 -0
- package/features/steps/deploy.steps.ts +354 -0
- package/package.json +51 -0
- package/src/__tests__/auth-flow.integration.test.ts +738 -0
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/configure-domain.test.ts +263 -0
- package/src/__tests__/deploy.integration.test.ts +798 -0
- package/src/__tests__/git.test.ts +190 -0
- package/src/__tests__/github-api.test.ts +761 -0
- package/src/__tests__/github-deploy.test.ts +2411 -0
- package/src/__tests__/progress-timeout.test.ts +209 -0
- package/src/__tests__/repo-setup-progress.test.ts +367 -0
- package/src/__tests__/repo-setup.test.ts +370 -0
- package/src/__tests__/token.test.ts +152 -0
- package/src/__tests__/utils.test.ts +129 -0
- package/src/__tests__/workflow.test.ts +146 -0
- package/src/auth.ts +588 -0
- package/src/constants.ts +7 -0
- package/src/git.ts +60 -0
- package/src/github-api.ts +601 -0
- package/src/github-deploy.ts +593 -0
- package/src/main.ts +646 -0
- package/src/repo-setup.ts +685 -0
- package/src/token.ts +202 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +108 -0
- package/src/workflow.ts +79 -0
- package/test-helpers/mock-github-api.ts +217 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +50 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the on_deploy hook
|
|
3
|
+
*
|
|
4
|
+
* Uses @symbiosis-lab/moss-api/testing to mock Tauri IPC commands
|
|
5
|
+
* and test the full deployment flow with various scenarios.
|
|
6
|
+
*
|
|
7
|
+
* The deploy target is derived from .git origin (via getOriginOwnerRepo),
|
|
8
|
+
* not from a config file. No config.json, no validation.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
setupMockTauri,
|
|
14
|
+
type MockTauriContext,
|
|
15
|
+
} from "@symbiosis-lab/moss-api/testing";
|
|
16
|
+
import type { DeployContext } from "../types";
|
|
17
|
+
|
|
18
|
+
// We need to mock the utils module to prevent actual IPC calls for logging
|
|
19
|
+
vi.mock("../utils", () => ({
|
|
20
|
+
reportProgress: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
reportError: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
reportComplete: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
setCurrentHookName: vi.fn(),
|
|
24
|
+
showToast: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
dismissToast: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
closeBrowser: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
sleep: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// Mock the github-deploy module
|
|
31
|
+
vi.mock("../github-deploy", () => ({
|
|
32
|
+
verifyRepoExists: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
getOriginOwnerRepo: vi.fn().mockResolvedValue({ owner: "test-user", repo: "test-repo" }),
|
|
34
|
+
deployViaGitPush: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// Mock the auth module
|
|
38
|
+
vi.mock("../auth", () => ({
|
|
39
|
+
promptLogin: vi.fn(),
|
|
40
|
+
checkAuthentication: vi.fn(),
|
|
41
|
+
validateToken: vi.fn(),
|
|
42
|
+
hasRequiredScopes: vi.fn(),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
// Mock the token module
|
|
46
|
+
vi.mock("../token", () => ({
|
|
47
|
+
getToken: vi.fn(),
|
|
48
|
+
getTokenFromGit: vi.fn(),
|
|
49
|
+
storeToken: vi.fn(),
|
|
50
|
+
clearToken: vi.fn(),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
// Mock the git module
|
|
54
|
+
vi.mock("../git", () => ({
|
|
55
|
+
buildPagesUrl: vi.fn().mockImplementation((owner: string, repo: string) => {
|
|
56
|
+
if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
|
|
57
|
+
return `https://${owner}.github.io`;
|
|
58
|
+
}
|
|
59
|
+
return `https://${owner}.github.io/${repo}`;
|
|
60
|
+
}),
|
|
61
|
+
parseGitHubUrl: vi.fn().mockImplementation((remoteUrl: string) => {
|
|
62
|
+
const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
63
|
+
if (m) return { owner: m[1], repo: m[2] };
|
|
64
|
+
return null;
|
|
65
|
+
}),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Mock the repo-setup module
|
|
69
|
+
vi.mock("../repo-setup", () => ({
|
|
70
|
+
ensureGitHubRepo: vi.fn(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// Mock @symbiosis-lab/moss-api
|
|
74
|
+
vi.mock("@symbiosis-lab/moss-api", () => ({
|
|
75
|
+
readPluginFile: vi.fn(),
|
|
76
|
+
writePluginFile: vi.fn(),
|
|
77
|
+
pluginFileExists: vi.fn(),
|
|
78
|
+
listSiteFilesWithSizes: vi.fn().mockResolvedValue([]),
|
|
79
|
+
getTauriCore: vi.fn().mockReturnValue({
|
|
80
|
+
invoke: vi.fn().mockResolvedValue("git"),
|
|
81
|
+
}),
|
|
82
|
+
fetchUrl: vi.fn().mockResolvedValue({ ok: true, status: 200 }),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// Mock the github-api module (checkPagesStatus used by waitForPagesLive)
|
|
86
|
+
vi.mock("../github-api", () => ({
|
|
87
|
+
checkPagesStatus: vi.fn().mockResolvedValue({ status: "built", url: "", commit: "orphan-sha-abc1234def5678" }),
|
|
88
|
+
requestPagesBuild: vi.fn().mockResolvedValue(true),
|
|
89
|
+
getAuthenticatedUser: vi.fn(),
|
|
90
|
+
checkRepoExists: vi.fn(),
|
|
91
|
+
createRepository: vi.fn(),
|
|
92
|
+
setCustomDomain: vi.fn(),
|
|
93
|
+
ensurePagesSource: vi.fn().mockResolvedValue({ configured: true, wasCreated: false }),
|
|
94
|
+
getPages: vi.fn().mockResolvedValue(null),
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
// Import after mocking
|
|
98
|
+
import { on_deploy } from "../main";
|
|
99
|
+
import { reportProgress, showToast } from "../utils";
|
|
100
|
+
import { verifyRepoExists, getOriginOwnerRepo, deployViaGitPush } from "../github-deploy";
|
|
101
|
+
import { promptLogin, validateToken, hasRequiredScopes } from "../auth";
|
|
102
|
+
import { getToken, getTokenFromGit, storeToken } from "../token";
|
|
103
|
+
import { buildPagesUrl, parseGitHubUrl } from "../git";
|
|
104
|
+
import { checkPagesStatus, ensurePagesSource, getPages } from "../github-api";
|
|
105
|
+
import { ensureGitHubRepo } from "../repo-setup";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a mock DeployContext for testing
|
|
109
|
+
*/
|
|
110
|
+
function createMockContext(overrides?: Partial<DeployContext>): DeployContext {
|
|
111
|
+
return {
|
|
112
|
+
project_path: "/test/project",
|
|
113
|
+
moss_dir: "/test/project/.moss",
|
|
114
|
+
output_dir: "/test/project/.moss/build/site",
|
|
115
|
+
site_files: ["index.html", "style.css"],
|
|
116
|
+
project_info: {
|
|
117
|
+
project_type: "markdown",
|
|
118
|
+
content_folders: ["posts"],
|
|
119
|
+
total_files: 10,
|
|
120
|
+
homepage_file: "index.md",
|
|
121
|
+
},
|
|
122
|
+
config: {},
|
|
123
|
+
...overrides,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Set up common mocks for a successful deployment flow.
|
|
129
|
+
*
|
|
130
|
+
* The deploy target comes from getOriginOwnerRepo() (reads .git origin).
|
|
131
|
+
* When needsSetup=true, getOriginOwnerRepo returns null and ensureGitHubRepo runs.
|
|
132
|
+
*/
|
|
133
|
+
function setupDeployMocks(
|
|
134
|
+
_ctx: MockTauriContext,
|
|
135
|
+
options?: {
|
|
136
|
+
hasChanges?: boolean;
|
|
137
|
+
commitSha?: string;
|
|
138
|
+
token?: string;
|
|
139
|
+
owner?: string;
|
|
140
|
+
repo?: string;
|
|
141
|
+
needsSetup?: boolean;
|
|
142
|
+
}
|
|
143
|
+
) {
|
|
144
|
+
const {
|
|
145
|
+
hasChanges = true,
|
|
146
|
+
commitSha = "abc1234def5678",
|
|
147
|
+
token = "test-token",
|
|
148
|
+
owner = "test-user",
|
|
149
|
+
repo = "test-repo",
|
|
150
|
+
needsSetup = false,
|
|
151
|
+
} = options ?? {};
|
|
152
|
+
|
|
153
|
+
// Token is available
|
|
154
|
+
vi.mocked(getToken).mockResolvedValue(token);
|
|
155
|
+
vi.mocked(getTokenFromGit).mockResolvedValue(null);
|
|
156
|
+
|
|
157
|
+
if (needsSetup) {
|
|
158
|
+
// No .git origin — setup flow runs
|
|
159
|
+
vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
|
|
160
|
+
vi.mocked(ensureGitHubRepo).mockResolvedValue({
|
|
161
|
+
name: repo,
|
|
162
|
+
fullName: `${owner}/${repo}`,
|
|
163
|
+
sshUrl: `git@github.com:${owner}/${repo}.git`,
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
// .git origin exists — use it directly
|
|
167
|
+
vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner, repo });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Deploy result (now returns DeployResult object)
|
|
171
|
+
vi.mocked(deployViaGitPush).mockResolvedValue(
|
|
172
|
+
hasChanges
|
|
173
|
+
? { commitSha: commitSha, orphanSha: "orphan-sha-" + commitSha, treeChanged: true }
|
|
174
|
+
: { commitSha: "", orphanSha: "", treeChanged: false }
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Pages status check
|
|
178
|
+
vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built", url: "", commit: "orphan-sha-" + commitSha });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe("on_deploy integration", () => {
|
|
182
|
+
let ctx: MockTauriContext;
|
|
183
|
+
|
|
184
|
+
beforeEach(async () => {
|
|
185
|
+
ctx = setupMockTauri();
|
|
186
|
+
vi.clearAllMocks();
|
|
187
|
+
|
|
188
|
+
// Restore default implementations after clearAllMocks
|
|
189
|
+
vi.mocked(parseGitHubUrl).mockImplementation((remoteUrl: string) => {
|
|
190
|
+
const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
191
|
+
if (m) return { owner: m[1], repo: m[2] };
|
|
192
|
+
return null;
|
|
193
|
+
});
|
|
194
|
+
vi.mocked(buildPagesUrl).mockImplementation((owner: string, repo: string) => {
|
|
195
|
+
if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
|
|
196
|
+
return `https://${owner}.github.io`;
|
|
197
|
+
}
|
|
198
|
+
return `https://${owner}.github.io/${repo}`;
|
|
199
|
+
});
|
|
200
|
+
vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "test-user", repo: "test-repo" });
|
|
201
|
+
vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built", url: "", commit: "orphan-sha-abc1234def5678" });
|
|
202
|
+
vi.mocked(getPages).mockResolvedValue(null);
|
|
203
|
+
|
|
204
|
+
// Reset fetchUrl to default (site reachable) — individual tests override as needed
|
|
205
|
+
const mossApi = await import("@symbiosis-lab/moss-api");
|
|
206
|
+
vi.mocked(mossApi.fetchUrl).mockResolvedValue({ ok: true, status: 200, body: new Uint8Array(), contentType: null, text: () => "" });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
afterEach(() => {
|
|
210
|
+
ctx.cleanup();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("Deploy Target Resolution", () => {
|
|
214
|
+
it("reads deploy target from git origin", async () => {
|
|
215
|
+
setupDeployMocks(ctx, { owner: "myuser", repo: "mysite" });
|
|
216
|
+
|
|
217
|
+
const result = await on_deploy(createMockContext());
|
|
218
|
+
|
|
219
|
+
expect(result.success).toBe(true);
|
|
220
|
+
expect(vi.mocked(getOriginOwnerRepo)).toHaveBeenCalled();
|
|
221
|
+
expect(vi.mocked(deployViaGitPush)).toHaveBeenCalledWith(
|
|
222
|
+
expect.objectContaining({ owner: "myuser", repo: "mysite" })
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("runs setup when no git origin exists", async () => {
|
|
227
|
+
vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
|
|
228
|
+
vi.mocked(ensureGitHubRepo).mockResolvedValue(null);
|
|
229
|
+
|
|
230
|
+
const result = await on_deploy(createMockContext());
|
|
231
|
+
|
|
232
|
+
expect(result.success).toBe(false);
|
|
233
|
+
expect(result.message).toContain("cancelled");
|
|
234
|
+
expect(vi.mocked(ensureGitHubRepo)).toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("uses setup result for deploy when no git origin", async () => {
|
|
238
|
+
setupDeployMocks(ctx, {
|
|
239
|
+
needsSetup: true,
|
|
240
|
+
owner: "newuser",
|
|
241
|
+
repo: "newsite",
|
|
242
|
+
hasChanges: true,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const result = await on_deploy(createMockContext());
|
|
246
|
+
|
|
247
|
+
expect(result.success).toBe(true);
|
|
248
|
+
expect(result.deployment?.metadata?.was_first_setup).toBe("true");
|
|
249
|
+
expect(result.deployment?.metadata?.repo_url).toBe("https://github.com/newuser/newsite");
|
|
250
|
+
expect(vi.mocked(deployViaGitPush)).toHaveBeenCalledWith(
|
|
251
|
+
expect.objectContaining({ owner: "newuser", repo: "newsite" })
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("Site Compilation Validation", () => {
|
|
257
|
+
it("fails when context.site_files is empty", async () => {
|
|
258
|
+
const result = await on_deploy(
|
|
259
|
+
createMockContext({ site_files: [] })
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(result.success).toBe(false);
|
|
263
|
+
expect(result.message).toMatch(/site.*empty|build.*first/i);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("passes validation when context.site_files has files", async () => {
|
|
267
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
268
|
+
|
|
269
|
+
const result = await on_deploy(
|
|
270
|
+
createMockContext({ site_files: ["index.html", "style.css", "app.js"] })
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(result.success).toBe(true);
|
|
274
|
+
expect(result.message).not.toContain("Site directory is empty");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("Successful Deployment", () => {
|
|
279
|
+
it("returns success with deployment info", async () => {
|
|
280
|
+
setupDeployMocks(ctx, {
|
|
281
|
+
owner: "testuser",
|
|
282
|
+
repo: "testrepo",
|
|
283
|
+
hasChanges: true,
|
|
284
|
+
commitSha: "abc123def",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const result = await on_deploy(createMockContext());
|
|
288
|
+
|
|
289
|
+
expect(result.success).toBe(true);
|
|
290
|
+
expect(result.deployment).toBeDefined();
|
|
291
|
+
expect(result.deployment?.method).toBe("github-pages");
|
|
292
|
+
expect(result.deployment?.url).toBe("https://testuser.github.io/testrepo");
|
|
293
|
+
expect(result.deployment?.metadata?.repo_url).toBe("https://github.com/testuser/testrepo");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("indicates first-time setup in message", async () => {
|
|
297
|
+
setupDeployMocks(ctx, {
|
|
298
|
+
needsSetup: true,
|
|
299
|
+
hasChanges: true,
|
|
300
|
+
commitSha: "abc123",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const result = await on_deploy(createMockContext());
|
|
304
|
+
|
|
305
|
+
expect(result.success).toBe(true);
|
|
306
|
+
expect(result.message).toContain("deployed");
|
|
307
|
+
expect(result.deployment?.metadata?.was_first_setup).toBe("true");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("was_first_setup is false for subsequent deploys", async () => {
|
|
311
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
312
|
+
|
|
313
|
+
const result = await on_deploy(createMockContext());
|
|
314
|
+
|
|
315
|
+
expect(result.success).toBe(true);
|
|
316
|
+
expect(result.deployment?.metadata?.was_first_setup).toBe("false");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("No Changes Detection", () => {
|
|
321
|
+
it("reports no changes when deployViaGitPush returns empty string", async () => {
|
|
322
|
+
setupDeployMocks(ctx, { hasChanges: false });
|
|
323
|
+
|
|
324
|
+
const result = await on_deploy(createMockContext());
|
|
325
|
+
|
|
326
|
+
expect(result.success).toBe(true);
|
|
327
|
+
expect(result.message).toContain("No changes to deploy");
|
|
328
|
+
expect(result.deployment?.metadata?.commit_sha).toBe("");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("Deployment via git push", () => {
|
|
333
|
+
it("calls deployViaGitPush with correct owner/repo", async () => {
|
|
334
|
+
setupDeployMocks(ctx, {
|
|
335
|
+
owner: "user",
|
|
336
|
+
repo: "repo",
|
|
337
|
+
hasChanges: true,
|
|
338
|
+
commitSha: "new-commit-sha",
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const result = await on_deploy(createMockContext());
|
|
342
|
+
|
|
343
|
+
expect(result.success).toBe(true);
|
|
344
|
+
expect(vi.mocked(deployViaGitPush)).toHaveBeenCalledTimes(1);
|
|
345
|
+
const deployCall = vi.mocked(deployViaGitPush).mock.calls[0][0];
|
|
346
|
+
expect(deployCall.owner).toBe("user");
|
|
347
|
+
expect(deployCall.repo).toBe("repo");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("handles git push errors gracefully", async () => {
|
|
351
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
352
|
+
vi.mocked(deployViaGitPush).mockRejectedValue(new Error("git push failed: remote rejected"));
|
|
353
|
+
|
|
354
|
+
const result = await on_deploy(createMockContext());
|
|
355
|
+
|
|
356
|
+
expect(result.success).toBe(false);
|
|
357
|
+
expect(result.message).toContain("remote rejected");
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("Authentication", () => {
|
|
362
|
+
it("prompts OAuth when no token available", async () => {
|
|
363
|
+
vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "user", repo: "repo" });
|
|
364
|
+
vi.mocked(getToken).mockResolvedValue(null);
|
|
365
|
+
vi.mocked(getTokenFromGit).mockResolvedValue(null);
|
|
366
|
+
|
|
367
|
+
vi.mocked(promptLogin).mockResolvedValue(true);
|
|
368
|
+
vi.mocked(getToken)
|
|
369
|
+
.mockResolvedValueOnce(null) // Phase 1 check
|
|
370
|
+
.mockResolvedValueOnce("new-token"); // after promptLogin
|
|
371
|
+
|
|
372
|
+
vi.mocked(deployViaGitPush).mockResolvedValue({ commitSha: "", orphanSha: "", treeChanged: false });
|
|
373
|
+
|
|
374
|
+
const result = await on_deploy(createMockContext());
|
|
375
|
+
|
|
376
|
+
expect(vi.mocked(promptLogin)).toHaveBeenCalled();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("uses git credential token when valid", async () => {
|
|
380
|
+
vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "test-user", repo: "test-repo" });
|
|
381
|
+
vi.mocked(getToken).mockResolvedValue(null);
|
|
382
|
+
vi.mocked(getTokenFromGit).mockResolvedValue("git-credential-token");
|
|
383
|
+
|
|
384
|
+
vi.mocked(validateToken).mockResolvedValue({
|
|
385
|
+
valid: true,
|
|
386
|
+
user: { login: "test-user", id: 1, avatar_url: "", html_url: "" },
|
|
387
|
+
scopes: ["repo"],
|
|
388
|
+
});
|
|
389
|
+
vi.mocked(hasRequiredScopes).mockReturnValue(true);
|
|
390
|
+
|
|
391
|
+
vi.mocked(deployViaGitPush).mockResolvedValue({ commitSha: "commit-sha", orphanSha: "orphan-commit-sha", treeChanged: true });
|
|
392
|
+
vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built", url: "", commit: "orphan-commit-sha" });
|
|
393
|
+
|
|
394
|
+
const result = await on_deploy(createMockContext());
|
|
395
|
+
|
|
396
|
+
expect(vi.mocked(validateToken)).toHaveBeenCalledWith("git-credential-token");
|
|
397
|
+
expect(vi.mocked(storeToken)).toHaveBeenCalledWith("git-credential-token");
|
|
398
|
+
expect(result.success).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("falls through to OAuth when git credential token lacks scopes", async () => {
|
|
402
|
+
vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "test-user", repo: "test-repo" });
|
|
403
|
+
vi.mocked(getToken).mockResolvedValue(null);
|
|
404
|
+
vi.mocked(getTokenFromGit).mockResolvedValue("weak-git-token");
|
|
405
|
+
|
|
406
|
+
vi.mocked(validateToken).mockResolvedValue({
|
|
407
|
+
valid: true,
|
|
408
|
+
user: { login: "test-user", id: 1, avatar_url: "", html_url: "" },
|
|
409
|
+
scopes: ["gist"],
|
|
410
|
+
});
|
|
411
|
+
vi.mocked(hasRequiredScopes).mockReturnValue(false);
|
|
412
|
+
|
|
413
|
+
vi.mocked(promptLogin).mockResolvedValue(false);
|
|
414
|
+
|
|
415
|
+
const result = await on_deploy(createMockContext());
|
|
416
|
+
|
|
417
|
+
expect(vi.mocked(storeToken)).not.toHaveBeenCalledWith("weak-git-token");
|
|
418
|
+
expect(vi.mocked(promptLogin)).toHaveBeenCalled();
|
|
419
|
+
expect(result.success).toBe(false);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("auth error returns helpful message", async () => {
|
|
423
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
424
|
+
vi.mocked(deployViaGitPush).mockRejectedValue(
|
|
425
|
+
new Error("Bad credentials - authentication failed")
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const result = await on_deploy(createMockContext());
|
|
429
|
+
|
|
430
|
+
expect(result.success).toBe(false);
|
|
431
|
+
const toastCalls = vi.mocked(showToast).mock.calls;
|
|
432
|
+
const errorToasts = toastCalls.filter((call) => call[0]?.variant === "error");
|
|
433
|
+
expect(errorToasts.length).toBeGreaterThan(0);
|
|
434
|
+
expect(errorToasts[0][0].message).toContain("Authentication failed");
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe("Error Categorization", () => {
|
|
439
|
+
it("shows helpful message for timeout errors", async () => {
|
|
440
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
441
|
+
vi.mocked(deployViaGitPush).mockRejectedValue(
|
|
442
|
+
new Error("Request timed out after 300000 ms")
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const result = await on_deploy(createMockContext());
|
|
446
|
+
|
|
447
|
+
expect(result.success).toBe(false);
|
|
448
|
+
const toastCalls = vi.mocked(showToast).mock.calls;
|
|
449
|
+
const errorToasts = toastCalls.filter((call) => call[0]?.variant === "error");
|
|
450
|
+
expect(errorToasts.length).toBeGreaterThan(0);
|
|
451
|
+
expect(errorToasts[0][0].message).toContain("may still be running");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("shows network error message for connection failures", async () => {
|
|
455
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
456
|
+
vi.mocked(deployViaGitPush).mockRejectedValue(
|
|
457
|
+
new Error("Network connection failed")
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const result = await on_deploy(createMockContext());
|
|
461
|
+
|
|
462
|
+
expect(result.success).toBe(false);
|
|
463
|
+
const toastCalls = vi.mocked(showToast).mock.calls;
|
|
464
|
+
const errorToasts = toastCalls.filter((call) => call[0]?.variant === "error");
|
|
465
|
+
expect(errorToasts.length).toBeGreaterThan(0);
|
|
466
|
+
expect(errorToasts[0][0].message).toContain("Network error");
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("Progress Visibility", () => {
|
|
471
|
+
it("reports progress during deployment", async () => {
|
|
472
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
473
|
+
|
|
474
|
+
const result = await on_deploy(createMockContext());
|
|
475
|
+
|
|
476
|
+
expect(result.success).toBe(true);
|
|
477
|
+
const progressCalls = vi.mocked(reportProgress).mock.calls;
|
|
478
|
+
expect(progressCalls.length).toBeGreaterThan(0);
|
|
479
|
+
for (const call of progressCalls) {
|
|
480
|
+
expect(call[2]).toBe(10); // total=10
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("progress is monotonically increasing", async () => {
|
|
485
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
486
|
+
|
|
487
|
+
const result = await on_deploy(createMockContext());
|
|
488
|
+
|
|
489
|
+
expect(result.success).toBe(true);
|
|
490
|
+
const progressCalls = vi.mocked(reportProgress).mock.calls;
|
|
491
|
+
const currentValues = progressCalls.map((call) => call[1]);
|
|
492
|
+
|
|
493
|
+
for (let i = 1; i < currentValues.length; i++) {
|
|
494
|
+
expect(currentValues[i]).toBeGreaterThanOrEqual(currentValues[i - 1]);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe("Pages Source Configuration", () => {
|
|
500
|
+
it("calls ensurePagesSource after successful deploy", async () => {
|
|
501
|
+
setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true, token: "test-token" });
|
|
502
|
+
|
|
503
|
+
const result = await on_deploy(createMockContext());
|
|
504
|
+
|
|
505
|
+
expect(result.success).toBe(true);
|
|
506
|
+
expect(vi.mocked(ensurePagesSource)).toHaveBeenCalledWith("user", "repo", "test-token", "gh-pages");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("does not fail deploy when ensurePagesSource fails", async () => {
|
|
510
|
+
setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true });
|
|
511
|
+
vi.mocked(ensurePagesSource).mockRejectedValue(new Error("API error"));
|
|
512
|
+
|
|
513
|
+
const result = await on_deploy(createMockContext());
|
|
514
|
+
|
|
515
|
+
expect(result.success).toBe(true);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("calls ensurePagesSource even when no changes to push", async () => {
|
|
519
|
+
setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: false, token: "test-token" });
|
|
520
|
+
|
|
521
|
+
const result = await on_deploy(createMockContext());
|
|
522
|
+
|
|
523
|
+
expect(result.success).toBe(true);
|
|
524
|
+
expect(vi.mocked(ensurePagesSource)).toHaveBeenCalledWith("user", "repo", "test-token", "gh-pages");
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("Verify Repo Exists", () => {
|
|
529
|
+
it("calls verifyRepoExists before deploying", async () => {
|
|
530
|
+
setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true });
|
|
531
|
+
|
|
532
|
+
const result = await on_deploy(createMockContext());
|
|
533
|
+
|
|
534
|
+
expect(result.success).toBe(true);
|
|
535
|
+
expect(vi.mocked(verifyRepoExists)).toHaveBeenCalledWith("user", "repo", "test-token");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("fails with clear error when repo does not exist", async () => {
|
|
539
|
+
setupDeployMocks(ctx, { hasChanges: true });
|
|
540
|
+
vi.mocked(verifyRepoExists).mockRejectedValue(
|
|
541
|
+
new Error('Repository "test-user/test-repo" not found on GitHub.')
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const result = await on_deploy(createMockContext());
|
|
545
|
+
|
|
546
|
+
expect(result.success).toBe(false);
|
|
547
|
+
expect(result.message).toContain("not found");
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// ==========================================================================
|
|
552
|
+
// Bug 2: Stale build detection via orphanSha
|
|
553
|
+
// ==========================================================================
|
|
554
|
+
describe("Stale Build Detection", () => {
|
|
555
|
+
it("detects stale build when commit SHA does not match and site not reachable", async () => {
|
|
556
|
+
setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true, commitSha: "new-commit" });
|
|
557
|
+
vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
|
|
558
|
+
|
|
559
|
+
// checkPagesStatus returns "built" but with a DIFFERENT commit SHA (stale)
|
|
560
|
+
vi.mocked(checkPagesStatus)
|
|
561
|
+
.mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
|
|
562
|
+
.mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
|
|
563
|
+
.mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
|
|
564
|
+
.mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
|
|
565
|
+
.mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
|
|
566
|
+
.mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" });
|
|
567
|
+
|
|
568
|
+
// Site not yet reachable (stale content still propagating)
|
|
569
|
+
const { fetchUrl } = await import("@symbiosis-lab/moss-api");
|
|
570
|
+
vi.mocked(fetchUrl).mockResolvedValue({ ok: false, status: 404, body: new Uint8Array(), contentType: null, text: () => "" });
|
|
571
|
+
|
|
572
|
+
const result = await on_deploy(createMockContext());
|
|
573
|
+
|
|
574
|
+
expect(result.success).toBe(true);
|
|
575
|
+
// isLive should be false: stale build + HTTP check fails
|
|
576
|
+
expect(result.deployment?.metadata?.is_live).toBe("false");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("recognizes fresh build when commit SHA matches orphanSha", async () => {
|
|
580
|
+
setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true, commitSha: "fresh-commit" });
|
|
581
|
+
vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
|
|
582
|
+
|
|
583
|
+
// checkPagesStatus returns "built" with the MATCHING orphan SHA
|
|
584
|
+
vi.mocked(checkPagesStatus).mockResolvedValue({
|
|
585
|
+
status: "built",
|
|
586
|
+
url: "",
|
|
587
|
+
commit: "orphan-sha-fresh-commit",
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Site is reachable via HTTP
|
|
591
|
+
const { fetchUrl } = await import("@symbiosis-lab/moss-api");
|
|
592
|
+
vi.mocked(fetchUrl).mockResolvedValue({ ok: true, status: 200, body: new Uint8Array(), contentType: null, text: () => "" });
|
|
593
|
+
|
|
594
|
+
const result = await on_deploy(createMockContext());
|
|
595
|
+
|
|
596
|
+
expect(result.success).toBe(true);
|
|
597
|
+
expect(result.deployment?.metadata?.is_live).toBe("true");
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ==========================================================================
|
|
602
|
+
// Bug 3: Error/unknown status toast messaging
|
|
603
|
+
// ==========================================================================
|
|
604
|
+
describe("Error and Unknown Status Toast Messaging", () => {
|
|
605
|
+
it("shows warning toast with error message when build errors", async () => {
|
|
606
|
+
setupDeployMocks(ctx, { owner: "user", repo: "my-repo", hasChanges: true, commitSha: "errored-commit" });
|
|
607
|
+
vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
|
|
608
|
+
|
|
609
|
+
// Build errors on GitHub
|
|
610
|
+
vi.mocked(checkPagesStatus).mockResolvedValue({
|
|
611
|
+
status: "errored",
|
|
612
|
+
url: "",
|
|
613
|
+
error: "Build failed: invalid config",
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const result = await on_deploy(createMockContext());
|
|
617
|
+
|
|
618
|
+
expect(result.success).toBe(true);
|
|
619
|
+
|
|
620
|
+
// Find the toast call for deploy status (the last showToast call)
|
|
621
|
+
const toastCalls = vi.mocked(showToast).mock.calls;
|
|
622
|
+
const lastToast = toastCalls[toastCalls.length - 1][0];
|
|
623
|
+
expect(lastToast).toMatchObject({
|
|
624
|
+
variant: "warning",
|
|
625
|
+
});
|
|
626
|
+
// Should have "View on GitHub" action pointing to settings/pages
|
|
627
|
+
expect(lastToast.actions).toBeDefined();
|
|
628
|
+
expect(lastToast.actions[0].url).toContain("settings/pages");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("shows info toast when build status is unknown/timeout and site not reachable", async () => {
|
|
632
|
+
setupDeployMocks(ctx, { owner: "user", repo: "my-repo", hasChanges: true, commitSha: "timeout-commit" });
|
|
633
|
+
vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
|
|
634
|
+
|
|
635
|
+
// Pages returns "building" every time (simulates timeout)
|
|
636
|
+
vi.mocked(checkPagesStatus).mockResolvedValue({
|
|
637
|
+
status: "building",
|
|
638
|
+
url: "",
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// HTTP check also fails (site not yet available)
|
|
642
|
+
const { fetchUrl } = await import("@symbiosis-lab/moss-api");
|
|
643
|
+
vi.mocked(fetchUrl).mockResolvedValue({ ok: false, status: 404, body: new Uint8Array(), contentType: null, text: () => "" });
|
|
644
|
+
|
|
645
|
+
const result = await on_deploy(createMockContext());
|
|
646
|
+
|
|
647
|
+
expect(result.success).toBe(true);
|
|
648
|
+
|
|
649
|
+
// Should show an info toast with "View site" action (not GitHub Actions link)
|
|
650
|
+
const toastCalls = vi.mocked(showToast).mock.calls;
|
|
651
|
+
const lastToast = toastCalls[toastCalls.length - 1][0];
|
|
652
|
+
expect(lastToast.variant).toBe("info");
|
|
653
|
+
expect(lastToast.actions).toBeDefined();
|
|
654
|
+
expect(lastToast.actions[0].url).toContain("user.github.io");
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// ==========================================================================
|
|
659
|
+
// Custom Domain Display URLs
|
|
660
|
+
// ==========================================================================
|
|
661
|
+
describe("Custom domain display URLs", () => {
|
|
662
|
+
it("uses custom domain in deployment.url when context.domain is set", async () => {
|
|
663
|
+
setupDeployMocks(ctx, {
|
|
664
|
+
owner: "testuser",
|
|
665
|
+
repo: "testrepo",
|
|
666
|
+
hasChanges: true,
|
|
667
|
+
commitSha: "abc123def",
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const result = await on_deploy(createMockContext({ domain: "example.com" }));
|
|
671
|
+
|
|
672
|
+
expect(result.success).toBe(true);
|
|
673
|
+
expect(result.deployment?.url).toBe("https://example.com");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("uses github.io URL when context.domain is not set", async () => {
|
|
677
|
+
setupDeployMocks(ctx, {
|
|
678
|
+
owner: "testuser",
|
|
679
|
+
repo: "testrepo",
|
|
680
|
+
hasChanges: true,
|
|
681
|
+
commitSha: "abc123def",
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const result = await on_deploy(createMockContext());
|
|
685
|
+
|
|
686
|
+
expect(result.success).toBe(true);
|
|
687
|
+
expect(result.deployment?.url).toBe("https://testuser.github.io/testrepo");
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("uses custom domain in toast actions", async () => {
|
|
691
|
+
setupDeployMocks(ctx, {
|
|
692
|
+
owner: "testuser",
|
|
693
|
+
repo: "testrepo",
|
|
694
|
+
hasChanges: true,
|
|
695
|
+
commitSha: "abc123def",
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
await on_deploy(createMockContext({ domain: "example.com" }));
|
|
699
|
+
|
|
700
|
+
const toastCalls = vi.mocked(showToast).mock.calls;
|
|
701
|
+
const lastToast = toastCalls[toastCalls.length - 1][0];
|
|
702
|
+
expect(lastToast.actions[0].url).toBe("https://example.com");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("falls back to GitHub Pages CNAME when no context.domain", async () => {
|
|
706
|
+
setupDeployMocks(ctx, {
|
|
707
|
+
owner: "testuser",
|
|
708
|
+
repo: "testrepo",
|
|
709
|
+
hasChanges: true,
|
|
710
|
+
commitSha: "abc123def",
|
|
711
|
+
});
|
|
712
|
+
vi.mocked(getPages).mockResolvedValue({ cname: "example.com", https_enforced: true });
|
|
713
|
+
|
|
714
|
+
const result = await on_deploy(createMockContext());
|
|
715
|
+
|
|
716
|
+
expect(result.success).toBe(true);
|
|
717
|
+
expect(result.deployment?.url).toBe("https://example.com");
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ==========================================================================
|
|
722
|
+
// HTTP Reachability Verification
|
|
723
|
+
// ==========================================================================
|
|
724
|
+
describe("HTTP reachability verification", () => {
|
|
725
|
+
it("reports isLive=true when site is reachable via HTTP", async () => {
|
|
726
|
+
setupDeployMocks(ctx, {
|
|
727
|
+
owner: "testuser",
|
|
728
|
+
repo: "testrepo",
|
|
729
|
+
hasChanges: true,
|
|
730
|
+
commitSha: "abc123def",
|
|
731
|
+
});
|
|
732
|
+
// fetchUrl returns ok: true (site is reachable)
|
|
733
|
+
const { fetchUrl } = await import("@symbiosis-lab/moss-api");
|
|
734
|
+
vi.mocked(fetchUrl).mockResolvedValue({ ok: true, status: 200, body: new Uint8Array(), contentType: null, text: () => "" });
|
|
735
|
+
|
|
736
|
+
const result = await on_deploy(createMockContext());
|
|
737
|
+
|
|
738
|
+
expect(result.success).toBe(true);
|
|
739
|
+
expect(result.deployment?.metadata?.is_live).toBe("true");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("reports isLive=false when site is not reachable", async () => {
|
|
743
|
+
setupDeployMocks(ctx, {
|
|
744
|
+
owner: "testuser",
|
|
745
|
+
repo: "testrepo",
|
|
746
|
+
hasChanges: true,
|
|
747
|
+
commitSha: "abc123def",
|
|
748
|
+
});
|
|
749
|
+
// GitHub API says built, but HTTP check fails
|
|
750
|
+
const { fetchUrl } = await import("@symbiosis-lab/moss-api");
|
|
751
|
+
vi.mocked(fetchUrl).mockResolvedValue({ ok: false, status: 404, body: new Uint8Array(), contentType: null, text: () => "" });
|
|
752
|
+
|
|
753
|
+
const result = await on_deploy(createMockContext());
|
|
754
|
+
|
|
755
|
+
expect(result.success).toBe(true);
|
|
756
|
+
expect(result.deployment?.metadata?.is_live).toBe("false");
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("shows info toast when site not reachable after deploy", async () => {
|
|
760
|
+
setupDeployMocks(ctx, {
|
|
761
|
+
owner: "testuser",
|
|
762
|
+
repo: "testrepo",
|
|
763
|
+
hasChanges: true,
|
|
764
|
+
commitSha: "abc123def",
|
|
765
|
+
});
|
|
766
|
+
const { fetchUrl } = await import("@symbiosis-lab/moss-api");
|
|
767
|
+
vi.mocked(fetchUrl).mockResolvedValue({ ok: false, status: 404, body: new Uint8Array(), contentType: null, text: () => "" });
|
|
768
|
+
|
|
769
|
+
await on_deploy(createMockContext());
|
|
770
|
+
|
|
771
|
+
const toastCalls = vi.mocked(showToast).mock.calls;
|
|
772
|
+
const lastToast = toastCalls[toastCalls.length - 1][0];
|
|
773
|
+
// Should show info variant (not success) since site isn't reachable yet
|
|
774
|
+
expect(lastToast.variant).not.toBe("success");
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// ==========================================================================
|
|
779
|
+
// Bug 3: ensurePagesSource logging
|
|
780
|
+
// ==========================================================================
|
|
781
|
+
describe("ensurePagesSource logging", () => {
|
|
782
|
+
it("logs warning when ensurePagesSource returns configured: false", async () => {
|
|
783
|
+
setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true });
|
|
784
|
+
vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
|
|
785
|
+
vi.mocked(ensurePagesSource).mockResolvedValue({ configured: false, wasCreated: false });
|
|
786
|
+
|
|
787
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
788
|
+
|
|
789
|
+
const result = await on_deploy(createMockContext());
|
|
790
|
+
|
|
791
|
+
expect(result.success).toBe(true);
|
|
792
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
793
|
+
expect.stringContaining("Failed to configure GitHub Pages source")
|
|
794
|
+
);
|
|
795
|
+
consoleSpy.mockRestore();
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
});
|