@nosto/nosto-cli 1.0.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.
Files changed (88) hide show
  1. package/.github/copilot-instructions.md +326 -0
  2. package/.github/dependabot.yml +9 -0
  3. package/.github/pull_request_template.md +12 -0
  4. package/.github/workflows/ci.yml +58 -0
  5. package/.github/workflows/release.yml +49 -0
  6. package/.husky/commit-msg +1 -0
  7. package/.prettierrc +9 -0
  8. package/LICENSE +29 -0
  9. package/README.md +154 -0
  10. package/commitlint.config.js +4 -0
  11. package/eslint.config.js +36 -0
  12. package/package.json +63 -0
  13. package/src/api/library/fetchLibraryFile.ts +18 -0
  14. package/src/api/retry.ts +28 -0
  15. package/src/api/source/fetchSourceFile.ts +33 -0
  16. package/src/api/source/listSourceFiles.ts +13 -0
  17. package/src/api/source/putSourceFile.ts +14 -0
  18. package/src/api/source/schema.ts +10 -0
  19. package/src/api/utils.ts +52 -0
  20. package/src/bootstrap.sh +26 -0
  21. package/src/commander.ts +119 -0
  22. package/src/config/authConfig.ts +42 -0
  23. package/src/config/config.ts +109 -0
  24. package/src/config/envConfig.ts +23 -0
  25. package/src/config/fileConfig.ts +39 -0
  26. package/src/config/schema.ts +70 -0
  27. package/src/config/searchTemplatesConfig.ts +33 -0
  28. package/src/console/logger.ts +93 -0
  29. package/src/console/userPrompt.ts +16 -0
  30. package/src/errors/InvalidLoginResponseError.ts +14 -0
  31. package/src/errors/MissingConfigurationError.ts +14 -0
  32. package/src/errors/NostoError.ts +13 -0
  33. package/src/errors/NotNostoTemplateError.ts +15 -0
  34. package/src/errors/withErrorHandler.ts +50 -0
  35. package/src/exports.ts +8 -0
  36. package/src/filesystem/asserts/assertGitRepo.ts +19 -0
  37. package/src/filesystem/asserts/assertNostoTemplate.ts +34 -0
  38. package/src/filesystem/calculateTreeHash.ts +28 -0
  39. package/src/filesystem/esbuild.ts +37 -0
  40. package/src/filesystem/esbuildPlugins.ts +72 -0
  41. package/src/filesystem/filesystem.ts +40 -0
  42. package/src/filesystem/isIgnored.ts +65 -0
  43. package/src/filesystem/legacyUtils.ts +10 -0
  44. package/src/filesystem/loadLibrary.ts +31 -0
  45. package/src/filesystem/processInBatches.ts +38 -0
  46. package/src/filesystem/utils/getLoaderScript.ts +28 -0
  47. package/src/index.ts +3 -0
  48. package/src/modules/login.ts +87 -0
  49. package/src/modules/logout.ts +13 -0
  50. package/src/modules/search-templates/build.ts +61 -0
  51. package/src/modules/search-templates/dev.ts +50 -0
  52. package/src/modules/search-templates/pull.ts +89 -0
  53. package/src/modules/search-templates/push.ts +121 -0
  54. package/src/modules/setup.ts +96 -0
  55. package/src/modules/status.ts +71 -0
  56. package/src/utils/withSafeEnvironment.ts +22 -0
  57. package/test/api/fetchSourceFile.test.ts +30 -0
  58. package/test/api/putSourceFile.test.ts +34 -0
  59. package/test/api/retry.test.ts +102 -0
  60. package/test/api/utils.test.ts +27 -0
  61. package/test/commander.test.ts +271 -0
  62. package/test/config/envConfig.test.ts +62 -0
  63. package/test/config/fileConfig.test.ts +63 -0
  64. package/test/config/schema.test.ts +96 -0
  65. package/test/config/searchTemplatesConfig.test.ts +43 -0
  66. package/test/console/logger.test.ts +96 -0
  67. package/test/errors/withErrorHandler.test.ts +64 -0
  68. package/test/filesystem/filesystem.test.ts +53 -0
  69. package/test/filesystem/plugins.test.ts +35 -0
  70. package/test/index.test.ts +15 -0
  71. package/test/modules/search-templates/build.legacy.test.ts +74 -0
  72. package/test/modules/search-templates/build.modern.test.ts +33 -0
  73. package/test/modules/search-templates/dev.legacy.test.ts +75 -0
  74. package/test/modules/search-templates/dev.modern.test.ts +44 -0
  75. package/test/modules/search-templates/pull.test.ts +96 -0
  76. package/test/modules/search-templates/push.test.ts +109 -0
  77. package/test/modules/setup.test.ts +49 -0
  78. package/test/modules/status.test.ts +22 -0
  79. package/test/setup.ts +28 -0
  80. package/test/utils/generateEndpointMock.ts +60 -0
  81. package/test/utils/mockCommander.ts +22 -0
  82. package/test/utils/mockConfig.ts +37 -0
  83. package/test/utils/mockConsole.ts +65 -0
  84. package/test/utils/mockFileSystem.ts +52 -0
  85. package/test/utils/mockServer.ts +76 -0
  86. package/test/utils/mockStarterManifest.ts +42 -0
  87. package/tsconfig.json +20 -0
  88. package/vitest.config.ts +33 -0
