@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,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
|
+
}
|