puter-cli 1.8.5 → 2.0.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/.github/workflows/npm-build.yml +4 -3
- package/CHANGELOG.md +38 -0
- package/README.md +18 -4
- package/bin/index.js +184 -31
- package/package.json +12 -12
- package/src/commands/apps.js +53 -139
- package/src/commands/auth.js +113 -115
- package/src/commands/deploy.js +29 -27
- package/src/commands/files.js +151 -512
- package/src/commands/shell.js +13 -25
- package/src/commands/sites.js +25 -83
- package/src/commands/subdomains.js +46 -55
- package/src/commons.js +2 -2
- package/src/executor.js +27 -28
- package/src/modules/ErrorModule.js +18 -31
- package/src/modules/ProfileModule.js +183 -123
- package/src/modules/PuterModule.js +30 -0
- package/tests/ErrorModule.test.js +42 -0
- package/tests/ProfileModule.test.js +274 -0
- package/tests/PuterModule.test.js +56 -0
- package/tests/apps.test.js +194 -0
- package/tests/commons.test.js +380 -0
- package/tests/deploy.test.js +84 -0
- package/tests/executor.test.js +52 -0
- package/tests/files.test.js +640 -0
- package/tests/login.test.js +69 -51
- package/tests/shell.test.js +184 -0
- package/tests/sites.test.js +67 -0
- package/tests/subdomains.test.js +90 -0
- package/src/modules/SetContextModule.js +0 -5
- package/src/temporary/context_helpers.js +0 -17
|
@@ -3,57 +3,54 @@ import inquirer from 'inquirer';
|
|
|
3
3
|
import Conf from 'conf';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import ora from 'ora';
|
|
6
|
+
import {getAuthToken} from "@heyputer/puter.js/src/init.cjs";
|
|
7
|
+
import { puter } from "@heyputer/puter.js";
|
|
6
8
|
|
|
7
9
|
// project
|
|
8
|
-
import {
|
|
9
|
-
import { getAuthToken, login } from '../commands/auth.js';
|
|
10
|
+
import { BASE_URL, NULL_UUID, PROJECT_NAME, getHeaders, reconfigureURLs } from '../commons.js'
|
|
10
11
|
|
|
11
12
|
// builtin
|
|
12
13
|
import fs from 'node:fs';
|
|
13
14
|
import crypto from 'node:crypto';
|
|
15
|
+
import { initPuterModule } from './PuterModule.js';
|
|
14
16
|
|
|
15
17
|
// initializations
|
|
16
18
|
const config = new Conf({ projectName: PROJECT_NAME });
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
let profileModule;
|
|
19
21
|
|
|
20
22
|
function toApiSubdomain(inputUrl) {
|
|
21
23
|
const url = new URL(inputUrl);
|
|
22
24
|
const hostParts = url.hostname.split('.');
|
|
23
|
-
|
|
25
|
+
|
|
24
26
|
// Insert 'api' before the domain
|
|
25
27
|
hostParts.splice(-2, 0, 'api');
|
|
26
28
|
url.hostname = hostParts.join('.');
|
|
27
29
|
|
|
28
30
|
let output = url.toString();
|
|
29
|
-
if (
|
|
31
|
+
if (output.endsWith('/')) {
|
|
30
32
|
output = output.slice(0, -1);
|
|
31
33
|
}
|
|
32
34
|
return output;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
class ProfileModule {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
process.exit(0);
|
|
48
|
-
}
|
|
49
|
-
this.applyProfileToGlobals();
|
|
50
|
-
});
|
|
51
|
-
|
|
38
|
+
async checkLogin() {
|
|
39
|
+
if (config.get('auth_token')) {
|
|
40
|
+
this.migrateLegacyConfig();
|
|
41
|
+
}
|
|
42
|
+
if (!config.get('selected_profile')) {
|
|
43
|
+
console.log(chalk.cyan('Please login first (or use CTRL+C to exit):'));
|
|
44
|
+
await this.switchProfileWizard();
|
|
45
|
+
// re init with new authToken
|
|
46
|
+
initPuterModule();
|
|
47
|
+
}
|
|
48
|
+
this.applyProfileToGlobals();
|
|
52
49
|
}
|
|
53
|
-
migrateLegacyConfig
|
|
50
|
+
migrateLegacyConfig() {
|
|
54
51
|
const auth_token = config.get('auth_token');
|
|
55
52
|
const username = config.get('username');
|
|
56
|
-
|
|
53
|
+
|
|
57
54
|
this.addProfile({
|
|
58
55
|
host: BASE_URL,
|
|
59
56
|
username,
|
|
@@ -61,13 +58,13 @@ class ProfileModule {
|
|
|
61
58
|
token: auth_token,
|
|
62
59
|
uuid: NULL_UUID,
|
|
63
60
|
});
|
|
64
|
-
|
|
61
|
+
|
|
65
62
|
config.delete('auth_token');
|
|
66
63
|
config.delete('username');
|
|
67
64
|
}
|
|
68
65
|
getDefaultProfile() {
|
|
69
66
|
const auth_token = config.get('auth_token');
|
|
70
|
-
if (
|
|
67
|
+
if (!auth_token) return;
|
|
71
68
|
return {
|
|
72
69
|
host: 'puter.com',
|
|
73
70
|
username: config.get('username'),
|
|
@@ -80,7 +77,7 @@ class ProfileModule {
|
|
|
80
77
|
}
|
|
81
78
|
addProfile(newProfile) {
|
|
82
79
|
const profiles = [
|
|
83
|
-
...this.getProfiles().filter(p => !
|
|
80
|
+
...this.getProfiles().filter(p => !p.transient),
|
|
84
81
|
newProfile,
|
|
85
82
|
];
|
|
86
83
|
config.set('profiles', profiles);
|
|
@@ -97,26 +94,25 @@ class ProfileModule {
|
|
|
97
94
|
return profiles.find(p => p.uuid === uuid);
|
|
98
95
|
}
|
|
99
96
|
applyProfileToGlobals(profile) {
|
|
100
|
-
if (
|
|
97
|
+
if (!profile) profile = this.getCurrentProfile();
|
|
101
98
|
reconfigureURLs({
|
|
102
99
|
base: profile.host,
|
|
103
100
|
api: toApiSubdomain(profile.host),
|
|
104
101
|
});
|
|
105
102
|
}
|
|
106
|
-
getAuthToken
|
|
103
|
+
getAuthToken() {
|
|
107
104
|
const uuid = config.get('selected_profile');
|
|
108
105
|
const profiles = this.getProfiles();
|
|
109
106
|
const profile = profiles.find(v => v.uuid === uuid);
|
|
110
107
|
return profile?.token;
|
|
111
108
|
}
|
|
112
|
-
|
|
113
|
-
async switchProfileWizard
|
|
109
|
+
|
|
110
|
+
async switchProfileWizard(args = {}) {
|
|
114
111
|
const profiles = this.getProfiles();
|
|
115
|
-
if (
|
|
116
|
-
return this.addProfileWizard();
|
|
112
|
+
if (profiles.length < 1) {
|
|
113
|
+
return this.addProfileWizard(args);
|
|
117
114
|
}
|
|
118
|
-
|
|
119
|
-
// console.log('doing this branch');
|
|
115
|
+
|
|
120
116
|
const answer = await inquirer.prompt([
|
|
121
117
|
{
|
|
122
118
|
name: 'profile',
|
|
@@ -136,23 +132,81 @@ class ProfileModule {
|
|
|
136
132
|
]
|
|
137
133
|
}
|
|
138
134
|
]);
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
return await this.addProfileWizard();
|
|
135
|
+
|
|
136
|
+
if (answer.profile === 'new') {
|
|
137
|
+
return await this.addProfileWizard(args);
|
|
142
138
|
}
|
|
143
|
-
|
|
139
|
+
|
|
144
140
|
this.selectProfile(answer.profile);
|
|
145
141
|
}
|
|
146
142
|
|
|
147
|
-
async addProfileWizard
|
|
143
|
+
async addProfileWizard(args = {}) {
|
|
144
|
+
const host = args.host || 'https://puter.com';
|
|
145
|
+
|
|
146
|
+
if (args.withCredentials) {
|
|
147
|
+
return await this.credentialLogin({ ...args, host });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Browser-based login (default)
|
|
151
|
+
return await this.browserLogin({ ...args, host });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async browserLogin(args) {
|
|
155
|
+
const { host, save } = args;
|
|
156
|
+
const TIMEOUT_MS = 60000; // 1 minute timeout
|
|
157
|
+
let spinner;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
spinner = ora('Opening browser for login...').start();
|
|
161
|
+
|
|
162
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
163
|
+
setTimeout(() => reject(new Error('Login timed out after 60 seconds')), TIMEOUT_MS);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const authToken = await Promise.race([
|
|
167
|
+
getAuthToken(),
|
|
168
|
+
timeoutPromise
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
if (!authToken) {
|
|
172
|
+
spinner.fail(chalk.red('Login failed or was cancelled.'));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
spinner.text = 'Fetching user info...';
|
|
177
|
+
|
|
178
|
+
// Set token and fetch user info
|
|
179
|
+
puter.setAuthToken(authToken);
|
|
180
|
+
const userInfo = await puter.auth.getUser();
|
|
181
|
+
|
|
182
|
+
const profileUUID = crypto.randomUUID();
|
|
183
|
+
const profile = {
|
|
184
|
+
host,
|
|
185
|
+
username: userInfo.username,
|
|
186
|
+
cwd: `/${userInfo.username}`,
|
|
187
|
+
token: authToken,
|
|
188
|
+
uuid: profileUUID,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
this.addProfile(profile);
|
|
192
|
+
this.selectProfile(profile);
|
|
193
|
+
spinner.succeed(chalk.green(`Successfully logged in as ${userInfo.username}!`));
|
|
194
|
+
|
|
195
|
+
// Handle --save option
|
|
196
|
+
this.saveTokenToEnv(authToken, save);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (spinner) {
|
|
199
|
+
spinner.fail(chalk.red(`Failed to login: ${error.message}`));
|
|
200
|
+
} else {
|
|
201
|
+
console.error(chalk.red(`Failed to login: ${error.message}`));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async credentialLogin(args) {
|
|
207
|
+
const { host, save } = args;
|
|
208
|
+
|
|
148
209
|
const answers = await inquirer.prompt([
|
|
149
|
-
{
|
|
150
|
-
type: 'input',
|
|
151
|
-
name: 'host',
|
|
152
|
-
message: 'Host (leave blank for puter.com):',
|
|
153
|
-
default: 'https://puter.com',
|
|
154
|
-
validate: input => input.length >= 1 || 'Host is required'
|
|
155
|
-
},
|
|
156
210
|
{
|
|
157
211
|
type: 'input',
|
|
158
212
|
name: 'username',
|
|
@@ -172,103 +226,109 @@ class ProfileModule {
|
|
|
172
226
|
try {
|
|
173
227
|
spinner = ora('Logging in to Puter...').start();
|
|
174
228
|
|
|
175
|
-
const
|
|
229
|
+
const apiHost = toApiSubdomain(host);
|
|
230
|
+
const response = await fetch(`${apiHost}/login`, {
|
|
176
231
|
method: 'POST',
|
|
177
232
|
headers: getHeaders(),
|
|
178
233
|
body: JSON.stringify({
|
|
179
234
|
username: answers.username,
|
|
180
|
-
password: answers.password
|
|
181
|
-
})
|
|
235
|
+
password: answers.password,
|
|
236
|
+
}),
|
|
182
237
|
});
|
|
183
238
|
|
|
239
|
+
const data = await response.json();
|
|
184
240
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (data.next_step === 'otp') {
|
|
197
|
-
spinner.succeed(chalk.green('2FA is enabled'));
|
|
198
|
-
const answers2FA = await inquirer.prompt([
|
|
199
|
-
{
|
|
200
|
-
type: 'input',
|
|
201
|
-
name: 'otp',
|
|
202
|
-
message: 'Authenticator Code:',
|
|
203
|
-
validate: input => input.length === 6 || 'OTP must be 6 digits'
|
|
204
|
-
}
|
|
205
|
-
]);
|
|
206
|
-
spinner = ora('Logging in to Puter...').start();
|
|
207
|
-
const response = await fetch(`${answers.host}/login/otp`, {
|
|
208
|
-
method: 'POST',
|
|
209
|
-
headers: getHeaders(),
|
|
210
|
-
body: JSON.stringify({
|
|
211
|
-
token: data.otp_jwt_token,
|
|
212
|
-
code: answers2FA.otp,
|
|
213
|
-
}),
|
|
214
|
-
});
|
|
215
|
-
data = await response.json();
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
241
|
+
if (data.proceed && data.next_step === 'otp') {
|
|
242
|
+
// Handle 2FA
|
|
243
|
+
spinner.stop();
|
|
244
|
+
const otpAnswer = await inquirer.prompt([
|
|
245
|
+
{
|
|
246
|
+
type: 'input',
|
|
247
|
+
name: 'otp',
|
|
248
|
+
message: 'Enter your 2FA code:',
|
|
249
|
+
validate: input => input.length >= 1 || '2FA code is required'
|
|
250
|
+
}
|
|
251
|
+
]);
|
|
218
252
|
|
|
219
|
-
|
|
253
|
+
spinner = ora('Verifying 2FA code...').start();
|
|
254
|
+
const otpResponse = await fetch(`${apiHost}/login/otp`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: getHeaders(),
|
|
257
|
+
body: JSON.stringify({
|
|
258
|
+
token: data.otp_jwt_token,
|
|
259
|
+
code: otpAnswer.otp,
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
220
262
|
|
|
221
|
-
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
263
|
+
const otpData = await otpResponse.json();
|
|
224
264
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
username: answers.username,
|
|
230
|
-
cwd: `/${answers.username}`,
|
|
231
|
-
token: data.token,
|
|
232
|
-
uuid: profileUUID,
|
|
233
|
-
};
|
|
234
|
-
this.addProfile(profile);
|
|
235
|
-
this.selectProfile(profile);
|
|
236
|
-
if (spinner) {
|
|
237
|
-
spinner.succeed(chalk.green('Successfully logged in to Puter!'));
|
|
238
|
-
}
|
|
239
|
-
console.log(chalk.dim(`Token: ${data.token.slice(0, 5)}...${data.token.slice(-5)}`));
|
|
240
|
-
// Save token
|
|
241
|
-
if (args.save) {
|
|
242
|
-
const localEnvFile = '.env';
|
|
243
|
-
try {
|
|
244
|
-
// Check if the file exists, if so then append the api key to the EOF.
|
|
245
|
-
if (fs.existsSync(localEnvFile)) {
|
|
246
|
-
console.log(chalk.yellow(`File "${localEnvFile}" already exists... Adding token.`));
|
|
247
|
-
fs.appendFileSync(localEnvFile, `\nPUTER_API_KEY="${data.token}"`, 'utf8');
|
|
248
|
-
} else {
|
|
249
|
-
console.log(chalk.cyan(`Saving token to ${chalk.green(localEnvFile)} file.`));
|
|
250
|
-
fs.writeFileSync(localEnvFile, `PUTER_API_KEY="${data.token}"`, 'utf8');
|
|
251
|
-
}
|
|
252
|
-
} catch (error) {
|
|
253
|
-
console.error(chalk.red(`Cannot save token to .env file. Error: ${error.message}`));
|
|
254
|
-
console.log(chalk.cyan(`PUTER_API_KEY="${data.token}"`));
|
|
255
|
-
}
|
|
265
|
+
if (otpData.token) {
|
|
266
|
+
this.createProfileFromToken(otpData.token, answers.username, host, spinner, save);
|
|
267
|
+
} else {
|
|
268
|
+
spinner.fail(chalk.red('2FA verification failed.'));
|
|
256
269
|
}
|
|
270
|
+
} else if (data.token) {
|
|
271
|
+
this.createProfileFromToken(data.token, answers.username, host, spinner, save);
|
|
257
272
|
} else {
|
|
258
|
-
spinner.fail(chalk.red('Login failed. Please check your credentials.'));
|
|
273
|
+
spinner.fail(chalk.red(data.error?.message || 'Login failed. Please check your credentials.'));
|
|
259
274
|
}
|
|
260
275
|
} catch (error) {
|
|
261
276
|
if (spinner) {
|
|
262
277
|
spinner.fail(chalk.red(`Failed to login: ${error.message}`));
|
|
263
|
-
console.log(error);
|
|
264
278
|
} else {
|
|
265
279
|
console.error(chalk.red(`Failed to login: ${error.message}`));
|
|
266
280
|
}
|
|
267
281
|
}
|
|
268
282
|
}
|
|
283
|
+
|
|
284
|
+
createProfileFromToken(token, username, host, spinner, save) {
|
|
285
|
+
const profileUUID = crypto.randomUUID();
|
|
286
|
+
const profile = {
|
|
287
|
+
host,
|
|
288
|
+
username,
|
|
289
|
+
cwd: `/${username}`,
|
|
290
|
+
token,
|
|
291
|
+
uuid: profileUUID,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
this.addProfile(profile);
|
|
295
|
+
this.selectProfile(profile);
|
|
296
|
+
spinner.succeed(chalk.green(`Successfully logged in as ${username}!`));
|
|
297
|
+
|
|
298
|
+
// Handle --save option
|
|
299
|
+
this.saveTokenToEnv(token, save);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
saveTokenToEnv(token, save) {
|
|
303
|
+
if (!save) return;
|
|
304
|
+
|
|
305
|
+
const localEnvFile = '.env';
|
|
306
|
+
try {
|
|
307
|
+
if (fs.existsSync(localEnvFile)) {
|
|
308
|
+
console.log(chalk.yellow(`File "${localEnvFile}" already exists... Adding token.`));
|
|
309
|
+
fs.appendFileSync(localEnvFile, `\nPUTER_API_KEY="${token}"`, 'utf8');
|
|
310
|
+
} else {
|
|
311
|
+
console.log(chalk.cyan(`Saving token to ${chalk.green(localEnvFile)} file.`));
|
|
312
|
+
fs.writeFileSync(localEnvFile, `PUTER_API_KEY="${token}"`, 'utf8');
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.error(chalk.red(`Cannot save token to .env file. Error: ${error.message}`));
|
|
316
|
+
console.log(chalk.cyan(`PUTER_API_KEY="${token}"`));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
269
319
|
}
|
|
270
320
|
|
|
271
|
-
export
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
321
|
+
export const initProfileModule = () => {
|
|
322
|
+
profileModule = new ProfileModule();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get ProfileModule object
|
|
327
|
+
* @returns {ProfileModule} ProfileModule - ProfileModule Object.
|
|
328
|
+
*/
|
|
329
|
+
export const getProfileModule = () => {
|
|
330
|
+
if (!profileModule) {
|
|
331
|
+
throw new Error("Call initprofileModule() first");
|
|
332
|
+
}
|
|
333
|
+
return profileModule;
|
|
334
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Conf from "conf";
|
|
2
|
+
import { PROJECT_NAME } from "../commons.js";
|
|
3
|
+
import { puter } from "@heyputer/puter.js";
|
|
4
|
+
|
|
5
|
+
const config = new Conf({ projectName: PROJECT_NAME });
|
|
6
|
+
|
|
7
|
+
let puterModule;
|
|
8
|
+
|
|
9
|
+
export const initPuterModule = () => {
|
|
10
|
+
const uuid = config.get("selected_profile");
|
|
11
|
+
const profiles = config.get("profiles") ?? [];
|
|
12
|
+
const profile = profiles.find((v) => v.uuid === uuid);
|
|
13
|
+
const authToken = profile?.token;
|
|
14
|
+
|
|
15
|
+
if (authToken) {
|
|
16
|
+
puter.setAuthToken(authToken);
|
|
17
|
+
puterModule = puter;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get Puter object
|
|
23
|
+
* @returns {puter} puter - Puter Object.
|
|
24
|
+
*/
|
|
25
|
+
export const getPuter = () => {
|
|
26
|
+
if (!puterModule) {
|
|
27
|
+
throw new Error("Call initPuterModule() first");
|
|
28
|
+
}
|
|
29
|
+
return puterModule;
|
|
30
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { it, describe, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.spyOn(console, "log").mockImplementation(() => { });
|
|
4
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
5
|
+
|
|
6
|
+
let errors, report, ERROR_BUFFER_LIMIT, showLast;
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
vi.resetModules();
|
|
10
|
+
const module = await import("../src/modules/ErrorModule");
|
|
11
|
+
errors = module.errors;
|
|
12
|
+
report = module.report;
|
|
13
|
+
ERROR_BUFFER_LIMIT = module.ERROR_BUFFER_LIMIT;
|
|
14
|
+
showLast = module.showLast;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("report", () => {
|
|
18
|
+
it("should be able to report error", () => {
|
|
19
|
+
report("hehe")
|
|
20
|
+
expect(errors).toHaveLength(1)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("should not exceed error buffer limit", () => {
|
|
24
|
+
for (let i = 0; i < 100; i++) {
|
|
25
|
+
report(`error ${i}`)
|
|
26
|
+
}
|
|
27
|
+
expect(errors.length).lessThanOrEqual(ERROR_BUFFER_LIMIT);
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe("showLast", () => {
|
|
32
|
+
it("should not log error if no error exists", () => {
|
|
33
|
+
showLast();
|
|
34
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("No errors to report"));
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("should log error if error exists", () => {
|
|
38
|
+
report("hehe")
|
|
39
|
+
showLast();
|
|
40
|
+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("hehe"));
|
|
41
|
+
})
|
|
42
|
+
})
|