@@ -0,0 +1,96 @@
1
+ import { describe, it } from "vitest"
2
+
3
+ import { pullSearchTemplate } from "#modules/search-templates/pull.ts"
4
+ import { setupMockConsole } from "#test/utils/mockConsole.ts"
5
+ import { setupMockFileSystem } from "#test/utils/mockFileSystem.ts"
6
+ import { mockFetchSourceFile, mockListSourceFiles, setupMockServer } from "#test/utils/mockServer.ts"
7
+
8
+ const fs = setupMockFileSystem()
9
+ const server = setupMockServer()
10
+ const terminal = setupMockConsole()
11
+
12
+ describe("Pull Search Template", () => {
13
+ it("should fetch all files when no paths specified", async () => {
14
+ mockListSourceFiles(server, {
15
+ response: [
16
+ { path: "index.js", size: 10 },
17
+ { path: "wizard.js", size: 25 }
18
+ ]
19
+ })
20
+ mockFetchSourceFile(server, {
21
+ path: "index.js",
22
+ response: "index.js content"
23
+ })
24
+ mockFetchSourceFile(server, {
25
+ path: "wizard.js",
26
+ response: "wizard.js content"
27
+ })
28
+
29
+ await pullSearchTemplate({ paths: [], force: true })
30
+ fs.expectFile("index.js").toContain('"index.js content"')
31
+ fs.expectFile("wizard.js").toContain('"wizard.js content"')
32
+ })
33
+
34
+ it("should filter by specified paths", async () => {
35
+ mockListSourceFiles(server, {
36
+ response: [
37
+ { path: "index.js", size: 10 },
38
+ { path: "wizard.js", size: 25 }
39
+ ]
40
+ })
41
+ mockFetchSourceFile(server, {
42
+ path: "index.js",
43
+ response: "index.js content"
44
+ })
45
+ mockFetchSourceFile(server, {
46
+ path: "wizard.js",
47
+ response: "wizard.js content"
48
+ })
49
+
50
+ await pullSearchTemplate({ paths: ["index.js"], force: true })
51
+
52
+ fs.expectFile("index.js").toContain('"index.js content"')
53
+ fs.expectFile("wizard.js").not.toExist()
54
+ })
55
+
56
+ it("should prompt for confirmation when files will be overridden", async () => {
57
+ fs.writeFile("index.js", "old content")
58
+ mockListSourceFiles(server, {
59
+ response: [{ path: "index.js", size: 10 }]
60
+ })
61
+ mockFetchSourceFile(server, {
62
+ path: "index.js",
63
+ response: "index.js content"
64
+ })
65
+
66
+ terminal.setUserResponse("N")
67
+ await pullSearchTemplate({ paths: [], force: false })
68
+ terminal.expect.user.toHaveBeenPromptedWith("Are you sure you want to override your local data? (y/N):")
69
+ })
70
+
71
+ it("should cancel operation when user declines override", async () => {
72
+ fs.writeFile("index.js", "old content")
73
+ mockListSourceFiles(server, {
74
+ response: [{ path: "index.js", size: 10 }]
75
+ })
76
+
77
+ terminal.setUserResponse("N")
78
+ await pullSearchTemplate({ paths: [], force: false })
79
+ fs.expectFile("/index.js").toContain("old content")
80
+ })
81
+
82
+ it("should proceed with download when user confirms override", async () => {
83
+ fs.writeFile("index.js", "old content")
84
+ mockListSourceFiles(server, {
85
+ response: [{ path: "index.js", size: 10 }]
86
+ })
87
+ mockFetchSourceFile(server, {
88
+ path: "index.js",
89
+ response: "index.js content"
90
+ })
91
+
92
+ terminal.setUserResponse("Y")
93
+ await pullSearchTemplate({ paths: [], force: false })
94
+ fs.expectFile("index.js").toContain('"index.js content"')
95
+ })
96
+ })
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { pushSearchTemplate } from "#modules/search-templates/push.ts"
4
+ import { setupMockConfig } from "#test/utils/mockConfig.ts"
5
+ import { setupMockConsole } from "#test/utils/mockConsole.ts"
6
+ import { setupMockFileSystem } from "#test/utils/mockFileSystem.ts"
7
+ import { mockPutSourceFile, setupMockServer } from "#test/utils/mockServer.ts"
8
+
9
+ const fs = setupMockFileSystem()
10
+ const server = setupMockServer()
11
+ const terminal = setupMockConsole()
12
+
13
+ describe("Push Search Template", () => {
14
+ it("should throw error if target folder does not exist", async () => {
15
+ setupMockConfig({ projectPath: "/nonexistent/path" })
16
+
17
+ await expect(pushSearchTemplate({ paths: [], force: true })).rejects.toThrow(
18
+ "ENOENT: no such file or directory, scandir '/nonexistent/path'"
19
+ )
20
+ })
21
+
22
+ it("should throw error if target path is not a directory", async () => {
23
+ fs.writeFile("file.txt", "file content")
24
+ setupMockConfig({
25
+ projectPath: "./file.txt"
26
+ })
27
+
28
+ await expect(pushSearchTemplate({ paths: [], force: true })).rejects.toThrow(
29
+ "ENOTDIR: not a directory, scandir '/file.txt'"
30
+ )
31
+ })
32
+
33
+ it("should exit early if no files to push", async () => {
34
+ fs.writeFile(".gitignore", "*.js")
35
+ fs.writeFile("index.js", "content with @nosto/preact")
36
+
37
+ await pushSearchTemplate({ paths: [], force: true })
38
+
39
+ expect(terminal.getSpy("warn")).toHaveBeenCalledWith("No files to push. Exiting.")
40
+ })
41
+
42
+ it("should process files and display summary", async () => {
43
+ fs.writeFile(".gitignore", "*.log")
44
+ fs.writeFile("index.js", "content with @nosto/preact")
45
+ fs.writeFile("file1.js", "file1 content")
46
+ fs.writeFile("file2.js", "file2 content")
47
+ fs.writeFile("ignored.log", "log content")
48
+
49
+ mockPutSourceFile(server, { path: "index.js" })
50
+ mockPutSourceFile(server, { path: "file1.js" })
51
+ mockPutSourceFile(server, { path: "file2.js" })
52
+
53
+ await pushSearchTemplate({ paths: [], force: true })
54
+
55
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Pushing template from: /")
56
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Found 4 files to push (3 source, 1 built, 1 ignored).")
57
+ })
58
+
59
+ it("should not ignore build directory", async () => {
60
+ fs.writeFile(".gitignore", "build/")
61
+ fs.writeFile("index.js", "content with @nosto/preact")
62
+ fs.writeFile("build/index.js", "build content")
63
+
64
+ mockPutSourceFile(server, { path: "index.js" })
65
+ mockPutSourceFile(server, { path: "build/index.js" })
66
+
67
+ await pushSearchTemplate({ paths: [], force: true })
68
+
69
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Pushing template from: /")
70
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Found 3 files to push (1 source, 2 built, 0 ignored).")
71
+ })
72
+
73
+ it("should cancel operation when user declines", async () => {
74
+ fs.writeFile("index.js", "content with @nosto/preact")
75
+ fs.writeFile("file1.js", "file1 content")
76
+
77
+ terminal.setUserResponse("N")
78
+
79
+ await pushSearchTemplate({ paths: [], force: false })
80
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Push operation cancelled by user.")
81
+ })
82
+
83
+ it("should filter files by specified paths", async () => {
84
+ fs.writeFile("index.js", "content with @nosto/preact")
85
+ fs.writeFile("file1.js", "file1 content")
86
+ fs.writeFile("file2.js", "file2 content")
87
+ fs.writeFile("file3.js", "file3 content")
88
+
89
+ mockPutSourceFile(server, { path: "index.js" })
90
+ mockPutSourceFile(server, { path: "file1.js" })
91
+ mockPutSourceFile(server, { path: "file3.js" })
92
+
93
+ await pushSearchTemplate({ paths: ["index.js", "file1.js", "file3.js"], force: true })
94
+ expect(terminal.getSpy("warn")).not.toHaveBeenCalledWith("No files to push. Exiting.")
95
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Pushing template from: /")
96
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Found 4 files to push (3 source, 1 built, 0 ignored).")
97
+ })
98
+
99
+ // TODO: Times out because of retry delay. Should be configurable in tests.
100
+ it.skip("should handle upload failures gracefully", async () => {
101
+ fs.writeFile("index.js", "content with @nosto/preact")
102
+ fs.writeFile("file1.js", "file1 content")
103
+
104
+ mockPutSourceFile(server, { path: "index.js", error: { status: 500, message: "Upload failed" } })
105
+ mockPutSourceFile(server, { path: "file1.js", error: { status: 500, message: "Upload failed" } })
106
+
107
+ await pushSearchTemplate({ paths: [], force: true })
108
+ })
109
+ })
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { printSetupHelp } from "#modules/setup.ts"
4
+ import { setupMockConsole } from "#test/utils/mockConsole.ts"
5
+ import { setupMockFileSystem } from "#test/utils/mockFileSystem.ts"
6
+
7
+ const fs = setupMockFileSystem()
8
+ const terminal = setupMockConsole()
9
+
10
+ describe("Setup Module", () => {
11
+ it("should print configuration help information", async () => {
12
+ fs.writeFile(".nosto.json", '{"apiKey": "test"}')
13
+
14
+ expect(async () => await printSetupHelp(".", {})).not.toThrow()
15
+ expect(terminal.getSpy("warn")).not.toHaveBeenCalledWith("Configuration file not found in project directory.")
16
+ })
17
+
18
+ it("should show warning when config file not found", async () => {
19
+ terminal.setUserResponse("N")
20
+
21
+ expect(async () => await printSetupHelp(".", {})).not.toThrow()
22
+ expect(terminal.getSpy("warn")).toHaveBeenCalledWith("Configuration file not found in project directory.")
23
+ })
24
+
25
+ it("should create config file when user confirms", async () => {
26
+ terminal.setUserResponse("Y")
27
+
28
+ await printSetupHelp(".", {})
29
+
30
+ terminal.expect.user.toHaveBeenPromptedWith("Would you like to create a configuration file? (Y/n):")
31
+ fs.expectFile(".nosto.json").toExist()
32
+ })
33
+
34
+ it("should not create config file when user declines", async () => {
35
+ terminal.setUserResponse("N")
36
+
37
+ await printSetupHelp(".", {})
38
+
39
+ fs.expectFile(".nosto.json").not.toExist()
40
+ })
41
+
42
+ it("should not prompt when config file already exists", async () => {
43
+ fs.writeFile(".nosto.json", '{"apiKey": "test"}')
44
+
45
+ await printSetupHelp(".", {})
46
+
47
+ terminal.expect.user.not.toHaveBeenPrompted()
48
+ })
49
+ })
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { printStatus } from "#modules/status.ts"
4
+ import { setupMockConfig } from "#test/utils/mockConfig.ts"
5
+ import { setupMockConsole } from "#test/utils/mockConsole.ts"
6
+
7
+ const terminal = setupMockConsole()
8
+
9
+ describe("Status Module", () => {
10
+ it("should print configuration status by default", async () => {
11
+ await expect(printStatus(".")).resolves.not.toThrow()
12
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Configuration is not valid:")
13
+ })
14
+
15
+ it("should indicate valid configuration", async () => {
16
+ setupMockConfig({ merchant: "test-merchant" })
17
+
18
+ await expect(printStatus(".")).resolves.not.toThrow()
19
+ expect(terminal.getSpy("info")).toHaveBeenCalledWith("Configuration seems to be valid:")
20
+ expect(terminal.getSpy("error")).toHaveBeenCalledWith("Some required configuration is missing\n")
21
+ })
22
+ })
package/test/setup.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { Volume } from "memfs"
2
+ import { beforeEach } from "vitest"
3
+ import { vi } from "vitest"
4
+
5
+ import { mockedConsoleIn, mockedConsoleOut } from "./utils/mockConsole.ts"
6
+
7
+ export const testVolume = Volume.fromJSON({}, "/")
8
+
9
+ vi.mock("fs", () => {
10
+ return {
11
+ default: testVolume
12
+ }
13
+ })
14
+ beforeEach(() => {
15
+ testVolume.reset()
16
+ process.chdir("/")
17
+ })
18
+
19
+ vi.mock("readline/promises", () => {
20
+ return {
21
+ createInterface: () => mockedConsoleIn.interface
22
+ }
23
+ })
24
+ vi.mock("#/console/logger.ts", () => mockedConsoleOut)
25
+
26
+ vi.mock("node:test", () => {
27
+ throw new Error("You seem to have accidentally imported node:test instead of vitest.")
28
+ })
@@ -0,0 +1,60 @@
1
+ import { type DefaultBodyType, http, HttpResponse, StrictRequest } from "msw"
2
+ import type { SetupServer } from "msw/node"
3
+
4
+ type HttpMethod = keyof typeof http
5
+
6
+ export type MockParams<ResponseT extends DefaultBodyType | void> =
7
+ | (ResponseT extends void ? object : { response: ResponseT })
8
+ | {
9
+ error: { status: number; message: string }
10
+ }
11
+
12
+ export const generateEndpointMock = (
13
+ server: SetupServer,
14
+ { method, path, ...params }: { method: HttpMethod; path: string } & MockParams<DefaultBodyType>
15
+ ) => {
16
+ let invocations: unknown[] = []
17
+
18
+ const handler = http[method](path, async ({ request }) => {
19
+ const status = (() => {
20
+ if ("error" in params) {
21
+ return params.error.status
22
+ } else if ("response" in params) {
23
+ return 200
24
+ }
25
+ return 204
26
+ })()
27
+
28
+ const returnedResponse = (() => {
29
+ if ("error" in params) {
30
+ return params.error
31
+ } else if ("response" in params) {
32
+ return params.response
33
+ }
34
+ return undefined
35
+ })()
36
+
37
+ const requestsWithBody = ["POST", "PUT", "PATCH"]
38
+ invocations.push(requestsWithBody.includes(request.method) ? await toBody(request) : {})
39
+
40
+ return HttpResponse.json(returnedResponse, { status })
41
+ })
42
+ server.use(handler)
43
+
44
+ return {
45
+ invocations,
46
+ clearInvocations: () => {
47
+ invocations = []
48
+ }
49
+ }
50
+ }
51
+
52
+ async function toBody(request: StrictRequest<DefaultBodyType>) {
53
+ const text = await request.text()
54
+ try {
55
+ return JSON.parse(text)
56
+ } catch {
57
+ // Throwing here would trigger actual API request in test
58
+ }
59
+ return text
60
+ }
@@ -0,0 +1,22 @@
1
+ import { expect } from "vitest"
2
+
3
+ import { runCLI } from "#commander.ts"
4
+
5
+ export function setupMockCommander() {
6
+ return {
7
+ async run(command: string) {
8
+ return runCLI(["/cwd", ...command.split(" ")])
9
+ },
10
+
11
+ expect(command: string) {
12
+ return {
13
+ toThrow: async () => {
14
+ await expect(this.run(command)).rejects.toThrow()
15
+ },
16
+ toResolve: async () => {
17
+ await expect(this.run(command)).resolves.not.toThrow()
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,37 @@
1
+ import { beforeEach, vi } from "vitest"
2
+
3
+ import * as config from "#config/config.ts"
4
+ import { type Config, SearchTemplatesConfigSchema } from "#config/schema.ts"
5
+
6
+ export function setupMockConfig(overrides: Partial<Config> = {}) {
7
+ const mockConfig: Config = {
8
+ apiKey: "test-api-key",
9
+ merchant: "test-merchant",
10
+ templatesEnv: "main",
11
+ apiUrl: "https://api.nosto.com",
12
+ logLevel: "info",
13
+ maxRequests: 15,
14
+ projectPath: "/",
15
+ libraryUrl: "https://library.nosto.com",
16
+ dryRun: false,
17
+ verbose: false,
18
+ auth: {
19
+ user: "",
20
+ token: "",
21
+ expiresAt: new Date(0)
22
+ },
23
+ searchTemplates: {
24
+ mode: "unknown",
25
+ data: SearchTemplatesConfigSchema.parse({})
26
+ },
27
+ ...overrides
28
+ }
29
+
30
+ // Connect immediately and on beforeEach so that this can be used at top level or in beforeEach
31
+ vi.spyOn(config, "getCachedConfig").mockReturnValue(mockConfig)
32
+ vi.spyOn(config, "getCachedSearchTemplatesConfig").mockReturnValue(mockConfig.searchTemplates.data)
33
+ beforeEach(() => {
34
+ vi.spyOn(config, "getCachedConfig").mockReturnValue(mockConfig)
35
+ vi.spyOn(config, "getCachedSearchTemplatesConfig").mockReturnValue(mockConfig.searchTemplates.data)
36
+ })
37
+ }
@@ -0,0 +1,65 @@
1
+ import { afterEach, expect, vi } from "vitest"
2
+
3
+ export const mockedConsoleIn = {
4
+ userResponse: "q",
5
+ recordedPrompts: [] as string[],
6
+ interface: {
7
+ question: (prompt: string) => {
8
+ mockedConsoleIn.recordedPrompts.push(prompt)
9
+ return mockedConsoleIn.userResponse
10
+ },
11
+ close: vi.fn()
12
+ }
13
+ }
14
+
15
+ export const mockedConsoleOut = {
16
+ Logger: {
17
+ context: {
18
+ logLevel: "info",
19
+ merchantId: "",
20
+ isDryRun: false
21
+ },
22
+ raw: vi.fn(),
23
+ debug: vi.fn(),
24
+ info: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn()
27
+ }
28
+ }
29
+
30
+ export function setupMockConsole() {
31
+ afterEach(() => {
32
+ mockedConsoleIn.recordedPrompts = []
33
+ })
34
+
35
+ return {
36
+ setUserResponse: (response: string) => {
37
+ mockedConsoleIn.userResponse = response
38
+ },
39
+ clearPrompts: () => {
40
+ mockedConsoleIn.recordedPrompts = []
41
+ },
42
+ getSpy: (method: Exclude<keyof typeof mockedConsoleOut.Logger, "context">) => {
43
+ return mockedConsoleOut.Logger[method]
44
+ },
45
+ expect: {
46
+ user: {
47
+ toHaveBeenPromptedWith: (prompt: string) => {
48
+ expect(
49
+ mockedConsoleIn.recordedPrompts.map(p => p.trim()),
50
+ "No matching prompt found.\nExpected:\n" +
51
+ prompt +
52
+ "\nGot: \n" +
53
+ mockedConsoleIn.recordedPrompts.map(p => p.trim()).join("\n") +
54
+ "\n\n"
55
+ ).toContain(prompt.trim())
56
+ },
57
+ not: {
58
+ toHaveBeenPrompted: () => {
59
+ expect(mockedConsoleIn.recordedPrompts.length, "Prompt was recorded when it was not expected").toBe(0)
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,52 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import { expect } from "vitest"
4
+
5
+ import { testVolume } from "#test/setup.ts"
6
+
7
+ export function setupMockFileSystem() {
8
+ const fs = testVolume
9
+ return {
10
+ writeFile: (targetFile: string, content: string) => {
11
+ const dir = path.join("/", targetFile.substring(0, targetFile.lastIndexOf("/")))
12
+ const filePath = path.join("/", targetFile)
13
+ // Ensure parent directory exists
14
+ if (dir && !fs.existsSync(dir)) {
15
+ fs.mkdirSync(dir, { recursive: true })
16
+ }
17
+ if (fs.existsSync(filePath)) {
18
+ fs.unlinkSync(filePath)
19
+ }
20
+ fs.writeFileSync(filePath, content)
21
+ },
22
+ writeFolder: (targetFolder: string) => {
23
+ const dir = path.join("/", targetFolder)
24
+ fs.mkdirSync(dir, { recursive: true })
25
+ },
26
+ expectFile: (targetFile: string) => {
27
+ const filePath = path.join("/", targetFile)
28
+ return makeFileMatcher(filePath)
29
+ }
30
+ }
31
+ }
32
+
33
+ export function makeFileMatcher(path: string) {
34
+ return {
35
+ toContain: (expectedContent: string) => {
36
+ const content = fs.readFileSync(path, "utf8")
37
+ expect(content, `File ${path} has content ${content}`).toEqual(expectedContent)
38
+ },
39
+ toExist: () => {
40
+ return expect(fs.existsSync(path), `File ${path} does not exist when it was expected to`).toBe(true)
41
+ },
42
+ not: {
43
+ toContain: (expectedContent: string) => {
44
+ const content = fs.readFileSync(path, "utf8")
45
+ expect(content, `File ${path} has content ${content}`).not.toEqual(expectedContent)
46
+ },
47
+ toExist: () => {
48
+ return expect(fs.existsSync(path), `File ${path} exists when it was not expected to`).toBe(false)
49
+ }
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,76 @@
1
+ import { SetupServer, setupServer } from "msw/node"
2
+ import { afterAll, afterEach, beforeAll, beforeEach } from "vitest"
3
+
4
+ import { fetchLibraryFile } from "#api/library/fetchLibraryFile.ts"
5
+ import { fetchSourceFile } from "#api/source/fetchSourceFile.ts"
6
+ import { listSourceFiles } from "#api/source/listSourceFiles.ts"
7
+ import { putSourceFile } from "#api/source/putSourceFile.ts"
8
+ import { getSourceUrl } from "#api/utils.ts"
9
+ import { generateEndpointMock, MockParams } from "#test/utils/generateEndpointMock.ts"
10
+
11
+ export const setupMockServer = () => {
12
+ const server = setupServer()
13
+ beforeAll(() => {
14
+ server.listen({ onUnhandledRequest: "error" })
15
+ })
16
+ beforeEach(() => {
17
+ mockFetchSourceFile(server, {
18
+ path: "build/hash",
19
+ error: { status: 404, message: "Not Found" }
20
+ })
21
+ mockPutSourceFile(server, {
22
+ path: "build/hash"
23
+ })
24
+ })
25
+ afterEach(() => {
26
+ server.resetHandlers()
27
+ })
28
+ afterAll(() => server.close())
29
+
30
+ return server
31
+ }
32
+
33
+ export function mockListSourceFiles(
34
+ server: SetupServer,
35
+ params: MockParams<Awaited<ReturnType<typeof listSourceFiles>>>
36
+ ) {
37
+ return generateEndpointMock(server, {
38
+ ...params,
39
+ method: "get",
40
+ path: getSourceUrl("source/{env}")
41
+ })
42
+ }
43
+
44
+ export function mockFetchSourceFile(
45
+ server: SetupServer,
46
+ params: { path: string } & MockParams<Awaited<ReturnType<typeof fetchSourceFile>>>
47
+ ) {
48
+ return generateEndpointMock(server, {
49
+ ...params,
50
+ method: "get",
51
+ path: getSourceUrl(`source/{env}/${params.path}`)
52
+ })
53
+ }
54
+
55
+ export function mockPutSourceFile(
56
+ server: SetupServer,
57
+ params: { path: string } & MockParams<Awaited<ReturnType<typeof putSourceFile>>>
58
+ ) {
59
+ const { path, ...mockParams } = params
60
+ return generateEndpointMock(server, {
61
+ method: "put",
62
+ path: getSourceUrl(`source/{env}/${path}`),
63
+ ...mockParams
64
+ })
65
+ }
66
+
67
+ export function mockFetchLibraryFile(
68
+ server: SetupServer,
69
+ params: { path: string } & MockParams<Awaited<ReturnType<typeof fetchLibraryFile>>>
70
+ ) {
71
+ return generateEndpointMock(server, {
72
+ ...params,
73
+ method: "get",
74
+ path: `https://library.nosto.com/${params.path}`
75
+ })
76
+ }
@@ -0,0 +1,42 @@
1
+ import path from "path"
2
+ import { vi } from "vitest"
3
+
4
+ import { SearchTemplatesConfig } from "#config/schema.ts"
5
+ import { makeConfig } from "#exports.ts"
6
+
7
+ import { setupMockFileSystem } from "./mockFileSystem.ts"
8
+
9
+ type Props = {
10
+ projectPath?: string
11
+ mockScript?: Partial<SearchTemplatesConfig>
12
+ }
13
+
14
+ export function setupMockStarterManifest({ projectPath, mockScript }: Props = {}) {
15
+ const fs = setupMockFileSystem()
16
+ const manifest = makeConfig({
17
+ onBuild: async () => {
18
+ console.log("Building...")
19
+ },
20
+ onBuildWatch: async () => {
21
+ console.log("Watching...")
22
+ },
23
+ ...mockScript
24
+ })
25
+
26
+ const filePath = path.join("/", projectPath ?? "", "nosto.config.ts")
27
+
28
+ vi.doMock(filePath, () => ({
29
+ default: manifest
30
+ }))
31
+
32
+ let scriptBuilder = `export default {`
33
+ Object.entries(manifest).forEach(([key, value]) => {
34
+ scriptBuilder += `
35
+ ${key}: ${value.toString()},
36
+ `
37
+ })
38
+ scriptBuilder += `}`
39
+
40
+ fs.writeFile(filePath, scriptBuilder)
41
+ return manifest
42
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "noEmit": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "allowImportingTsExtensions": true,
11
+ "rootDir": ".",
12
+ "resolveJsonModule": true,
13
+ "paths": {
14
+ "#*": ["./src/*"],
15
+ "#test/*": ["./test/*"]
16
+ }
17
+ },
18
+ "include": ["src/**/*", "test/**/*", "vitest.config.ts"],
19
+ "exclude": ["node_modules"]
20
+ }