@nosto/nosto-cli 1.0.3 → 1.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/.github/copilot-instructions.md +2 -2
- package/.github/dependabot.yml +12 -1
- package/README.md +3 -3
- package/eslint.config.js +11 -1
- package/package.json +8 -8
- package/src/api/retry.ts +24 -8
- package/src/config/authConfig.ts +2 -2
- package/src/config/config.ts +7 -1
- package/src/errors/InvalidLoginResponseError.ts +1 -1
- package/src/errors/withErrorHandler.ts +10 -14
- package/src/filesystem/homeDirectory.ts +3 -0
- package/src/filesystem/processInBatches.ts +1 -0
- package/src/modules/login.ts +10 -4
- package/src/modules/search-templates/push.ts +1 -21
- package/src/modules/setup.ts +1 -3
- package/src/modules/status.ts +1 -1
- package/src/vite-env.d.ts +1 -0
- package/test/api/retry.test.ts +41 -1
- package/test/commander.test.ts +31 -2
- package/test/config/authConfig.test.ts +62 -0
- package/test/config/config.test.ts +71 -0
- package/test/console/logger.test.ts +16 -0
- package/test/console/userPrompt.test.ts +30 -0
- package/test/errors/withErrorHandler.test.ts +80 -5
- package/test/filesystem/asserts/assertGitRepo.test.ts +35 -0
- package/test/filesystem/asserts/assertNostoTemplate.test.ts +53 -0
- package/test/filesystem/filesystem.test.ts +17 -1
- package/test/filesystem/plugins.test.ts +73 -2
- package/test/filesystem/processInBatches.test.ts +34 -0
- package/test/modules/login.test.ts +38 -0
- package/test/modules/logout.test.ts +24 -0
- package/test/modules/search-templates/build.legacy.test.ts +25 -0
- package/test/modules/search-templates/build.modern.test.ts +5 -1
- package/test/modules/search-templates/dev.legacy.test.ts +23 -0
- package/test/modules/search-templates/pull.test.ts +38 -1
- package/test/modules/search-templates/push.test.ts +145 -1
- package/test/modules/setup.test.ts +16 -0
- package/test/modules/status.test.ts +45 -4
- package/test/setup.ts +11 -0
- package/test/utils/generateEndpointMock.ts +10 -3
- package/test/utils/mockAuthServer.ts +72 -0
- package/test/utils/mockConsole.ts +20 -0
- package/test/utils/mockFileSystem.ts +46 -12
- package/test/utils/mockStarterManifest.ts +3 -2
- package/vitest.config.ts +13 -5
|
@@ -74,7 +74,7 @@ tsx /path/to/nosto-cli/src/index.ts st --help
|
|
|
74
74
|
tsx /path/to/nosto-cli/src/index.ts st build --help
|
|
75
75
|
|
|
76
76
|
# Test dry run (will attempt API connection and fail - this is expected)
|
|
77
|
-
mkdir -p src && echo 'console.
|
|
77
|
+
mkdir -p src && echo 'console.info("test");' > src/test.js
|
|
78
78
|
tsx /path/to/nosto-cli/src/index.ts st build --dry-run
|
|
79
79
|
```
|
|
80
80
|
|
|
@@ -220,7 +220,7 @@ const server = setupMockServer()
|
|
|
220
220
|
// Usage
|
|
221
221
|
mockFetchSourceFile(server, {
|
|
222
222
|
path: "index.js",
|
|
223
|
-
response: "console.
|
|
223
|
+
response: "console.info('test')"
|
|
224
224
|
})
|
|
225
225
|
```
|
|
226
226
|
|
package/.github/dependabot.yml
CHANGED
|
@@ -6,4 +6,15 @@ updates:
|
|
|
6
6
|
interval: "weekly"
|
|
7
7
|
commit-message:
|
|
8
8
|
prefix: "deps"
|
|
9
|
-
open-pull-requests-limit: 10
|
|
9
|
+
open-pull-requests-limit: 10
|
|
10
|
+
groups:
|
|
11
|
+
eslint:
|
|
12
|
+
patterns:
|
|
13
|
+
- "@eslint/*"
|
|
14
|
+
- "eslint"
|
|
15
|
+
- "eslint-*"
|
|
16
|
+
- "typescript-eslint"
|
|
17
|
+
vitest:
|
|
18
|
+
patterns:
|
|
19
|
+
- "@vitest/*"
|
|
20
|
+
- "vitest"
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A command-line interface to interact with Nosto's backend systems. Primarily aim
|
|
|
6
6
|
|
|
7
7
|
# About Nosto
|
|
8
8
|
|
|
9
|
-
If you are unfamiliar with Nosto as a company, you are welcome to visit our homepage at [https://nosto.com
|
|
9
|
+
If you are unfamiliar with Nosto as a company, you are welcome to visit our homepage at [https://www.nosto.com](https://www.nosto.com/).
|
|
10
10
|
|
|
11
11
|
If you wish to know more about our tech stack, we publish extensive documentation known as the [Techdocs](https://docs.nosto.com/techdocs).
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ If you wish to know more about our tech stack, we publish extensive documentatio
|
|
|
14
14
|
|
|
15
15
|
Nosto CLI aims to be as user-friendly as CLI tools get. You should be able to get up and running by utilizing the built-in `help` and `setup` commands, but a quick-start guide is also provided here.
|
|
16
16
|
|
|
17
|
-
To start with, you may create an empty folder for your
|
|
17
|
+
To start with, you may create an empty folder for your project; or you may clone your git repository to work with.
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
# Install the CLI tool:
|
|
@@ -155,4 +155,4 @@ Running `nosto st push` after `nosto st dev` without changing any files will sto
|
|
|
155
155
|
|
|
156
156
|
## Contributions
|
|
157
157
|
|
|
158
|
-
We welcome both internal and external contributions to this project! Feel free to request bugfixes, open issues or PRs for review. Nosto is actively maintaining this project, and we will process your contributions as soon as possible.
|
|
158
|
+
We welcome both internal and external contributions to this project! Feel free to request bugfixes, open issues or PRs for review. Nosto is actively maintaining this project, and we will process your contributions as soon as possible.
|
package/eslint.config.js
CHANGED
|
@@ -27,9 +27,19 @@ export default defineConfig([
|
|
|
27
27
|
},
|
|
28
28
|
rules: {
|
|
29
29
|
"unused-imports/no-unused-imports": "error",
|
|
30
|
+
"unused-imports/no-unused-vars": [
|
|
31
|
+
"warn",
|
|
32
|
+
{
|
|
33
|
+
vars: "all",
|
|
34
|
+
varsIgnorePattern: "^_",
|
|
35
|
+
args: "after-used",
|
|
36
|
+
argsIgnorePattern: "^_"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
30
39
|
"simple-import-sort/imports": "error",
|
|
31
40
|
"simple-import-sort/exports": "error",
|
|
32
|
-
"no-restricted-imports": ["error", "node:test"]
|
|
41
|
+
"no-restricted-imports": ["error", "node:test"],
|
|
42
|
+
"@typescript-eslint/no-unused-vars": "off"
|
|
33
43
|
}
|
|
34
44
|
},
|
|
35
45
|
eslintPluginPrettierRecommended
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nosto/nosto-cli",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"main": "./src/index.ts",
|
|
5
5
|
"bin": {
|
|
6
6
|
"nosto": "./src/bootstrap.mjs"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"chalk": "^5.6.2",
|
|
33
33
|
"commander": "^14.0.1",
|
|
34
|
-
"esbuild": "^0.
|
|
34
|
+
"esbuild": "^0.27.0",
|
|
35
35
|
"ignore": "^7.0.5",
|
|
36
36
|
"ky": "^1.11.0",
|
|
37
37
|
"open": "^10.2.0",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"@commitlint/cli": "^20.1.0",
|
|
44
44
|
"@commitlint/config-conventional": "^20.0.0",
|
|
45
45
|
"@eslint/js": "^9.37.0",
|
|
46
|
-
"@types/node": "^24.7.
|
|
47
|
-
"@vitest/coverage-v8": "^
|
|
48
|
-
"@vitest/ui": "^
|
|
46
|
+
"@types/node": "^24.7.2",
|
|
47
|
+
"@vitest/coverage-v8": "^4.0.6",
|
|
48
|
+
"@vitest/ui": "^4.0.6",
|
|
49
49
|
"eslint": "^9.37.0",
|
|
50
50
|
"eslint-config-prettier": "^10.1.8",
|
|
51
51
|
"eslint-plugin-prettier": "^5.5.4",
|
|
@@ -53,10 +53,10 @@
|
|
|
53
53
|
"eslint-plugin-unused-imports": "^4.2.0",
|
|
54
54
|
"husky": "^9.1.7",
|
|
55
55
|
"memfs": "^4.48.1",
|
|
56
|
-
"msw": "^2.11.
|
|
56
|
+
"msw": "^2.11.5",
|
|
57
57
|
"prettier": "^3.6.2",
|
|
58
|
-
"typescript-eslint": "^8.
|
|
59
|
-
"vitest": "^
|
|
58
|
+
"typescript-eslint": "^8.46.0",
|
|
59
|
+
"vitest": "^4.0.6"
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"access": "public"
|
package/src/api/retry.ts
CHANGED
|
@@ -2,27 +2,43 @@ import chalk from "chalk"
|
|
|
2
2
|
|
|
3
3
|
import { Logger } from "#console/logger.ts"
|
|
4
4
|
|
|
5
|
+
import { putSourceFile } from "./source/putSourceFile.ts"
|
|
6
|
+
|
|
5
7
|
const MAX_RETRIES = 3
|
|
6
8
|
const INITIAL_RETRY_DELAY = 1000 // 1 second
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
async function executeWithRetry<T>(
|
|
11
|
+
operation: () => Promise<T>,
|
|
10
12
|
filePath: string,
|
|
13
|
+
operationType: "fetch" | "push",
|
|
11
14
|
retryCount = 0
|
|
12
|
-
): Promise<
|
|
15
|
+
): Promise<T> {
|
|
13
16
|
try {
|
|
14
|
-
return await
|
|
17
|
+
return await operation()
|
|
15
18
|
} catch (error: unknown) {
|
|
16
19
|
if (retryCount >= MAX_RETRIES) {
|
|
17
20
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
if (operationType === "fetch") {
|
|
22
|
+
Logger.error(`${chalk.red("✗")} ${chalk.cyan(filePath)}: ${errorMessage}`)
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`Failed to ${operationType} ${filePath} after ${MAX_RETRIES} retries: ${errorMessage}`)
|
|
20
25
|
}
|
|
21
26
|
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount)
|
|
22
27
|
Logger.warn(
|
|
23
|
-
`${chalk.yellow("⟳")} Failed to
|
|
28
|
+
`${chalk.yellow("⟳")} Failed to ${operationType} ${chalk.cyan(filePath)}: Retrying in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`
|
|
24
29
|
)
|
|
25
30
|
await new Promise(resolve => setTimeout(resolve, delay))
|
|
26
|
-
return
|
|
31
|
+
return executeWithRetry(operation, filePath, operationType, retryCount + 1)
|
|
27
32
|
}
|
|
28
33
|
}
|
|
34
|
+
|
|
35
|
+
export async function fetchWithRetry(
|
|
36
|
+
apiFunction: (filePath: string) => Promise<string>,
|
|
37
|
+
filePath: string
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
return executeWithRetry(() => apiFunction(filePath), filePath, "fetch")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function putWithRetry(filePath: string, content: string): Promise<void> {
|
|
43
|
+
return executeWithRetry(() => putSourceFile(filePath, content), filePath, "push")
|
|
44
|
+
}
|
package/src/config/authConfig.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import fs from "fs"
|
|
2
|
-
import os from "os"
|
|
3
2
|
import path from "path"
|
|
4
3
|
import z from "zod"
|
|
5
4
|
|
|
6
5
|
import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
|
|
6
|
+
import { HomeDirectory } from "#filesystem/homeDirectory.ts"
|
|
7
7
|
|
|
8
8
|
import { type AuthConfig, AuthConfigSchema } from "./schema.ts"
|
|
9
9
|
|
|
10
|
-
export const AuthConfigFilePath = path.join(
|
|
10
|
+
export const AuthConfigFilePath = path.join(HomeDirectory, ".nosto", ".auth.json")
|
|
11
11
|
|
|
12
12
|
export function authFileExists() {
|
|
13
13
|
return fs.existsSync(AuthConfigFilePath)
|
package/src/config/config.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import { parseSearchTemplatesConfigFile } from "./searchTemplatesConfig.ts"
|
|
18
18
|
|
|
19
19
|
let isConfigLoaded = false
|
|
20
|
-
|
|
20
|
+
const defaultCachedConfig: Config = {
|
|
21
21
|
...getDefaultConfig(),
|
|
22
22
|
auth: AuthConfigSchema.parse({
|
|
23
23
|
user: "",
|
|
@@ -30,6 +30,7 @@ let cachedConfig: Config = {
|
|
|
30
30
|
},
|
|
31
31
|
...RuntimeConfigSchema.parse({})
|
|
32
32
|
}
|
|
33
|
+
let cachedConfig = defaultCachedConfig
|
|
33
34
|
|
|
34
35
|
export type LoadConfigProps = {
|
|
35
36
|
projectPath: string
|
|
@@ -107,3 +108,8 @@ export function getDefaultConfig() {
|
|
|
107
108
|
merchant: ""
|
|
108
109
|
})
|
|
109
110
|
}
|
|
111
|
+
|
|
112
|
+
export function clearCachedConfig() {
|
|
113
|
+
isConfigLoaded = false
|
|
114
|
+
cachedConfig = defaultCachedConfig
|
|
115
|
+
}
|
|
@@ -4,34 +4,30 @@ import { HTTPError, TimeoutError } from "ky"
|
|
|
4
4
|
import { getCachedConfig } from "#config/config.ts"
|
|
5
5
|
import { Logger } from "#console/logger.ts"
|
|
6
6
|
|
|
7
|
-
import { InvalidLoginResponseError } from "./InvalidLoginResponseError.ts"
|
|
8
7
|
import { NostoError } from "./NostoError.ts"
|
|
9
8
|
|
|
10
9
|
export async function withErrorHandler(fn: () => void | Promise<void>): Promise<void> {
|
|
10
|
+
function printStack(error: Error) {
|
|
11
|
+
const config = getCachedConfig()
|
|
12
|
+
if (config.verbose) {
|
|
13
|
+
Logger.raw(chalk.red(prettyPrintStack(error.stack)))
|
|
14
|
+
} else {
|
|
15
|
+
Logger.info(chalk.gray("Rerun with --verbose to see details"))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
11
18
|
try {
|
|
12
19
|
await fn()
|
|
13
20
|
} catch (error) {
|
|
14
21
|
if (error instanceof HTTPError) {
|
|
15
|
-
const config = getCachedConfig()
|
|
16
22
|
Logger.error(`HTTP Request failed:`)
|
|
17
23
|
Logger.error(`- ${error.response.status} ${error.response.statusText}`)
|
|
18
24
|
Logger.error(`- ${error.request.method} ${error.request.url}`)
|
|
19
|
-
|
|
20
|
-
Logger.raw(chalk.red(prettyPrintStack(error.stack)))
|
|
21
|
-
}
|
|
22
|
-
if (!config.verbose) {
|
|
23
|
-
Logger.info(chalk.gray("Rerun with --verbose to see details"))
|
|
24
|
-
}
|
|
25
|
+
printStack(error)
|
|
25
26
|
} else if (error instanceof TimeoutError) {
|
|
26
|
-
const config = getCachedConfig()
|
|
27
27
|
Logger.error(`HTTP Request timed out:`)
|
|
28
28
|
Logger.error(`- Server did not respond after 10 seconds`)
|
|
29
29
|
Logger.error(`- ${error.request.method} ${error.request.url}`)
|
|
30
|
-
|
|
31
|
-
Logger.raw(chalk.red(prettyPrintStack(error.stack)))
|
|
32
|
-
}
|
|
33
|
-
} else if (error instanceof InvalidLoginResponseError) {
|
|
34
|
-
Logger.error(`Received malformed login response from server. This is probably a bug on our side.`, error)
|
|
30
|
+
printStack(error)
|
|
35
31
|
} else if (error instanceof NostoError) {
|
|
36
32
|
error.handle()
|
|
37
33
|
} else {
|
|
@@ -27,6 +27,7 @@ export async function processInBatches({ files, logIcon, processElement }: Props
|
|
|
27
27
|
} catch (error: unknown) {
|
|
28
28
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
29
29
|
Logger.error(`${chalk.red("✗")} ${chalk.cyan(file)}: ${errorMessage}`)
|
|
30
|
+
throw error
|
|
30
31
|
}
|
|
31
32
|
})
|
|
32
33
|
const results = await Promise.allSettled(batchPromises)
|
package/src/modules/login.ts
CHANGED
|
@@ -29,6 +29,9 @@ export async function loginToPlaycart() {
|
|
|
29
29
|
|
|
30
30
|
Logger.info("Awaiting response from the browser...")
|
|
31
31
|
const response = await server.responseData
|
|
32
|
+
if ("error" in response) {
|
|
33
|
+
throw new InvalidLoginResponseError(response.error)
|
|
34
|
+
}
|
|
32
35
|
writeFile(AuthConfigFilePath, JSON.stringify(response, null, 2) + "\n")
|
|
33
36
|
|
|
34
37
|
Logger.success(`Login successful! Auth file saved at ${AuthConfigFilePath}`)
|
|
@@ -39,7 +42,7 @@ type AuthServer = {
|
|
|
39
42
|
responseData: Promise<PlaycartResponse>
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
type PlaycartResponse = z.infer<typeof AuthConfigSchema>
|
|
45
|
+
type PlaycartResponse = z.infer<typeof AuthConfigSchema> | { error: string }
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
48
|
* The authentication server is created to handle a single redirect from the browser.
|
|
@@ -58,7 +61,9 @@ async function createAuthServer(): Promise<AuthServer> {
|
|
|
58
61
|
expiresAt: url.searchParams.get("expiresAt")
|
|
59
62
|
})
|
|
60
63
|
if (!parsed.success) {
|
|
61
|
-
|
|
64
|
+
const error = `Failed to parse playcart response: ${parsed.error.message}`
|
|
65
|
+
resolveTokenPromise({ error })
|
|
66
|
+
throw new InvalidLoginResponseError(error)
|
|
62
67
|
}
|
|
63
68
|
resolveTokenPromise(parsed.data)
|
|
64
69
|
}
|
|
@@ -70,11 +75,12 @@ async function createAuthServer(): Promise<AuthServer> {
|
|
|
70
75
|
server.close()
|
|
71
76
|
})
|
|
72
77
|
|
|
73
|
-
const port = await new Promise<number>(resolve => {
|
|
78
|
+
const port = await new Promise<number>((resolve, reject) => {
|
|
74
79
|
server.listen(0, "localhost", () => {
|
|
75
80
|
const addr = server.address()
|
|
76
81
|
if (!addr || typeof addr !== "object") {
|
|
77
|
-
|
|
82
|
+
reject(new Error("Failed to get server address"))
|
|
83
|
+
return
|
|
78
84
|
}
|
|
79
85
|
resolve(addr.port)
|
|
80
86
|
})
|
|
@@ -2,8 +2,8 @@ import chalk from "chalk"
|
|
|
2
2
|
import fs from "fs"
|
|
3
3
|
import path from "path"
|
|
4
4
|
|
|
5
|
+
import { putWithRetry } from "#api/retry.ts"
|
|
5
6
|
import { fetchSourceFileIfExists } from "#api/source/fetchSourceFile.ts"
|
|
6
|
-
import { putSourceFile } from "#api/source/putSourceFile.ts"
|
|
7
7
|
import { getCachedConfig } from "#config/config.ts"
|
|
8
8
|
import { Logger } from "#console/logger.ts"
|
|
9
9
|
import { promptForConfirmation } from "#console/userPrompt.ts"
|
|
@@ -11,9 +11,6 @@ import { calculateTreeHash } from "#filesystem/calculateTreeHash.ts"
|
|
|
11
11
|
import { listAllFiles, readFileIfExists, writeFile } from "#filesystem/filesystem.ts"
|
|
12
12
|
import { processInBatches } from "#filesystem/processInBatches.ts"
|
|
13
13
|
|
|
14
|
-
const MAX_RETRIES = 3
|
|
15
|
-
const INITIAL_RETRY_DELAY = 1000 // 1 second
|
|
16
|
-
|
|
17
14
|
type PushSearchTemplateOptions = {
|
|
18
15
|
// Filter to only push these files. Ignored if empty.
|
|
19
16
|
paths: string[]
|
|
@@ -102,20 +99,3 @@ export async function pushSearchTemplate({ paths, force }: PushSearchTemplateOpt
|
|
|
102
99
|
}
|
|
103
100
|
})
|
|
104
101
|
}
|
|
105
|
-
|
|
106
|
-
async function putWithRetry(filePath: string, content: string, retryCount = 0): Promise<void> {
|
|
107
|
-
try {
|
|
108
|
-
return await putSourceFile(filePath, content)
|
|
109
|
-
} catch (error: unknown) {
|
|
110
|
-
if (retryCount >= MAX_RETRIES) {
|
|
111
|
-
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
112
|
-
throw new Error(`Failed to push ${filePath} after ${MAX_RETRIES} retries: ${errorMessage}`)
|
|
113
|
-
}
|
|
114
|
-
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount)
|
|
115
|
-
Logger.warn(
|
|
116
|
-
`${chalk.yellow("⟳")} Failed to push ${chalk.cyan(filePath)}: Retrying in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`
|
|
117
|
-
)
|
|
118
|
-
await new Promise(resolve => setTimeout(resolve, delay))
|
|
119
|
-
return putWithRetry(filePath, content, retryCount + 1)
|
|
120
|
-
}
|
|
121
|
-
}
|
package/src/modules/setup.ts
CHANGED
|
@@ -68,9 +68,7 @@ export async function printSetupHelp(projectPath: string, options: Options) {
|
|
|
68
68
|
const envConfig = getEnvConfig()
|
|
69
69
|
const configToCreate = defaultConfig
|
|
70
70
|
Object.entries(envConfig).forEach(([key, value]) => {
|
|
71
|
-
|
|
72
|
-
Object.assign(configToCreate, { [key]: value })
|
|
73
|
-
}
|
|
71
|
+
Object.assign(configToCreate, { [key]: value })
|
|
74
72
|
})
|
|
75
73
|
|
|
76
74
|
const { merchant } = options
|
package/src/modules/status.ts
CHANGED
|
@@ -9,7 +9,7 @@ export async function printStatus(projectPath: string) {
|
|
|
9
9
|
await loadConfig({ projectPath, options: {} })
|
|
10
10
|
} catch (error) {
|
|
11
11
|
if (error instanceof MissingConfigurationError) {
|
|
12
|
-
|
|
12
|
+
// Configuration is missing, we will report it below
|
|
13
13
|
} else {
|
|
14
14
|
throw error
|
|
15
15
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/test/api/retry.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
2
|
|
|
3
|
-
import { fetchWithRetry } from "#api/retry.ts"
|
|
3
|
+
import { fetchWithRetry, putWithRetry } from "#api/retry.ts"
|
|
4
|
+
import * as putSourceFileModule from "#api/source/putSourceFile.ts"
|
|
4
5
|
import { setupMockConsole } from "#test/utils/mockConsole.ts"
|
|
5
6
|
|
|
6
7
|
const terminal = setupMockConsole()
|
|
@@ -99,4 +100,43 @@ describe("API Retry", () => {
|
|
|
99
100
|
await assertion
|
|
100
101
|
})
|
|
101
102
|
})
|
|
103
|
+
|
|
104
|
+
describe("putWithRetry", () => {
|
|
105
|
+
let putSourceFileSpy: ReturnType<typeof vi.spyOn>
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.clearAllMocks()
|
|
109
|
+
putSourceFileSpy = vi.spyOn(putSourceFileModule, "putSourceFile")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
putSourceFileSpy.mockRestore()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("should complete successfully on first attempt", async () => {
|
|
117
|
+
putSourceFileSpy.mockResolvedValue(undefined)
|
|
118
|
+
|
|
119
|
+
await putWithRetry("test-file.txt", "content")
|
|
120
|
+
|
|
121
|
+
expect(putSourceFileSpy).toHaveBeenCalledTimes(1)
|
|
122
|
+
expect(putSourceFileSpy).toHaveBeenCalledWith("test-file.txt", "content")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("should use 'push' operation type in error messages", async () => {
|
|
126
|
+
putSourceFileSpy.mockRejectedValue(new Error("Network error"))
|
|
127
|
+
|
|
128
|
+
const retryPromise = putWithRetry("test-file.txt", "content")
|
|
129
|
+
|
|
130
|
+
const assertion = expect(retryPromise).rejects.toThrow(
|
|
131
|
+
"Failed to push test-file.txt after 3 retries: Network error"
|
|
132
|
+
)
|
|
133
|
+
await vi.runAllTimersAsync()
|
|
134
|
+
|
|
135
|
+
await assertion
|
|
136
|
+
|
|
137
|
+
expect(terminal.getSpy("warn")).toHaveBeenCalledWith(
|
|
138
|
+
expect.stringContaining("Failed to push test-file.txt: Retrying in 1000ms (attempt 1/3)")
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
102
142
|
})
|
package/test/commander.test.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, MockInstance, vi } from "vitest"
|
|
2
2
|
|
|
3
|
+
import { clearCachedConfig, getCachedConfig } from "#config/config.ts"
|
|
3
4
|
import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
|
|
5
|
+
import * as login from "#modules/login.ts"
|
|
6
|
+
import * as logout from "#modules/logout.ts"
|
|
4
7
|
import * as build from "#modules/search-templates/build.ts"
|
|
5
8
|
import * as dev from "#modules/search-templates/dev.ts"
|
|
6
9
|
import * as pull from "#modules/search-templates/pull.ts"
|
|
@@ -14,6 +17,8 @@ import { setupMockFileSystem } from "./utils/mockFileSystem.ts"
|
|
|
14
17
|
const fs = setupMockFileSystem()
|
|
15
18
|
const commander = setupMockCommander()
|
|
16
19
|
|
|
20
|
+
let loginSpy: MockInstance
|
|
21
|
+
let logoutSpy: MockInstance
|
|
17
22
|
let setupSpy: MockInstance
|
|
18
23
|
let statusSpy: MockInstance
|
|
19
24
|
let buildSpy: MockInstance
|
|
@@ -22,14 +27,36 @@ let pushSpy: MockInstance
|
|
|
22
27
|
let devSpy: MockInstance
|
|
23
28
|
|
|
24
29
|
describe("commander", () => {
|
|
25
|
-
// Make sure the actual functions are never called
|
|
26
30
|
beforeEach(() => {
|
|
31
|
+
loginSpy = vi.spyOn(login, "loginToPlaycart").mockImplementation(() => Promise.resolve())
|
|
32
|
+
logoutSpy = vi.spyOn(logout, "removeLoginCredentials").mockImplementation(() => Promise.resolve())
|
|
27
33
|
setupSpy = vi.spyOn(setup, "printSetupHelp").mockImplementation(() => Promise.resolve())
|
|
28
34
|
statusSpy = vi.spyOn(status, "printStatus").mockImplementation(() => Promise.resolve())
|
|
29
35
|
buildSpy = vi.spyOn(build, "buildSearchTemplate").mockImplementation(() => Promise.resolve())
|
|
30
36
|
pullSpy = vi.spyOn(pull, "pullSearchTemplate").mockImplementation(() => Promise.resolve())
|
|
31
37
|
pushSpy = vi.spyOn(push, "pushSearchTemplate").mockImplementation(() => Promise.resolve())
|
|
32
38
|
devSpy = vi.spyOn(dev, "searchTemplateDevMode").mockImplementation(() => Promise.resolve())
|
|
39
|
+
clearCachedConfig()
|
|
40
|
+
fs.mockUserAuthentication()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("nosto login", () => {
|
|
44
|
+
it("should call the function", async () => {
|
|
45
|
+
await commander.run("nosto login")
|
|
46
|
+
expect(loginSpy).toHaveBeenCalledWith()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("should load the config", async () => {
|
|
50
|
+
await commander.run("nosto login --verbose")
|
|
51
|
+
expect(getCachedConfig().verbose).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe("nosto logout", () => {
|
|
56
|
+
it("should call the function", async () => {
|
|
57
|
+
await commander.run("nosto logout")
|
|
58
|
+
expect(logoutSpy).toHaveBeenCalledWith()
|
|
59
|
+
})
|
|
33
60
|
})
|
|
34
61
|
|
|
35
62
|
describe("nosto setup", () => {
|
|
@@ -143,6 +170,9 @@ describe("commander", () => {
|
|
|
143
170
|
})
|
|
144
171
|
|
|
145
172
|
describe("nosto search-templates pull", () => {
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
fs.writeFile(".nosto.json", JSON.stringify({ apiKey: "123", merchant: "456" }))
|
|
175
|
+
})
|
|
146
176
|
it("should pull even without files present", async () => {
|
|
147
177
|
await commander.run("nosto st pull")
|
|
148
178
|
expect(pullSpy).toHaveBeenCalled()
|
|
@@ -150,7 +180,6 @@ describe("commander", () => {
|
|
|
150
180
|
|
|
151
181
|
describe("with valid environment", () => {
|
|
152
182
|
beforeEach(() => {
|
|
153
|
-
fs.writeFile(".nosto.json", JSON.stringify({ apiKey: "123", merchant: "456" }))
|
|
154
183
|
fs.writeFile("index.js", "@nosto/preact")
|
|
155
184
|
})
|
|
156
185
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { parseAuthFile } from "#config/authConfig.ts"
|
|
4
|
+
import { AuthConfig } from "#config/schema.ts"
|
|
5
|
+
import { MissingConfigurationError } from "#errors/MissingConfigurationError.ts"
|
|
6
|
+
import { setupMockFileSystem } from "#test/utils/mockFileSystem.ts"
|
|
7
|
+
|
|
8
|
+
const fs = setupMockFileSystem()
|
|
9
|
+
|
|
10
|
+
describe("Auth Config", () => {
|
|
11
|
+
it("should throw if auth file does not exist", () => {
|
|
12
|
+
expect(() => parseAuthFile({})).toThrowError(MissingConfigurationError)
|
|
13
|
+
expect(() => parseAuthFile({ allowIncomplete: false })).toThrowError(MissingConfigurationError)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("with allowIncomplete, should return empty auth config if auth file does not exist", () => {
|
|
17
|
+
const authConfig = parseAuthFile({ allowIncomplete: true })
|
|
18
|
+
expect(authConfig).toEqual({ user: "", token: "", expiresAt: new Date(0) })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("should parse a valid auth file", () => {
|
|
22
|
+
const config = {
|
|
23
|
+
user: "testuser@nosto.com",
|
|
24
|
+
token: "testtoken",
|
|
25
|
+
expiresAt: new Date(Date.now() + 1000 * 60 * 60)
|
|
26
|
+
} satisfies AuthConfig
|
|
27
|
+
|
|
28
|
+
fs.writeFile(fs.paths.authFile, config)
|
|
29
|
+
|
|
30
|
+
const authConfig = parseAuthFile({})
|
|
31
|
+
expect(authConfig).toEqual(config)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("should throw an error for invalid auth file JSON", () => {
|
|
35
|
+
fs.writeFile(fs.paths.authFile, "{ invalidJson: true ")
|
|
36
|
+
|
|
37
|
+
expect(() => parseAuthFile({})).toThrowError(/Invalid JSON in auth file/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("should throw an error for auth file with invalid schema", () => {
|
|
41
|
+
const invalidConfig = {
|
|
42
|
+
user: ""
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fs.writeFile(fs.paths.authFile, invalidConfig)
|
|
46
|
+
|
|
47
|
+
expect(() => parseAuthFile({})).toThrowError(/Invalid auth file/)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("should throw an error for a malformed file", () => {
|
|
51
|
+
fs.writeFile(fs.paths.authFile, "\xff\xfe\xff\xfe")
|
|
52
|
+
|
|
53
|
+
expect(() => parseAuthFile({})).toThrowError(/Unexpected token/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("should throw an error when filesystem throws", () => {
|
|
57
|
+
fs.writeFile(fs.paths.authFile, {})
|
|
58
|
+
fs.chmod(fs.paths.authFile, 0o000) // Remove all permissions
|
|
59
|
+
|
|
60
|
+
expect(() => parseAuthFile({})).toThrowError(/permission denied/)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { clearCachedConfig, getCachedConfig, getCachedSearchTemplatesConfig, loadConfig } from "#config/config.ts"
|
|
4
|
+
import { PersistentConfigSchema } from "#config/schema.ts"
|
|
5
|
+
import { setupMockConsole } from "#test/utils/mockConsole.ts"
|
|
6
|
+
import { setupMockFileSystem } from "#test/utils/mockFileSystem.ts"
|
|
7
|
+
|
|
8
|
+
const fs = setupMockFileSystem()
|
|
9
|
+
const terminal = setupMockConsole()
|
|
10
|
+
|
|
11
|
+
describe("Config", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
clearCachedConfig()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
// Clean up environment variables
|
|
18
|
+
delete process.env.NOSTO_MAX_REQUESTS
|
|
19
|
+
vi.restoreAllMocks()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("uses cached config on subsequent calls", async () => {
|
|
23
|
+
fs.mockConfigFile()
|
|
24
|
+
fs.mockUserAuthentication()
|
|
25
|
+
|
|
26
|
+
await loadConfig({ projectPath: ".", options: {} })
|
|
27
|
+
expect(terminal.getSpy("debug")).not.toHaveBeenCalledWith("Using cached configuration")
|
|
28
|
+
await loadConfig({ projectPath: ".", options: {} })
|
|
29
|
+
expect(terminal.getSpy("debug")).toHaveBeenCalledWith("Using cached configuration")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("returns cached config", async () => {
|
|
33
|
+
fs.mockConfigFile()
|
|
34
|
+
fs.mockUserAuthentication()
|
|
35
|
+
|
|
36
|
+
const config = await loadConfig({ projectPath: ".", options: {} })
|
|
37
|
+
expect(getCachedConfig()).toEqual(config)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("returns cached search-templates build config", async () => {
|
|
41
|
+
fs.mockConfigFile()
|
|
42
|
+
fs.mockUserAuthentication()
|
|
43
|
+
|
|
44
|
+
const config = await loadConfig({ projectPath: ".", options: {} })
|
|
45
|
+
expect(getCachedSearchTemplatesConfig()).toEqual(config.searchTemplates.data)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("throws error with cause when last-chance schema validation fails", async () => {
|
|
49
|
+
fs.mockUserAuthentication()
|
|
50
|
+
fs.mockConfigFile()
|
|
51
|
+
|
|
52
|
+
// Mock the PersistentConfigSchema.parse to throw an error
|
|
53
|
+
const mockParse = vi.spyOn(PersistentConfigSchema, "parse")
|
|
54
|
+
mockParse.mockImplementationOnce(() => {
|
|
55
|
+
throw new Error("Mocked schema validation error")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await expect(loadConfig({ projectPath: ".", options: {} })).rejects.toThrow("Failed to load configuration")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("default implementation of modern config should throw", async () => {
|
|
62
|
+
fs.mockUserAuthentication()
|
|
63
|
+
fs.mockConfigFile()
|
|
64
|
+
|
|
65
|
+
const config = await loadConfig({ projectPath: ".", options: {} })
|
|
66
|
+
await expect(config.searchTemplates.data.onBuild()).rejects.toThrow(/not implemented/)
|
|
67
|
+
await expect(config.searchTemplates.data.onBuildWatch({ onAfterBuild: async () => {} })).rejects.toThrow(
|
|
68
|
+
/not implemented/
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
})
|