@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +3 -0
  4. package/assets/manifest.json +19 -0
  5. package/e2e/deploy-api.test.ts +1129 -0
  6. package/e2e/moss-cli.test.ts +478 -0
  7. package/features/auth/device-flow.feature +41 -0
  8. package/features/deploy/validation.feature +50 -0
  9. package/features/steps/auth.steps.ts +285 -0
  10. package/features/steps/deploy.steps.ts +354 -0
  11. package/package.json +51 -0
  12. package/src/__tests__/auth-flow.integration.test.ts +738 -0
  13. package/src/__tests__/auth.test.ts +147 -0
  14. package/src/__tests__/configure-domain.test.ts +263 -0
  15. package/src/__tests__/deploy.integration.test.ts +798 -0
  16. package/src/__tests__/git.test.ts +190 -0
  17. package/src/__tests__/github-api.test.ts +761 -0
  18. package/src/__tests__/github-deploy.test.ts +2411 -0
  19. package/src/__tests__/progress-timeout.test.ts +209 -0
  20. package/src/__tests__/repo-setup-progress.test.ts +367 -0
  21. package/src/__tests__/repo-setup.test.ts +370 -0
  22. package/src/__tests__/token.test.ts +152 -0
  23. package/src/__tests__/utils.test.ts +129 -0
  24. package/src/__tests__/workflow.test.ts +146 -0
  25. package/src/auth.ts +588 -0
  26. package/src/constants.ts +7 -0
  27. package/src/git.ts +60 -0
  28. package/src/github-api.ts +601 -0
  29. package/src/github-deploy.ts +593 -0
  30. package/src/main.ts +646 -0
  31. package/src/repo-setup.ts +685 -0
  32. package/src/token.ts +202 -0
  33. package/src/types.ts +91 -0
  34. package/src/utils.ts +108 -0
  35. package/src/workflow.ts +79 -0
  36. package/test-helpers/mock-github-api.ts +217 -0
  37. package/tsconfig.json +20 -0
  38. package/vitest.config.ts +50 -0
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Step definitions for GitHub OAuth Device Flow authentication tests
3
+ */
4
+
5
+ import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
6
+ import { expect, vi, beforeEach, afterEach } from "vitest";
7
+ import {
8
+ createMockFetch,
9
+ defaultDeviceCodeResponse,
10
+ defaultTokenResponse,
11
+ defaultUserResponse,
12
+ authorizationPendingResponse,
13
+ expiredTokenResponse,
14
+ } from "../../test-helpers/mock-github-api";
15
+ import type { DeviceCodeResponse, TokenResponse, GitHubUser } from "../../src/types";
16
+
17
+ // Load the feature file
18
+ const feature = await loadFeature("features/auth/device-flow.feature");
19
+
20
+ describeFeature(feature, ({ Scenario, BeforeEachScenario, AfterEachScenario }) => {
21
+ // Test state
22
+ let mockFetch: ReturnType<typeof createMockFetch>;
23
+ let originalFetch: typeof global.fetch;
24
+ let deviceCodeResponse: DeviceCodeResponse | null = null;
25
+ let tokenResponse: TokenResponse | null = null;
26
+ let validationResult: { valid: boolean; user?: GitHubUser; scopes?: string[] } | null = null;
27
+ let storedToken: string | null = null;
28
+ let retrievedToken: string | null = null;
29
+
30
+ // Mock credential helper
31
+ const credentialStore = new Map<string, string>();
32
+
33
+ BeforeEachScenario(() => {
34
+ // Save original fetch
35
+ originalFetch = global.fetch;
36
+ // Reset state
37
+ deviceCodeResponse = null;
38
+ tokenResponse = null;
39
+ validationResult = null;
40
+ storedToken = null;
41
+ retrievedToken = null;
42
+ credentialStore.clear();
43
+ });
44
+
45
+ AfterEachScenario(() => {
46
+ // Restore original fetch
47
+ global.fetch = originalFetch;
48
+ vi.restoreAllMocks();
49
+ });
50
+
51
+ // ============================================================================
52
+ // Scenario: Request device code from GitHub
53
+ // ============================================================================
54
+
55
+ Scenario("Request device code from GitHub", ({ Given, When, Then, And }) => {
56
+ Given("no existing GitHub credentials", () => {
57
+ credentialStore.clear();
58
+ mockFetch = createMockFetch({
59
+ deviceCodeResponse: defaultDeviceCodeResponse,
60
+ });
61
+ global.fetch = mockFetch;
62
+ });
63
+
64
+ When("I initiate the device flow authentication", async () => {
65
+ const response = await fetch("https://github.com/login/device/code", {
66
+ method: "POST",
67
+ headers: {
68
+ Accept: "application/json",
69
+ "Content-Type": "application/json",
70
+ },
71
+ body: JSON.stringify({
72
+ client_id: "test-client-id",
73
+ scope: "repo workflow",
74
+ }),
75
+ });
76
+
77
+ deviceCodeResponse = await response.json();
78
+ });
79
+
80
+ Then("I should receive a device code response", () => {
81
+ expect(deviceCodeResponse).not.toBeNull();
82
+ expect(deviceCodeResponse?.device_code).toBeDefined();
83
+ });
84
+
85
+ And("the response should include user_code, verification_uri, and interval", () => {
86
+ expect(deviceCodeResponse?.user_code).toBe("ABCD-1234");
87
+ expect(deviceCodeResponse?.verification_uri).toBe("https://github.com/login/device");
88
+ expect(deviceCodeResponse?.interval).toBe(5);
89
+ });
90
+ });
91
+
92
+ // ============================================================================
93
+ // Scenario: Poll for access token after authorization
94
+ // ============================================================================
95
+
96
+ Scenario("Poll for access token after authorization", ({ Given, When, Then, And }) => {
97
+ Given("a valid device code", () => {
98
+ mockFetch = createMockFetch({
99
+ tokenResponse: defaultTokenResponse,
100
+ userResponse: defaultUserResponse,
101
+ scopes: ["repo", "workflow"],
102
+ });
103
+ global.fetch = mockFetch;
104
+ });
105
+
106
+ And("the user has authorized the application", () => {
107
+ // Mock is already configured to return success
108
+ });
109
+
110
+ When("I poll for the access token", async () => {
111
+ const response = await fetch("https://github.com/login/oauth/access_token", {
112
+ method: "POST",
113
+ headers: {
114
+ Accept: "application/json",
115
+ "Content-Type": "application/json",
116
+ },
117
+ body: JSON.stringify({
118
+ client_id: "test-client-id",
119
+ device_code: "test-device-code",
120
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
121
+ }),
122
+ });
123
+
124
+ tokenResponse = await response.json();
125
+ });
126
+
127
+ Then("I should receive an access token", () => {
128
+ expect(tokenResponse?.access_token).toBeDefined();
129
+ expect(tokenResponse?.access_token).toBe("gho_test_token_abc123");
130
+ });
131
+
132
+ And("the token should have repo and workflow scopes", () => {
133
+ expect(tokenResponse?.scope).toContain("repo");
134
+ expect(tokenResponse?.scope).toContain("workflow");
135
+ });
136
+ });
137
+
138
+ // ============================================================================
139
+ // Scenario: Handle authorization pending state
140
+ // ============================================================================
141
+
142
+ Scenario("Handle authorization pending state", ({ Given, When, Then, And }) => {
143
+ Given("a valid device code", () => {
144
+ mockFetch = createMockFetch({
145
+ tokenResponse: authorizationPendingResponse,
146
+ });
147
+ global.fetch = mockFetch;
148
+ });
149
+
150
+ And("the user has not yet authorized", () => {
151
+ // Mock is configured to return authorization_pending
152
+ });
153
+
154
+ When("I poll for the access token", async () => {
155
+ const response = await fetch("https://github.com/login/oauth/access_token", {
156
+ method: "POST",
157
+ headers: {
158
+ Accept: "application/json",
159
+ "Content-Type": "application/json",
160
+ },
161
+ body: JSON.stringify({
162
+ client_id: "test-client-id",
163
+ device_code: "test-device-code",
164
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
165
+ }),
166
+ });
167
+
168
+ tokenResponse = await response.json();
169
+ });
170
+
171
+ Then("I should receive authorization_pending error", () => {
172
+ expect(tokenResponse?.error).toBe("authorization_pending");
173
+ });
174
+
175
+ And("I should continue polling", () => {
176
+ // This is the expected behavior - we would continue polling in real code
177
+ expect(tokenResponse?.error_description).toContain("pending");
178
+ });
179
+ });
180
+
181
+ // ============================================================================
182
+ // Scenario: Store token in git credential helper
183
+ // ============================================================================
184
+
185
+ Scenario("Store token in git credential helper", ({ Given, When, Then, And }) => {
186
+ Given("a valid access token", () => {
187
+ storedToken = "gho_test_token_abc123";
188
+ });
189
+
190
+ When("I store the token", () => {
191
+ // Simulate storing in credential helper
192
+ credentialStore.set("github.com", storedToken!);
193
+ });
194
+
195
+ Then("the token should be stored successfully", () => {
196
+ expect(credentialStore.has("github.com")).toBe(true);
197
+ });
198
+
199
+ And("I should be able to retrieve the token", () => {
200
+ retrievedToken = credentialStore.get("github.com") || null;
201
+ expect(retrievedToken).toBe(storedToken);
202
+ });
203
+ });
204
+
205
+ // ============================================================================
206
+ // Scenario: Handle expired device code
207
+ // ============================================================================
208
+
209
+ Scenario("Handle expired device code", ({ Given, When, Then }) => {
210
+ Given("a device code that has expired", () => {
211
+ mockFetch = createMockFetch({
212
+ tokenResponse: expiredTokenResponse,
213
+ });
214
+ global.fetch = mockFetch;
215
+ });
216
+
217
+ When("I poll for the access token", async () => {
218
+ const response = await fetch("https://github.com/login/oauth/access_token", {
219
+ method: "POST",
220
+ headers: {
221
+ Accept: "application/json",
222
+ "Content-Type": "application/json",
223
+ },
224
+ body: JSON.stringify({
225
+ client_id: "test-client-id",
226
+ device_code: "expired-device-code",
227
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
228
+ }),
229
+ });
230
+
231
+ tokenResponse = await response.json();
232
+ });
233
+
234
+ Then("I should receive an expired_token error", () => {
235
+ expect(tokenResponse?.error).toBe("expired_token");
236
+ });
237
+ });
238
+
239
+ // ============================================================================
240
+ // Scenario: Validate token with GitHub API
241
+ // ============================================================================
242
+
243
+ Scenario("Validate token with GitHub API", ({ Given, When, Then, And }) => {
244
+ Given("a valid access token", () => {
245
+ mockFetch = createMockFetch({
246
+ userResponse: defaultUserResponse,
247
+ scopes: ["repo", "workflow"],
248
+ });
249
+ global.fetch = mockFetch;
250
+ });
251
+
252
+ When("I validate the token", async () => {
253
+ const response = await fetch("https://api.github.com/user", {
254
+ headers: {
255
+ Authorization: "Bearer gho_test_token_abc123",
256
+ Accept: "application/vnd.github.v3+json",
257
+ },
258
+ });
259
+
260
+ if (response.ok) {
261
+ const user = await response.json();
262
+ const scopeHeader = response.headers.get("X-OAuth-Scopes") || "";
263
+ const scopes = scopeHeader.split(",").map((s) => s.trim());
264
+
265
+ validationResult = {
266
+ valid: true,
267
+ user,
268
+ scopes,
269
+ };
270
+ } else {
271
+ validationResult = { valid: false };
272
+ }
273
+ });
274
+
275
+ Then("I should receive user information", () => {
276
+ expect(validationResult?.valid).toBe(true);
277
+ expect(validationResult?.user?.login).toBe("testuser");
278
+ });
279
+
280
+ And("the scopes should include repo and workflow", () => {
281
+ expect(validationResult?.scopes).toContain("repo");
282
+ expect(validationResult?.scopes).toContain("workflow");
283
+ });
284
+ });
285
+ });
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Step definitions for GitHub Deployer validation tests
3
+ *
4
+ * Uses @symbiosis-lab/moss-api/testing to mock Tauri IPC commands
5
+ *
6
+ * Updated for git-origin-based deploy target (no config.json, no validation.ts).
7
+ */
8
+
9
+ import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
10
+ import { expect, vi } from "vitest";
11
+ import {
12
+ setupMockTauri,
13
+ type MockTauriContext,
14
+ } from "@symbiosis-lab/moss-api/testing";
15
+ import type { DeployContext, HookResult } from "../../src/types";
16
+
17
+ // Load the feature file
18
+ const feature = await loadFeature("features/deploy/validation.feature");
19
+
20
+ // Mock the utils module
21
+ vi.mock("../../src/utils", () => ({
22
+ reportProgress: vi.fn().mockResolvedValue(undefined),
23
+ reportError: vi.fn().mockResolvedValue(undefined),
24
+ reportComplete: vi.fn().mockResolvedValue(undefined),
25
+ setCurrentHookName: vi.fn(),
26
+ showToast: vi.fn().mockResolvedValue(undefined),
27
+ dismissToast: vi.fn().mockResolvedValue(undefined),
28
+ closeBrowser: vi.fn().mockResolvedValue(undefined),
29
+ sleep: vi.fn().mockResolvedValue(undefined),
30
+ }));
31
+
32
+ // Mock the github-deploy module
33
+ vi.mock("../../src/github-deploy", () => ({
34
+ verifyRepoExists: vi.fn().mockResolvedValue(undefined),
35
+ getOriginOwnerRepo: vi.fn(),
36
+ deployViaGitPush: vi.fn(),
37
+ }));
38
+
39
+ // Mock the auth module
40
+ vi.mock("../../src/auth", () => ({
41
+ promptLogin: vi.fn(),
42
+ checkAuthentication: vi.fn(),
43
+ validateToken: vi.fn(),
44
+ hasRequiredScopes: vi.fn(),
45
+ }));
46
+
47
+ // Mock the token module
48
+ vi.mock("../../src/token", () => ({
49
+ getToken: vi.fn(),
50
+ getTokenFromGit: vi.fn(),
51
+ storeToken: vi.fn(),
52
+ clearToken: vi.fn(),
53
+ }));
54
+
55
+ // Mock the git module (only pure functions still imported by main.ts)
56
+ vi.mock("../../src/git", () => ({
57
+ buildPagesUrl: vi.fn().mockImplementation((owner: string, repo: string) => {
58
+ if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
59
+ return `https://${owner}.github.io`;
60
+ }
61
+ return `https://${owner}.github.io/${repo}`;
62
+ }),
63
+ parseGitHubUrl: vi.fn().mockImplementation((remoteUrl: string) => {
64
+ const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
65
+ if (m) return { owner: m[1], repo: m[2] };
66
+ return null;
67
+ }),
68
+ }));
69
+
70
+ // Mock the repo-setup module
71
+ vi.mock("../../src/repo-setup", () => ({
72
+ ensureGitHubRepo: vi.fn(),
73
+ }));
74
+
75
+ // Mock the github-api module
76
+ vi.mock("../../src/github-api", () => ({
77
+ checkPagesStatus: vi.fn().mockResolvedValue({ status: "built" }),
78
+ getAuthenticatedUser: vi.fn(),
79
+ checkRepoExists: vi.fn(),
80
+ createRepository: vi.fn(),
81
+ setCustomDomain: vi.fn(),
82
+ }));
83
+
84
+ // Import after mocking
85
+ const { on_deploy } = await import("../../src/main");
86
+ const { deployViaGitPush, getOriginOwnerRepo } = await import("../../src/github-deploy");
87
+ const { getToken, getTokenFromGit } = await import("../../src/token");
88
+ const { parseGitHubUrl, buildPagesUrl } = await import("../../src/git");
89
+ const { checkPagesStatus } = await import("../../src/github-api");
90
+ const { ensureGitHubRepo } = await import("../../src/repo-setup");
91
+
92
+ describeFeature(feature, ({ Scenario, BeforeEachScenario, AfterEachScenario }) => {
93
+ // Test state
94
+ let ctx: MockTauriContext;
95
+ let projectPath: string;
96
+ let deployResult: HookResult | null = null;
97
+ let scenarioSiteFiles: string[] = ["index.html"]; // Default site files
98
+
99
+ /**
100
+ * Create a mock DeployContext for testing
101
+ */
102
+ function createMockContext(): DeployContext {
103
+ return {
104
+ project_path: projectPath,
105
+ moss_dir: `${projectPath}/.moss`,
106
+ output_dir: `${projectPath}/.moss/build/site`,
107
+ site_files: scenarioSiteFiles,
108
+ project_info: {
109
+ project_type: "markdown",
110
+ content_folders: ["posts"],
111
+ total_files: 10,
112
+ homepage_file: "index.md",
113
+ },
114
+ config: {},
115
+ };
116
+ }
117
+
118
+ BeforeEachScenario(() => {
119
+ ctx = setupMockTauri();
120
+ projectPath = "/test/project";
121
+ deployResult = null;
122
+ scenarioSiteFiles = ["index.html"]; // Reset to default
123
+ vi.clearAllMocks();
124
+
125
+ // Restore default implementations after clearAllMocks
126
+ vi.mocked(parseGitHubUrl).mockImplementation((remoteUrl: string) => {
127
+ const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
128
+ if (m) return { owner: m[1], repo: m[2] };
129
+ return null;
130
+ });
131
+ vi.mocked(buildPagesUrl).mockImplementation((owner: string, repo: string) => {
132
+ if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
133
+ return `https://${owner}.github.io`;
134
+ }
135
+ return `https://${owner}.github.io/${repo}`;
136
+ });
137
+ vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built" });
138
+ // Default: no git origin (first-time user)
139
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
140
+ });
141
+
142
+ AfterEachScenario(() => {
143
+ ctx.cleanup();
144
+ });
145
+
146
+ // ============================================================================
147
+ // Scenario: Deploy from non-git directory shows repo setup UI
148
+ // No .git origin → ensureGitHubRepo → user cancels → "cancelled"
149
+ // ============================================================================
150
+
151
+ Scenario("Deploy from non-git directory", ({ Given, When, Then, And }) => {
152
+ Given("the directory is not a git repository", () => {
153
+ // No git origin (no .git directory)
154
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
155
+ // ensureGitHubRepo returns null (user cancelled)
156
+ vi.mocked(ensureGitHubRepo).mockResolvedValue(null);
157
+ });
158
+
159
+ When("I attempt to deploy", async () => {
160
+ deployResult = await on_deploy(createMockContext());
161
+ });
162
+
163
+ Then("the deployment should fail", () => {
164
+ expect(deployResult?.success).toBe(false);
165
+ });
166
+
167
+ And('the error should indicate setup was cancelled', () => {
168
+ expect(deployResult?.message).toContain("cancelled");
169
+ });
170
+ });
171
+
172
+ // ============================================================================
173
+ // Scenario: Deploy without any git remote
174
+ // Same as above: no origin → ensureGitHubRepo → user cancels
175
+ // ============================================================================
176
+
177
+ Scenario("Deploy without any git remote", ({ Given, When, Then, And }) => {
178
+ Given("the directory is a git repository", () => {
179
+ // Git repo exists but no GitHub origin
180
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
181
+ });
182
+
183
+ And("no git remote is configured", () => {
184
+ // ensureGitHubRepo returns null (user cancelled)
185
+ vi.mocked(ensureGitHubRepo).mockResolvedValue(null);
186
+ });
187
+
188
+ When("I attempt to deploy", async () => {
189
+ deployResult = await on_deploy(createMockContext());
190
+ });
191
+
192
+ Then("the deployment should fail", () => {
193
+ expect(deployResult?.success).toBe(false);
194
+ });
195
+
196
+ And('the error should mention "No git remote configured"', () => {
197
+ // In new flow: shows repo setup UI, returns cancelled when no interaction
198
+ expect(deployResult?.message).toContain("cancelled");
199
+ });
200
+
201
+ And("the error should include instructions to add a GitHub remote", () => {
202
+ expect(deployResult?.message).toContain("Repository setup cancelled");
203
+ });
204
+ });
205
+
206
+ // ============================================================================
207
+ // Scenario: Deploy with non-GitHub remote triggers setup
208
+ // getOriginOwnerRepo returns null for non-GitHub → ensureGitHubRepo → cancelled
209
+ // ============================================================================
210
+
211
+ Scenario("Deploy with non-GitHub remote triggers setup", ({ Given, When, Then, And }) => {
212
+ Given("the directory is a git repository", () => {
213
+ // Non-GitHub origin → getOriginOwnerRepo returns null
214
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
215
+ });
216
+
217
+ And("the git remote is not a GitHub URL", () => {
218
+ // ensureGitHubRepo returns null (user cancelled)
219
+ vi.mocked(ensureGitHubRepo).mockResolvedValue(null);
220
+ });
221
+
222
+ When("I attempt to deploy", async () => {
223
+ deployResult = await on_deploy(createMockContext());
224
+ });
225
+
226
+ Then("the deployment should fail", () => {
227
+ expect(deployResult?.success).toBe(false);
228
+ });
229
+
230
+ And('the error should indicate setup was cancelled', () => {
231
+ expect(deployResult?.message).toContain("cancelled");
232
+ });
233
+ });
234
+
235
+ // ============================================================================
236
+ // Scenario: Deploy with empty site directory
237
+ // ============================================================================
238
+
239
+ Scenario("Deploy with empty site directory", ({ Given, When, Then, And }) => {
240
+ Given("the directory is a git repository", () => {
241
+ // Git origin exists
242
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "user", repo: "repo" });
243
+ });
244
+
245
+ And("the site directory is empty", () => {
246
+ scenarioSiteFiles = [];
247
+ });
248
+
249
+ When("I attempt to deploy", async () => {
250
+ deployResult = await on_deploy(createMockContext());
251
+ });
252
+
253
+ Then("the deployment should fail", () => {
254
+ expect(deployResult?.success).toBe(false);
255
+ });
256
+
257
+ And("the error should mention that the site needs to be built", () => {
258
+ expect(deployResult?.message).toMatch(/site.*empty|build.*first|not found/i);
259
+ });
260
+ });
261
+
262
+ // ============================================================================
263
+ // Scenario: Successful deployment with SSH remote
264
+ // ============================================================================
265
+
266
+ Scenario("Successful deployment with SSH remote", ({ Given, When, Then, And }) => {
267
+ Given("the directory is a git repository", () => {
268
+ // Git origin returns testuser/testrepo
269
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "testuser", repo: "testrepo" });
270
+ });
271
+
272
+ And('the git remote is "git@github.com:testuser/testrepo.git"', () => {
273
+ // Already set via getOriginOwnerRepo in Given
274
+ });
275
+
276
+ And('the site is built with files in ".moss/build/site/"', () => {
277
+ ctx.filesystem.setFile(`${projectPath}/.moss/build/site/index.html`, "<html></html>");
278
+ });
279
+
280
+ And("the GitHub Actions workflow already exists", () => {
281
+ // Token available
282
+ vi.mocked(getToken).mockResolvedValue("test-token");
283
+ vi.mocked(getTokenFromGit).mockResolvedValue(null);
284
+
285
+ // deployViaGitPush returns DeployResult
286
+ vi.mocked(deployViaGitPush).mockResolvedValue({ commitSha: "new-commit-sha", orphanSha: "orphan-sha", treeChanged: true });
287
+
288
+ // Pages status check
289
+ vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built" });
290
+ });
291
+
292
+ When("I attempt to deploy", async () => {
293
+ deployResult = await on_deploy(createMockContext());
294
+ });
295
+
296
+ Then("the deployment should succeed", () => {
297
+ expect(deployResult?.success).toBe(true);
298
+ });
299
+
300
+ And('the deployment URL should be "https://testuser.github.io/testrepo"', () => {
301
+ expect(deployResult?.deployment?.url).toBe("https://testuser.github.io/testrepo");
302
+ });
303
+ });
304
+
305
+ // ============================================================================
306
+ // Scenario: First-time deployment creates workflow
307
+ // getOriginOwnerRepo returns null → ensureGitHubRepo → setup + deploy
308
+ // ============================================================================
309
+
310
+ Scenario("First-time deployment creates workflow", ({ Given, When, Then, And }) => {
311
+ Given("the directory is a git repository", () => {
312
+ // No git origin (first-time deploy)
313
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
314
+ });
315
+
316
+ And('the git remote is "git@github.com:user/repo.git"', () => {
317
+ // ensureGitHubRepo returns repo info (auto-created or via UI)
318
+ vi.mocked(ensureGitHubRepo).mockResolvedValue({
319
+ name: "repo",
320
+ sshUrl: "git@github.com:user/repo.git",
321
+ fullName: "user/repo",
322
+ });
323
+ });
324
+
325
+ And('the site is built with files in ".moss/build/site/"', () => {
326
+ ctx.filesystem.setFile(`${projectPath}/.moss/build/site/index.html`, "<html></html>");
327
+ ctx.filesystem.setFile(`${projectPath}/.gitignore`, "node_modules/");
328
+ });
329
+
330
+ And("the GitHub Actions workflow does not exist", () => {
331
+ // Token available
332
+ vi.mocked(getToken).mockResolvedValue("test-token");
333
+ vi.mocked(getTokenFromGit).mockResolvedValue(null);
334
+
335
+ // deployViaGitPush returns DeployResult
336
+ vi.mocked(deployViaGitPush).mockResolvedValue({ commitSha: "first-commit-sha", orphanSha: "orphan-sha", treeChanged: true });
337
+
338
+ // Pages status check
339
+ vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built" });
340
+ });
341
+
342
+ When("I attempt to deploy", async () => {
343
+ deployResult = await on_deploy(createMockContext());
344
+ });
345
+
346
+ Then("the deployment should succeed", () => {
347
+ expect(deployResult?.success).toBe(true);
348
+ });
349
+
350
+ And("the result should indicate first-time setup", () => {
351
+ expect(deployResult?.deployment?.metadata?.was_first_setup).toBe("true");
352
+ });
353
+ });
354
+ });
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@symbiosis-lab/moss-plugin-github",
3
+ "version": "1.5.1",
4
+ "description": "Deploy to GitHub Pages via GitHub Actions",
5
+ "main": "dist/main.js",
6
+ "keywords": [
7
+ "moss",
8
+ "plugin",
9
+ "github-pages",
10
+ "deployment",
11
+ "deployer"
12
+ ],
13
+ "author": "Symbiosis Lab",
14
+ "license": "MIT",
15
+ "publishConfig": {
16
+ "access": "public",
17
+ "registry": "https://registry.npmjs.org/",
18
+ "provenance": false
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/Symbiosis-Lab/moss-plugins.git",
23
+ "directory": "github"
24
+ },
25
+ "devDependencies": {
26
+ "@amiceli/vitest-cucumber": "^5.2.1",
27
+ "@types/node": "^20.0.0",
28
+ "@vitest/coverage-v8": "^3.2.4",
29
+ "chokidar-cli": "^3.0.0",
30
+ "concurrently": "^8.2.0",
31
+ "esbuild": "^0.27.0",
32
+ "happy-dom": "^20.0.11",
33
+ "typescript": "^5.3.0",
34
+ "vitest": "^3.2.4",
35
+ "@symbiosis-lab/moss-api": "0.10.0"
36
+ },
37
+ "scripts": {
38
+ "build": "npm run clean && npm run bundle && npm run copy-assets",
39
+ "dev": "npm run clean && npm run copy-assets && concurrently \"npm run bundle -- --watch\" \"npm run watch-assets\"",
40
+ "bundle": "esbuild src/main.ts --bundle --format=iife --global-name=GithubPlugin --outfile=dist/main.bundle.js",
41
+ "clean": "rm -rf dist && mkdir -p dist",
42
+ "copy-assets": "cp assets/* dist/",
43
+ "watch-assets": "chokidar 'assets/**/*' -c 'npm run copy-assets'",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "test:coverage": "vitest run --coverage",
47
+ "test:unit": "vitest run --project unit",
48
+ "test:integration": "vitest run --project integration",
49
+ "test:e2e": "vitest run --project e2e"
50
+ }
51
+ }