import { join } from "path"; import fse from "fs-extra"; import execa from "execa"; import Builder, { ASSETS_DIR, DEFAULT_LAMBDA_CODE_DIR, API_LAMBDA_CODE_DIR, IMAGE_LAMBDA_CODE_DIR } from "../../src/build"; import { cleanupDir, removeNewLineChars } from "../test-utils"; import { OriginRequestDefaultHandlerManifest, OriginRequestApiHandlerManifest, OriginRequestImageHandlerManifest } from "../../src/types"; jest.mock("execa"); describe("Builder Tests", () => { const countLines = (text: string): number => { return text.split(/\r\n|\r|\n/).length; }; let fseRemoveSpy: jest.SpyInstance; let fseEmptyDirSpy: jest.SpyInstance; let defaultBuildManifest: OriginRequestDefaultHandlerManifest; let apiBuildManifest: OriginRequestApiHandlerManifest; let imageBuildManifest: OriginRequestImageHandlerManifest; const fixturePath = join(__dirname, "./simple-app-fixture"); const outputDir = join(fixturePath, ".test_sls_next_output"); describe("Regular build", () => { beforeEach(async () => { const mockExeca = execa as jest.Mock; mockExeca.mockResolvedValueOnce(); fseRemoveSpy = jest.spyOn(fse, "remove").mockImplementation(() => { return; }); fseEmptyDirSpy = jest.spyOn(fse, "emptyDir"); const builder = new Builder(fixturePath, outputDir, { domainRedirects: { "example.com": "https://www.example.com", "another.com": "https://www.another.com/", "www.other.com": "https://other.com" }, assetIgnorePatterns: ["**/public/ignored.txt"] }); await builder.build(); defaultBuildManifest = await fse.readJSON( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}/manifest.json`) ); apiBuildManifest = await fse.readJSON( join(outputDir, `${API_LAMBDA_CODE_DIR}/manifest.json`) ); imageBuildManifest = await fse.readJSON( join(outputDir, `${IMAGE_LAMBDA_CODE_DIR}/manifest.json`) ); }); afterEach(() => { fseEmptyDirSpy.mockRestore(); fseRemoveSpy.mockRestore(); return cleanupDir(outputDir); }); describe("Cleanup", () => { it(".next directory is emptied except for cache/ folder", () => { expect(fseRemoveSpy).toBeCalledWith( join(fixturePath, ".next/serverless") ); expect(fseRemoveSpy).toBeCalledWith( join(fixturePath, ".next/prerender-manifest.json") ); expect(fseRemoveSpy).not.toBeCalledWith( join(fixturePath, ".next/cache") ); }); it("output directory is cleanup before building", () => { expect(fseEmptyDirSpy).toBeCalledWith( expect.stringContaining( join(".test_sls_next_output", "default-lambda") ) ); expect(fseEmptyDirSpy).toBeCalledWith( expect.stringContaining(join(".test_sls_next_output", "api-lambda")) ); expect(fseEmptyDirSpy).toBeCalledWith( expect.stringContaining(join(".test_sls_next_output", "assets")) ); }); }); describe("Default Handler Manifest", () => { it("adds full manifest", () => { const { buildId, publicFiles, pages: { dynamic, ssr, html }, trailingSlash, domainRedirects } = defaultBuildManifest; expect(removeNewLineChars(buildId)).toEqual("test-build-id"); expect(dynamic).toEqual([ { regex: "^\\/blog(?:\\/([^\\/#\\?]+?))[\\/#\\?]?$", route: "/blog/[post]" }, { regex: "^\\/customers(?:\\/([^\\/#\\?]+?))[\\/#\\?]?$", route: "/customers/[customer]" }, { regex: "^\\/customers(?:\\/([^\\/#\\?]+?))\\/profile[\\/#\\?]?$", route: "/customers/[customer]/profile" }, { regex: "^\\/customers(?:\\/([^\\/#\\?]+?))(?:\\/([^\\/#\\?]+?))[\\/#\\?]?$", route: "/customers/[customer]/[post]" }, { regex: "^\\/customers(?:\\/((?:[^\\/#\\?]+?)(?:\\/(?:[^\\/#\\?]+?))*))?[\\/#\\?]?$", route: "/customers/[...catchAll]" }, { regex: "^\\/products(?:\\/((?:[^\\/#\\?]+?)(?:\\/(?:[^\\/#\\?]+?))*))?[\\/#\\?]?$", route: "/products/[[...optionalCatchAll]]" }, { regex: "^(?:\\/([^\\/#\\?]+?))[\\/#\\?]?$", route: "/[root]" } ]); expect(ssr).toEqual({ dynamic: { "/[root]": "pages/[root].js", "/customers/[customer]": "pages/customers/[customer].js", "/customers/[customer]/[post]": "pages/customers/[customer]/[post].js", "/customers/[customer]/profile": "pages/customers/[customer]/profile.js", "/customers/[...catchAll]": "pages/customers/[...catchAll].js", "/products/[[...optionalCatchAll]]": "pages/products/[[...optionalCatchAll]].js" }, nonDynamic: { "/customers/new": "pages/customers/new.js", "/": "pages/index.js", "/_app": "pages/_app.js", "/_document": "pages/_document.js", "/products": "pages/products/[[...optionalCatchAll]].js" } }); expect(html).toEqual({ nonDynamic: { "/404": "pages/404.html", "/terms": "pages/terms.html", "/about": "pages/about.html" }, dynamic: { "/blog/[post]": "pages/blog/[post].html" } }); expect(publicFiles).toEqual({ "/.well-known/test.txt": ".well-known/test.txt", "/favicon.ico": "favicon.ico", "/sub/image.png": "sub/image.png", "/sw.js": "sw.js" }); expect(trailingSlash).toBe(false); expect(domainRedirects).toEqual({ "example.com": "https://www.example.com", "another.com": "https://www.another.com", "www.other.com": "https://other.com" }); }); }); describe("API Handler Manifest", () => { it("adds full api manifest", () => { const { apis: { dynamic, nonDynamic } } = apiBuildManifest; expect(nonDynamic).toEqual({ "/api/customers": "pages/api/customers.js", "/api/customers/new": "pages/api/customers/new.js" }); expect(dynamic).toEqual([ { file: "pages/api/customers/[id].js", regex: expect.any(String) } ]); }); }); describe("Default Handler Artefact Files", () => { it("copies build files", async () => { expect.assertions(7); const files = await fse.readdir( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}`) ); const pages = await fse.readdir( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}/pages`) ); const customerPages = await fse.readdir( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}/pages/customers`) ); const productPages = await fse.readdir( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}/pages/products`) ); const apiDirExists = await fse.pathExists( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}/pages/api`) ); expect(files).toEqual( expect.arrayContaining([ "index.js", // there are more chunks but it should at least contain the entry point "manifest.json", "pages", "prerender-manifest.json", "routes-manifest.json" ]) ); // api pages should not be included in the default lambda expect(apiDirExists).toEqual(false); // HTML Prerendered pages or JSON static props files // should not be included in the default lambda expect(pages).not.toContain([ "blog.json", "index.json", "contact.json" ]); expect(pages).not.toContain([ "about.html", "terms.html", "contact.html", "index.html" ]); // Note: JS files used only for prerendering at build time (contact.js, index.js) are included since there are API routes expect(pages).toEqual([ "_error.js", "api-starting-page.js", "blog.js", "contact.js", "customers", "index.js", "products" ]); expect(customerPages).toEqual(["[...catchAll].js", "[post].js"]); expect(productPages).toEqual(["[[...optionalCatchAll]].js"]); }); it("default handler is not minified", async () => { const defaultHandler = await fse.readFile( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}/index.js`) ); expect(countLines(defaultHandler.toString())).toBeGreaterThan(10); // Arbitrary choice }); }); describe("API Handler Artefact Files", () => { it("copies build files", async () => { expect.assertions(2); const files = await fse.readdir( join(outputDir, `${API_LAMBDA_CODE_DIR}`) ); const pages = await fse.readdir( join(outputDir, `${API_LAMBDA_CODE_DIR}/pages`) ); expect(files).toEqual( expect.arrayContaining([ "index.js", "manifest.json", "pages", "routes-manifest.json" ]) ); expect(pages).toEqual(["api"]); }); it("API handler is not minified", async () => { const apiHandler = await fse.readFile( join(outputDir, `${API_LAMBDA_CODE_DIR}/index.js`) ); expect(countLines(apiHandler.toString())).toBeGreaterThan(10); // Arbitrary choice }); }); describe("Image Handler Manifest", () => { it("adds full image manifest", () => { expect.assertions(1); const { domainRedirects } = imageBuildManifest; expect(domainRedirects).toEqual({ "example.com": "https://www.example.com", "another.com": "https://www.another.com", "www.other.com": "https://other.com" }); }); }); describe("Image Handler Artefact Files", () => { it("copies build files", async () => { expect.assertions(1); const files = await fse.readdir( join(outputDir, `${IMAGE_LAMBDA_CODE_DIR}`) ); expect(files).toEqual( expect.arrayContaining([ "images-manifest.json", // Next.js default images manifest "index.js", "manifest.json", "node_modules", // Contains sharp node modules built for Lambda Node.js 12.x "routes-manifest.json" ]) ); }); it("image handler is not minified", async () => { const imageHandler = await fse.readFile( join(outputDir, `${IMAGE_LAMBDA_CODE_DIR}/index.js`) ); expect(countLines(imageHandler.toString())).toBeGreaterThan(10); // Arbitrary choice }); }); describe("Assets Artefact Files", () => { it("copies all assets", async () => { const publicFiles = await fse.readdir( join(outputDir, `${ASSETS_DIR}/public`) ); expect(publicFiles).toEqual([ ".well-known", "favicon.ico", "sub", "sw.js" ]); const staticFiles = await fse.readdir( join(outputDir, `${ASSETS_DIR}/static`) ); expect(staticFiles).toEqual(["donotdelete.txt"]); const nextDataFiles = await fse.readdir( join(outputDir, `${ASSETS_DIR}/_next/data/test-build-id`) ); expect(nextDataFiles).toEqual(["contact.json", "index.json"]); const nextStaticFiles = await fse.readdir( join(outputDir, `${ASSETS_DIR}/_next/static`) ); expect(nextStaticFiles).toEqual(["chunks"]); const staticPagesFiles = await fse.readdir( join(outputDir, `${ASSETS_DIR}/static-pages/test-build-id`) ); expect(staticPagesFiles).toEqual([ "about.html", "contact.html", "index.html", "terms.html" ]); // Check BUILD_ID file expect( ( await fse.readFile(join(outputDir, `${ASSETS_DIR}/BUILD_ID`)) ).toString() ).toEqual("test-build-id"); }); }); }); describe("Minified handlers build", () => { beforeEach(async () => { const mockExeca = execa as jest.Mock; mockExeca.mockResolvedValueOnce(); fseRemoveSpy = jest.spyOn(fse, "remove").mockImplementation(() => { return; }); fseEmptyDirSpy = jest.spyOn(fse, "emptyDir"); const builder = new Builder(fixturePath, outputDir, { minifyHandlers: true }); await builder.build(); }); it("default handler is minified", async () => { const defaultHandler = await fse.readFile( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}/index.js`) ); expect(countLines(defaultHandler.toString())).toEqual(2); }); it("API handler is minified", async () => { const apiHandler = await fse.readFile( join(outputDir, `${API_LAMBDA_CODE_DIR}/index.js`) ); expect(countLines(apiHandler.toString())).toEqual(2); }); it("Image handler is minified", async () => { const imageHandler = await fse.readFile( join(outputDir, `${IMAGE_LAMBDA_CODE_DIR}/index.js`) ); expect(countLines(imageHandler.toString())).toEqual(2); }); }); describe("Build edge cases", () => { let builder: Builder; beforeEach(async () => { const mockExeca = execa as jest.Mock; mockExeca.mockResolvedValueOnce(); fseRemoveSpy = jest.spyOn(fse, "remove").mockImplementation(() => { return; }); fseEmptyDirSpy = jest.spyOn(fse, "emptyDir"); }); it("fails build when there is a public/static directory that conflicts with static/* behavior", async () => { const fixturePath = join( __dirname, "./simple-app-fixture-public-static-error" ); builder = new Builder(fixturePath, outputDir, {}); await expect(builder.build()).rejects.toThrow( "You cannot have assets in the directory [public/static] as they conflict with the static/* CloudFront cache behavior. Please move these assets into another directory." ); }); }); describe("Custom handler", () => { let fseRemoveSpy: jest.SpyInstance; let fseEmptyDirSpy: jest.SpyInstance; let defaultBuildManifest: OriginRequestDefaultHandlerManifest; let apiBuildManifest: OriginRequestApiHandlerManifest; const fixturePath = join(__dirname, "./simple-app-fixture"); const outputDir = join(fixturePath, ".test_sls_next_output"); beforeEach(async () => { const mockExeca = execa as jest.Mock; mockExeca.mockResolvedValueOnce(); fseRemoveSpy = jest .spyOn(fse, "remove") .mockImplementation(() => undefined); fseEmptyDirSpy = jest.spyOn(fse, "emptyDir"); const builder = new Builder(fixturePath, outputDir, { handler: "testFile.js" }); await builder.build(); defaultBuildManifest = await fse.readJSON( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}/manifest.json`) ); apiBuildManifest = await fse.readJSON( join(outputDir, `${API_LAMBDA_CODE_DIR}/manifest.json`) ); }); afterEach(() => { fseEmptyDirSpy.mockRestore(); fseRemoveSpy.mockRestore(); return cleanupDir(outputDir); }); it("copies build files", async () => { expect.assertions(2); const defaultFiles = await fse.readdir( join(outputDir, `${DEFAULT_LAMBDA_CODE_DIR}`) ); expect(defaultFiles).toEqual( expect.arrayContaining([ "index.js", "manifest.json", "chunks", "pages", "prerender-manifest.json", "routes-manifest.json", "testFile.js", "webpack-api-runtime.js", "webpack-runtime.js" ]) ); const apiFiles = await fse.readdir( join(outputDir, `${API_LAMBDA_CODE_DIR}`) ); expect(apiFiles).toEqual( expect.arrayContaining([ "index.js", "manifest.json", "chunks", "pages", "routes-manifest.json", "testFile.js", "webpack-api-runtime.js", "webpack-runtime.js" ]) ); }); }); });