-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdeploy.spec.ts
More file actions
221 lines (204 loc) · 8.06 KB
/
Copy pathdeploy.spec.ts
File metadata and controls
221 lines (204 loc) · 8.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { BuilderContext, BuilderOutput, Target } from '@angular-devkit/architect';
import type { DeployOptions } from './types.js';
import { runDeploy, type Deps } from './deploy.js';
function fakeContext(extra: Partial<BuilderContext> = {}): BuilderContext {
const messages: string[] = [];
const logger = {
debug: (m: string) => messages.push(`debug: ${m}`),
info: (m: string) => messages.push(`info: ${m}`),
warn: (m: string) => messages.push(`warn: ${m}`),
error: (m: string) => messages.push(`error: ${m}`),
fatal: (m: string) => messages.push(`fatal: ${m}`),
};
return {
workspaceRoot: process.cwd(),
logger,
target: { project: 'my-app', target: 'deploy' } as Target,
scheduleTarget: vi.fn(),
getTargetOptions: vi.fn(),
validateOptions: vi.fn(),
reportStatus: vi.fn(),
reportProgress: vi.fn(),
reportRunning: vi.fn(),
addTeardown: vi.fn(),
...extra,
} as unknown as BuilderContext;
}
function baseOptions(overrides: Partial<DeployOptions> = {}): DeployOptions {
return {
buildTarget: null,
outputPath: null,
storageZoneName: 'my-zone',
storageRegion: 'Falkenstein',
targetFolder: '/',
pullZoneId: 12345,
purgeAfterUpload: true,
concurrency: 4,
retries: 3,
ignore: [],
dryRun: false,
...overrides,
};
}
describe('runDeploy', () => {
let outputPath: string;
let deps: Deps;
let client: {
listAll: ReturnType<typeof vi.fn>;
upload: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
purgePullZone: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
outputPath = mkdtempSync(join(tmpdir(), 'bunny-deploy-'));
writeFileSync(join(outputPath, 'index.html'), '<!doctype html>');
mkdirSync(join(outputPath, 'assets'));
writeFileSync(join(outputPath, 'assets', 'app.js'), 'console.log(1)');
client = {
listAll: vi.fn(async () => []),
upload: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
purgePullZone: vi.fn(async () => undefined),
};
deps = {
loadSecrets: () => ({ storagePassword: 'sp', accountApiKey: 'ak' }),
makeClient: () => client as never,
};
});
afterEach(() => {
rmSync(outputPath, { recursive: true, force: true });
});
it('uploads every local file when the remote is empty', async () => {
const ctx = fakeContext();
const out = await runDeploy(baseOptions({ outputPath }), ctx, deps);
expect(out.success).toBe(true);
expect(client.upload).toHaveBeenCalledTimes(2);
expect(client.remove).not.toHaveBeenCalled();
expect(client.purgePullZone).toHaveBeenCalledWith(12345);
});
it('does nothing in dry-run mode', async () => {
const ctx = fakeContext();
const out = await runDeploy(baseOptions({ outputPath, dryRun: true }), ctx, deps);
expect(out.success).toBe(true);
expect(client.upload).not.toHaveBeenCalled();
expect(client.remove).not.toHaveBeenCalled();
expect(client.purgePullZone).not.toHaveBeenCalled();
});
it('skips unchanged files and deletes orphans after uploads', async () => {
// sha256 of "<!doctype html>" — verify with: node -e 'const c=require("node:crypto"); console.log(c.createHash("sha256").update("<!doctype html>").digest("hex"))'
const indexSha = 'fe26c59e91ac8de694b2531dc3bdc1b7faf471d3d7e4e00870af60f5f22897cb';
client.listAll.mockResolvedValue([
{ relPath: 'index.html', size: 15, sha256: indexSha },
{ relPath: 'old.js', size: 1, sha256: 'whatever' },
]);
const callOrder: string[] = [];
client.upload.mockImplementation(async () => {
callOrder.push('upload');
});
client.remove.mockImplementation(async () => {
callOrder.push('remove');
});
const out = await runDeploy(baseOptions({ outputPath }), fakeContext(), deps);
expect(out.success).toBe(true);
expect(client.upload).toHaveBeenCalledTimes(1);
expect(client.remove).toHaveBeenCalledTimes(1);
// Uploads run in a separate runWithConcurrency call before deletes, so
// all 'upload' entries must precede any 'remove' entry.
expect(callOrder).toEqual(['upload', 'remove']);
});
it('skips purge when purgeAfterUpload is false', async () => {
await runDeploy(
baseOptions({ outputPath, purgeAfterUpload: false, pullZoneId: null }),
fakeContext(),
deps,
);
expect(client.purgePullZone).not.toHaveBeenCalled();
});
it('aborts before touching the network when purge is on but pullZoneId is null', async () => {
const out = await runDeploy(
baseOptions({ outputPath, purgeAfterUpload: true, pullZoneId: null }),
fakeContext(),
deps,
);
expect(out.success).toBe(false);
expect(out.error).toMatch(/pullZoneId/);
expect(client.listAll).not.toHaveBeenCalled();
});
it('returns failure and skips purge when an upload fails', async () => {
client.upload.mockRejectedValueOnce(new Error('5xx'));
const out = await runDeploy(baseOptions({ outputPath }), fakeContext(), deps);
expect(out.success).toBe(false);
expect(client.purgePullZone).not.toHaveBeenCalled();
expect(client.remove).not.toHaveBeenCalled();
});
it('treats a purge failure as success-with-warning', async () => {
client.purgePullZone.mockRejectedValue(new Error('503'));
const out = await runDeploy(baseOptions({ outputPath }), fakeContext(), deps);
expect(out.success).toBe(true);
});
it('runs the build target and resolves outputPath via getTargetOptions', async () => {
const { dirname, basename } = await import('node:path');
const parentDir = dirname(outputPath);
const browserName = basename(outputPath);
const scheduleResult: BuilderOutput = { success: true };
const scheduleTarget = vi.fn(async () => ({
result: Promise.resolve(scheduleResult),
stop: async () => {},
}));
const getTargetOptions = vi.fn(async () => ({
outputPath: { base: '.', browser: browserName },
}));
const ctx = fakeContext({
workspaceRoot: parentDir,
scheduleTarget: scheduleTarget as never,
getTargetOptions: getTargetOptions as never,
});
const out = await runDeploy(
baseOptions({ outputPath: null, buildTarget: 'my-app:build:production' }),
ctx,
deps,
);
expect(out.success).toBe(true);
expect(scheduleTarget).toHaveBeenCalledWith(
{ project: 'my-app', target: 'build', configuration: 'production' },
undefined,
);
expect(getTargetOptions).toHaveBeenCalled();
expect(client.upload).toHaveBeenCalledTimes(2);
});
it('refuses to delete the whole remote when the local folder is empty', async () => {
const emptyDir = mkdtempSync(join(tmpdir(), 'bunny-empty-'));
try {
client.listAll.mockResolvedValue([
{ relPath: 'index.html', size: 15, sha256: 'abc' },
{ relPath: 'app.js', size: 3, sha256: 'def' },
]);
const out = await runDeploy(baseOptions({ outputPath: emptyDir }), fakeContext(), deps);
expect(out.success).toBe(false);
expect(out.error).toMatch(/empty/i);
expect(client.remove).not.toHaveBeenCalled();
expect(client.upload).not.toHaveBeenCalled();
expect(client.purgePullZone).not.toHaveBeenCalled();
} finally {
rmSync(emptyDir, { recursive: true, force: true });
}
});
it('resolves a relative outputPath against the workspace root, not cwd', async () => {
const wsRoot = mkdtempSync(join(tmpdir(), 'bunny-ws-'));
const browserDirName = 'relative-out';
mkdirSync(join(wsRoot, browserDirName));
writeFileSync(join(wsRoot, browserDirName, 'index.html'), '<!doctype html>');
try {
const ctx = fakeContext({ workspaceRoot: wsRoot });
const out = await runDeploy(baseOptions({ outputPath: browserDirName }), ctx, deps);
expect(out.success).toBe(true);
expect(client.upload).toHaveBeenCalledTimes(1);
} finally {
rmSync(wsRoot, { recursive: true, force: true });
}
});
});