@dk/jolly 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/.mcp.json +7 -0
- package/.sisyphus/boulder.json +13 -0
- package/.sisyphus/notepads/saleor-agent-cli/decisions.md +11 -0
- package/.sisyphus/notepads/saleor-agent-cli/issues.md +6 -0
- package/.sisyphus/notepads/saleor-agent-cli/learnings.md +6 -0
- package/.sisyphus/plans/saleor-agent-cli.md +600 -0
- package/AGENTS.md +46 -0
- package/README.md +121 -0
- package/bun.lock +65 -0
- package/bunfig.toml +8 -0
- package/dist/agent.js +259 -0
- package/dist/bootstrap.js +492 -0
- package/dist/index.js +5798 -0
- package/package.json +29 -0
- package/src/agents/index.ts +1 -0
- package/src/agents/setup.ts +210 -0
- package/src/api/auth.ts +21 -0
- package/src/api/client.ts +78 -0
- package/src/api/endpoints.ts +8 -0
- package/src/api/index.ts +4 -0
- package/src/cli/agent.ts +26 -0
- package/src/cli/bootstrap.ts +24 -0
- package/src/cli/commands/agent.ts +40 -0
- package/src/cli/commands/app.ts +51 -0
- package/src/cli/commands/config.ts +38 -0
- package/src/cli/commands/store.ts +65 -0
- package/src/cli/index.ts +16 -0
- package/src/commands/app.ts +126 -0
- package/src/commands/index.ts +1 -0
- package/src/commands/store.ts +64 -0
- package/src/test/command-handlers.test.ts +227 -0
- package/src/test/e2e-flows.test.ts +212 -0
- package/src/test/entry-points.test.ts +123 -0
- package/src/test/error-handling.test.ts +137 -0
- package/src/test/helpers.ts +49 -0
- package/src/test/index.ts +1 -0
- package/src/test/mocks.ts +132 -0
- package/src/test/setup.ts +29 -0
- package/src/tui/components.ts +77 -0
- package/src/tui/index.ts +3 -0
- package/src/tui/renderer.ts +34 -0
- package/src/tui/theme.ts +38 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { SaleorCloudClient } from '../api/client.js';
|
|
2
|
+
import { requireToken } from '../api/auth.js';
|
|
3
|
+
import { success, error, info } from '../tui/components.js';
|
|
4
|
+
|
|
5
|
+
type AppType = 'dashboard-extension' | 'payment' | 'webhook';
|
|
6
|
+
type PaymentProvider = 'dummy' | 'stripe';
|
|
7
|
+
|
|
8
|
+
const APP_TEMPLATES = {
|
|
9
|
+
'dashboard-extension': {
|
|
10
|
+
repo: 'https://github.com/saleor/saleor-app-sdk',
|
|
11
|
+
description: 'Dashboard Extension App',
|
|
12
|
+
},
|
|
13
|
+
payment: {
|
|
14
|
+
repo: 'https://github.com/saleor/saleor-apps',
|
|
15
|
+
description: 'Payment App',
|
|
16
|
+
},
|
|
17
|
+
webhook: {
|
|
18
|
+
repo: 'https://github.com/saleor/saleor-webhook-template',
|
|
19
|
+
description: 'Webhook Handler',
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const PAYMENT_APP_URLS = {
|
|
24
|
+
dummy: 'https://dummy-payment.saleor.io',
|
|
25
|
+
stripe: 'https://stripe-payment.saleor.io',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function createApp(
|
|
29
|
+
name: string,
|
|
30
|
+
type: AppType,
|
|
31
|
+
environmentId?: string,
|
|
32
|
+
provider?: PaymentProvider
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
info(`Creating ${type} app: ${name}`);
|
|
35
|
+
|
|
36
|
+
if (type === 'payment') {
|
|
37
|
+
const paymentProvider = provider || 'dummy';
|
|
38
|
+
info(`Payment provider: ${paymentProvider}`);
|
|
39
|
+
info(`Using hosted payment app: ${PAYMENT_APP_URLS[paymentProvider]}`);
|
|
40
|
+
|
|
41
|
+
if (environmentId) {
|
|
42
|
+
await registerHostedApp(environmentId, name, paymentProvider);
|
|
43
|
+
} else {
|
|
44
|
+
success(`\nPayment app "${name}" configured to use ${PAYMENT_APP_URLS[paymentProvider]}`);
|
|
45
|
+
info(`\nTo complete setup:`);
|
|
46
|
+
info(`1. Go to your dashboard at https://cloud.saleor.io`);
|
|
47
|
+
info(`2. Navigate to Apps > Third party apps`);
|
|
48
|
+
info(`3. Add the ${paymentProvider} payment app from ${PAYMENT_APP_URLS[paymentProvider]}`);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const template = APP_TEMPLATES[type];
|
|
54
|
+
info(`Cloning template from: ${template.repo}`);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const { spawn } = await import('child_process');
|
|
58
|
+
const child = spawn('git', ['clone', template.repo, name], {
|
|
59
|
+
stdio: 'inherit',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
child.on('close', (code) => {
|
|
63
|
+
if (code === 0) {
|
|
64
|
+
success(`\n${type} app "${name}" created successfully!`);
|
|
65
|
+
info(`\nTo get started:`);
|
|
66
|
+
info(`cd ${name}`);
|
|
67
|
+
info(`npm install`);
|
|
68
|
+
info(`npm run dev`);
|
|
69
|
+
|
|
70
|
+
if (environmentId) {
|
|
71
|
+
info(`\nRegistering app with environment ${environmentId}...`);
|
|
72
|
+
registerLocalApp(environmentId, name, type);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
error(`Failed to clone template (exit code: ${code})`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
error(`Failed to create app: ${err}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function registerHostedApp(
|
|
86
|
+
environmentId: string,
|
|
87
|
+
name: string,
|
|
88
|
+
provider: PaymentProvider
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const token = requireToken();
|
|
91
|
+
const client = new SaleorCloudClient(token);
|
|
92
|
+
|
|
93
|
+
info(`Registering hosted ${provider} payment app with environment...`);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const result = await client.registerApp(environmentId, 'payment', name);
|
|
97
|
+
success(`Payment app registered successfully!`);
|
|
98
|
+
info(`App ID: ${result.app.id}`);
|
|
99
|
+
info(`Payment URL: ${PAYMENT_APP_URLS[provider]}`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
error(`Failed to register app: ${err}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function registerLocalApp(
|
|
107
|
+
environmentId: string,
|
|
108
|
+
name: string,
|
|
109
|
+
type: AppType
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const token = requireToken();
|
|
112
|
+
const client = new SaleorCloudClient(token);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = await client.registerApp(environmentId, type, name);
|
|
116
|
+
success(`App registered with environment!`);
|
|
117
|
+
info(`App ID: ${result.app.id}`);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
warning(`Could not register app automatically: ${err}`);
|
|
120
|
+
info(`You can register manually in the dashboard.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function warning(msg: string): void {
|
|
125
|
+
console.log(`\x1b[33m${msg}\x1b[0m`);
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { SaleorCloudClient } from '../api/client.js';
|
|
2
|
+
import { requireToken } from '../api/auth.js';
|
|
3
|
+
import { spinner, success, error, info } from '../tui/components.js';
|
|
4
|
+
|
|
5
|
+
export async function createStore(name: string, region: string): Promise<void> {
|
|
6
|
+
const token = requireToken();
|
|
7
|
+
const client = new SaleorCloudClient(token);
|
|
8
|
+
|
|
9
|
+
info(`Creating store: ${name} in ${region}...`);
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const result = await client.createStore(name, region);
|
|
13
|
+
success(`Store created successfully!`);
|
|
14
|
+
info(`Store ID: ${result.store.id}`);
|
|
15
|
+
info(`Dashboard: https://cloud.saleor.io/stores/${result.store.id}`);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
error(`Failed to create store: ${err}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listStores(): Promise<void> {
|
|
23
|
+
const token = requireToken();
|
|
24
|
+
const client = new SaleorCloudClient(token);
|
|
25
|
+
|
|
26
|
+
info('Fetching stores...');
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = await client.getStores();
|
|
30
|
+
|
|
31
|
+
if (result.stores.length === 0) {
|
|
32
|
+
info('No stores found. Create one with: jolly store create --name <name>');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
success(`Found ${result.stores.length} store(s):\n`);
|
|
37
|
+
for (const store of result.stores) {
|
|
38
|
+
console.log(` ${store.name} (${store.id})`);
|
|
39
|
+
console.log(` Region: ${store.region}`);
|
|
40
|
+
console.log(` Created: ${new Date(store.created_at).toLocaleDateString()}`);
|
|
41
|
+
console.log();
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
error(`Failed to list stores: ${err}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createEnvironment(storeId: string, name: string): Promise<void> {
|
|
50
|
+
const token = requireToken();
|
|
51
|
+
const client = new SaleorCloudClient(token);
|
|
52
|
+
|
|
53
|
+
info(`Creating environment: ${name} for store ${storeId}...`);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const result = await client.createEnvironment(storeId, name);
|
|
57
|
+
success(`Environment created successfully!`);
|
|
58
|
+
info(`Environment ID: ${result.environment.id}`);
|
|
59
|
+
info(`API URL will be available at: https://${result.environment.id}.saleor.cloud`);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
error(`Failed to create environment: ${err}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { Given, When, Then } from './helpers';
|
|
3
|
+
import {
|
|
4
|
+
fixtures, captureConsole, mockFetch, mockFetchError,
|
|
5
|
+
mockProcessExit, withToken, withoutToken,
|
|
6
|
+
} from './mocks';
|
|
7
|
+
|
|
8
|
+
const tuiCalls: { fn: string; msg: string }[] = [];
|
|
9
|
+
mock.module('../tui/components', () => ({
|
|
10
|
+
info: (msg: string) => { tuiCalls.push({ fn: 'info', msg }); return msg; },
|
|
11
|
+
success: (msg: string) => { tuiCalls.push({ fn: 'success', msg }); return msg; },
|
|
12
|
+
error: (msg: string) => { tuiCalls.push({ fn: 'error', msg }); return msg; },
|
|
13
|
+
warning: (msg: string) => { tuiCalls.push({ fn: 'warning', msg }); return msg; },
|
|
14
|
+
spinner: () => ({ stop: () => {} }),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe('Command Handlers', () => {
|
|
18
|
+
let console_: ReturnType<typeof captureConsole>;
|
|
19
|
+
let exitSpy: ReturnType<typeof mockProcessExit>;
|
|
20
|
+
const originalFetch = globalThis.fetch;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
console_ = captureConsole();
|
|
24
|
+
exitSpy = mockProcessExit();
|
|
25
|
+
tuiCalls.length = 0;
|
|
26
|
+
withToken();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
console_.restore();
|
|
31
|
+
exitSpy.mockRestore();
|
|
32
|
+
globalThis.fetch = originalFetch;
|
|
33
|
+
withoutToken();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('Store Handlers', () => {
|
|
37
|
+
Given('a valid token and successful API', () => {
|
|
38
|
+
When('calling createStore', () => {
|
|
39
|
+
Then('it should call POST /stores with name and region', async () => {
|
|
40
|
+
const fetchMock = mockFetch({
|
|
41
|
+
'/stores': { store: fixtures.store },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const { createStore } = await import('../commands/store');
|
|
45
|
+
await createStore('my-store', 'us-east-1');
|
|
46
|
+
|
|
47
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
48
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
49
|
+
expect(url).toContain('/stores');
|
|
50
|
+
expect(opts.method).toBe('POST');
|
|
51
|
+
expect(JSON.parse(opts.body)).toEqual({ name: 'my-store', region: 'us-east-1' });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
When('calling listStores with results', () => {
|
|
56
|
+
Then('it should display store names and IDs', async () => {
|
|
57
|
+
mockFetch({
|
|
58
|
+
'/stores': { stores: [fixtures.store, fixtures.store2] },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const { listStores } = await import('../commands/store');
|
|
62
|
+
await listStores();
|
|
63
|
+
|
|
64
|
+
const allOutput = console_.logs.join('\n');
|
|
65
|
+
expect(allOutput).toContain('my-store');
|
|
66
|
+
expect(allOutput).toContain('store-1');
|
|
67
|
+
expect(allOutput).toContain('other-store');
|
|
68
|
+
expect(allOutput).toContain('store-2');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
When('calling listStores with empty results', () => {
|
|
73
|
+
Then('it should display a helpful message', async () => {
|
|
74
|
+
mockFetch({
|
|
75
|
+
'/stores': { stores: [] },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const { listStores } = await import('../commands/store');
|
|
79
|
+
await listStores();
|
|
80
|
+
|
|
81
|
+
const infoMessages = tuiCalls.filter(c => c.fn === 'info').map(c => c.msg).join('\n');
|
|
82
|
+
expect(infoMessages).toContain('No stores found');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
When('calling createEnvironment', () => {
|
|
87
|
+
Then('it should call POST /stores/:id/environments', async () => {
|
|
88
|
+
const fetchMock = mockFetch({
|
|
89
|
+
'/stores/store-1/environments': { environment: fixtures.environment },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const { createEnvironment } = await import('../commands/store');
|
|
93
|
+
await createEnvironment('store-1', 'staging');
|
|
94
|
+
|
|
95
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
96
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
97
|
+
expect(url).toContain('/stores/store-1/environments');
|
|
98
|
+
expect(opts.method).toBe('POST');
|
|
99
|
+
expect(JSON.parse(opts.body)).toEqual({ name: 'staging' });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
Given('a valid token but failing API', () => {
|
|
105
|
+
When('calling createStore and API returns 500', () => {
|
|
106
|
+
Then('it should print error and exit 1', async () => {
|
|
107
|
+
mockFetchError(500, 'Internal Server Error');
|
|
108
|
+
|
|
109
|
+
const { createStore } = await import('../commands/store');
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await createStore('fail-store', 'us-east-1');
|
|
113
|
+
} catch {}
|
|
114
|
+
|
|
115
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
When('calling listStores and API returns 401', () => {
|
|
120
|
+
Then('it should print error and exit 1', async () => {
|
|
121
|
+
mockFetchError(401, 'Unauthorized');
|
|
122
|
+
|
|
123
|
+
const { listStores } = await import('../commands/store');
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await listStores();
|
|
127
|
+
} catch {}
|
|
128
|
+
|
|
129
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
When('calling createEnvironment and API returns 404', () => {
|
|
134
|
+
Then('it should print error and exit 1', async () => {
|
|
135
|
+
mockFetchError(404, 'Not Found');
|
|
136
|
+
|
|
137
|
+
const { createEnvironment } = await import('../commands/store');
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await createEnvironment('bad-store', 'staging');
|
|
141
|
+
} catch {}
|
|
142
|
+
|
|
143
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
Given('no token is set', () => {
|
|
149
|
+
When('calling createStore', () => {
|
|
150
|
+
Then('it should exit with code 1 from requireToken', async () => {
|
|
151
|
+
withoutToken();
|
|
152
|
+
|
|
153
|
+
const { createStore } = await import('../commands/store');
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await createStore('no-token-store', 'us-east-1');
|
|
157
|
+
} catch {}
|
|
158
|
+
|
|
159
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
When('calling listStores', () => {
|
|
164
|
+
Then('it should exit with code 1 from requireToken', async () => {
|
|
165
|
+
withoutToken();
|
|
166
|
+
|
|
167
|
+
const { listStores } = await import('../commands/store');
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await listStores();
|
|
171
|
+
} catch {}
|
|
172
|
+
|
|
173
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('App Handlers', () => {
|
|
180
|
+
Given('a payment app with no environment', () => {
|
|
181
|
+
When('calling createApp', () => {
|
|
182
|
+
Then('it should print hosted payment app instructions', async () => {
|
|
183
|
+
const { createApp } = await import('../commands/app');
|
|
184
|
+
await createApp('pay-app', 'payment', undefined, 'stripe');
|
|
185
|
+
|
|
186
|
+
const allTuiOutput = tuiCalls.map(c => c.msg).join('\n');
|
|
187
|
+
expect(allTuiOutput).toContain('payment');
|
|
188
|
+
expect(allTuiOutput).toContain('stripe');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
Given('a payment app with environment and valid token', () => {
|
|
194
|
+
When('calling createApp', () => {
|
|
195
|
+
Then('it should register the hosted app via API', async () => {
|
|
196
|
+
const fetchMock = mockFetch({
|
|
197
|
+
'/environments/env-1/apps': { app: fixtures.app },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const { createApp } = await import('../commands/app');
|
|
201
|
+
await createApp('pay-app', 'payment', 'env-1', 'dummy');
|
|
202
|
+
|
|
203
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
204
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
205
|
+
expect(url).toContain('/environments/env-1/apps');
|
|
206
|
+
expect(opts.method).toBe('POST');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
Given('a payment app with environment but failing API', () => {
|
|
212
|
+
When('calling createApp', () => {
|
|
213
|
+
Then('it should print error and exit 1', async () => {
|
|
214
|
+
mockFetchError(500, 'Internal Server Error');
|
|
215
|
+
|
|
216
|
+
const { createApp } = await import('../commands/app');
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await createApp('pay-app', 'payment', 'env-1', 'dummy');
|
|
220
|
+
} catch {}
|
|
221
|
+
|
|
222
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { Given, When, Then, And } from './helpers';
|
|
3
|
+
import {
|
|
4
|
+
fixtures, captureConsole, mockFetch, mockFetchError,
|
|
5
|
+
mockProcessExit, withToken, withoutToken,
|
|
6
|
+
} from './mocks';
|
|
7
|
+
|
|
8
|
+
const tuiCalls: { fn: string; msg: string }[] = [];
|
|
9
|
+
mock.module('../tui/components', () => ({
|
|
10
|
+
info: (msg: string) => { tuiCalls.push({ fn: 'info', msg }); return msg; },
|
|
11
|
+
success: (msg: string) => { tuiCalls.push({ fn: 'success', msg }); return msg; },
|
|
12
|
+
error: (msg: string) => { tuiCalls.push({ fn: 'error', msg }); return msg; },
|
|
13
|
+
warning: (msg: string) => { tuiCalls.push({ fn: 'warning', msg }); return msg; },
|
|
14
|
+
spinner: () => ({ stop: () => {} }),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe('End-to-End Flows', () => {
|
|
18
|
+
let console_: ReturnType<typeof captureConsole>;
|
|
19
|
+
let exitSpy: ReturnType<typeof mockProcessExit>;
|
|
20
|
+
const originalFetch = globalThis.fetch;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
console_ = captureConsole();
|
|
24
|
+
exitSpy = mockProcessExit();
|
|
25
|
+
tuiCalls.length = 0;
|
|
26
|
+
withToken();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
console_.restore();
|
|
31
|
+
exitSpy.mockRestore();
|
|
32
|
+
globalThis.fetch = originalFetch;
|
|
33
|
+
withoutToken();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('Store Creation Flow', () => {
|
|
37
|
+
Given('a valid token and working API', () => {
|
|
38
|
+
When('creating a store end-to-end', () => {
|
|
39
|
+
Then('it should call API and display store ID and dashboard URL', async () => {
|
|
40
|
+
const fetchMock = mockFetch({
|
|
41
|
+
'/stores': { store: fixtures.store },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const { createStore } = await import('../commands/store');
|
|
45
|
+
await createStore('my-store', 'us-east-1');
|
|
46
|
+
|
|
47
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
48
|
+
|
|
49
|
+
const allTui = tuiCalls.map(c => c.msg).join('\n');
|
|
50
|
+
expect(allTui).toContain('store-1');
|
|
51
|
+
expect(allTui).toContain('cloud.saleor.io/stores/store-1');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('Store List Flow', () => {
|
|
58
|
+
Given('a valid token and multiple stores', () => {
|
|
59
|
+
When('listing stores end-to-end', () => {
|
|
60
|
+
Then('it should display all stores with regions and dates', async () => {
|
|
61
|
+
mockFetch({
|
|
62
|
+
'/stores': { stores: [fixtures.store, fixtures.store2] },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const { listStores } = await import('../commands/store');
|
|
66
|
+
await listStores();
|
|
67
|
+
|
|
68
|
+
const allTui = tuiCalls.map(c => c.msg).join('\n');
|
|
69
|
+
const allConsole = console_.logs.join('\n');
|
|
70
|
+
expect(allTui).toContain('2 store(s)');
|
|
71
|
+
expect(allConsole).toContain('my-store');
|
|
72
|
+
expect(allConsole).toContain('us-east-1');
|
|
73
|
+
expect(allConsole).toContain('other-store');
|
|
74
|
+
expect(allConsole).toContain('eu-west-1');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('Environment Creation Flow', () => {
|
|
81
|
+
Given('a valid token and existing store', () => {
|
|
82
|
+
When('creating an environment end-to-end', () => {
|
|
83
|
+
Then('it should call API and display environment ID and URL', async () => {
|
|
84
|
+
const fetchMock = mockFetch({
|
|
85
|
+
'/stores/store-1/environments': { environment: fixtures.environment },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const { createEnvironment } = await import('../commands/store');
|
|
89
|
+
await createEnvironment('store-1', 'staging');
|
|
90
|
+
|
|
91
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
92
|
+
|
|
93
|
+
const allTui = tuiCalls.map(c => c.msg).join('\n');
|
|
94
|
+
expect(allTui).toContain('env-1');
|
|
95
|
+
expect(allTui).toContain('saleor.cloud');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('Payment App Registration Flow', () => {
|
|
102
|
+
Given('a valid token and environment', () => {
|
|
103
|
+
When('creating a payment app with environment', () => {
|
|
104
|
+
Then('it should register via API and display app ID', async () => {
|
|
105
|
+
const fetchMock = mockFetch({
|
|
106
|
+
'/environments/env-1/apps': { app: fixtures.app },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const { createApp } = await import('../commands/app');
|
|
110
|
+
await createApp('my-payment', 'payment', 'env-1', 'stripe');
|
|
111
|
+
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
113
|
+
|
|
114
|
+
const allTui = tuiCalls.map(c => c.msg).join('\n');
|
|
115
|
+
expect(allTui).toContain('app-1');
|
|
116
|
+
expect(allTui).toContain('stripe');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
Given('a valid token but no environment', () => {
|
|
122
|
+
When('creating a payment app without environment', () => {
|
|
123
|
+
Then('it should display manual setup instructions', async () => {
|
|
124
|
+
const { createApp } = await import('../commands/app');
|
|
125
|
+
await createApp('my-payment', 'payment', undefined, 'stripe');
|
|
126
|
+
|
|
127
|
+
const allTui = tuiCalls.map(c => c.msg).join('\n');
|
|
128
|
+
expect(allTui).toContain('stripe');
|
|
129
|
+
expect(allTui).toContain('cloud.saleor.io');
|
|
130
|
+
expect(allTui).toContain('Third party apps');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('Full Error Recovery Flow', () => {
|
|
137
|
+
Given('no token set', () => {
|
|
138
|
+
When('attempting any store operation', () => {
|
|
139
|
+
Then('it should fail fast with auth error before API call', async () => {
|
|
140
|
+
withoutToken();
|
|
141
|
+
const fetchMock = mockFetch({ '/stores': { stores: [] } });
|
|
142
|
+
|
|
143
|
+
const { listStores } = await import('../commands/store');
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await listStores();
|
|
147
|
+
} catch {}
|
|
148
|
+
|
|
149
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
150
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
Given('a valid token but network failure', () => {
|
|
156
|
+
And('the API returns 500', () => {
|
|
157
|
+
When('creating a store', () => {
|
|
158
|
+
Then('it should display the error and exit 1', async () => {
|
|
159
|
+
mockFetchError(500, 'Internal Server Error');
|
|
160
|
+
|
|
161
|
+
const { createStore } = await import('../commands/store');
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await createStore('fail-store', 'us-east-1');
|
|
165
|
+
} catch {}
|
|
166
|
+
|
|
167
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('API Client Request Construction', () => {
|
|
175
|
+
Given('a SaleorCloudClient', () => {
|
|
176
|
+
When('creating a store', () => {
|
|
177
|
+
Then('it should send correct URL, method, headers, and body', async () => {
|
|
178
|
+
const fetchMock = mockFetch({
|
|
179
|
+
'/stores': { store: fixtures.store },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const { SaleorCloudClient } = await import('../api/client');
|
|
183
|
+
const client = new SaleorCloudClient(fixtures.token);
|
|
184
|
+
await client.createStore('test-store', 'eu-west-1');
|
|
185
|
+
|
|
186
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
187
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
188
|
+
expect(url).toBe('https://cloud.saleor.io/api/stores');
|
|
189
|
+
expect(opts.method).toBe('POST');
|
|
190
|
+
expect(opts.headers.Authorization).toBe(`Bearer ${fixtures.token}`);
|
|
191
|
+
expect(JSON.parse(opts.body)).toEqual({ name: 'test-store', region: 'eu-west-1' });
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
When('registering an app', () => {
|
|
196
|
+
Then('it should send correct URL with environment ID', async () => {
|
|
197
|
+
const fetchMock = mockFetch({
|
|
198
|
+
'/environments/env-1/apps': { app: fixtures.app },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const { SaleorCloudClient } = await import('../api/client');
|
|
202
|
+
const client = new SaleorCloudClient(fixtures.token);
|
|
203
|
+
await client.registerApp('env-1', 'payment', 'my-app');
|
|
204
|
+
|
|
205
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
206
|
+
expect(url).toBe('https://cloud.saleor.io/api/environments/env-1/apps');
|
|
207
|
+
expect(JSON.parse(opts.body)).toEqual({ type: 'payment', name: 'my-app' });
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|