diff --git a/package.json b/package.json index bf508c305..fd720a17a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "scripts": { "test": "npm run format && npm run build && vitest run", "covtest": "vitest run --coverage", - "prettier": "prettier \"{lib,test}/**/*.ts\" \"scripts/**/*.{mjs,cjs,js}\" \"examples/**/*.{ts,js,mjs}\"", + "prettier": "prettier \"{lib,test,scripts,examples}/**/*.{ts,js,cjs,mjs}\"", "format": "npm run prettier -- --write", "format:check": "npm run prettier -- -l", "clean": "rm -rf dist/*", diff --git a/test/consumer/consumer-contract-js.spec.ts b/test/consumer/consumer-contract-js.spec.ts new file mode 100644 index 000000000..51f4c304d --- /dev/null +++ b/test/consumer/consumer-contract-js.spec.ts @@ -0,0 +1,65 @@ +import { afterAll, beforeAll, describe, it } from "vitest"; +import { + buildPackedTarball, + copyDir, + installSdkTarball, + prepareFixtureDir, + removeDir, + runCommand, +} from "./runner"; + +const repoRoot = process.cwd(); +const tempDirs: string[] = []; +let tarballPath = ""; +const JS_TIMEOUT_MS = 180_000; + +async function runJsFixture( + fixtureName: "js-esm" | "js-cjs", + scripts: readonly string[], +): Promise { + const fixtureDir = await prepareFixtureDir(tempDirs, fixtureName); + await copyDir( + `${repoRoot}/test/consumer/fixtures/${fixtureName}`, + fixtureDir, + ); + installSdkTarball(fixtureDir, tarballPath); + + for (const script of scripts) { + runCommand(fixtureDir, "node", [script]); + } +} + +afterAll(async () => { + await Promise.allSettled(tempDirs.map(dir => removeDir(dir))); +}); + +describe("dual package JS consumer contract", () => { + beforeAll(async () => { + const packOutDir = await prepareFixtureDir(tempDirs, "js-pack"); + tarballPath = await buildPackedTarball(repoRoot, packOutDir); + }); + + it( + "runs JS ESM fixture with resolution and behavioral contract checks", + async () => { + await runJsFixture("js-esm", [ + "./resolution-load-check.mjs", + "./outbound-http-contract.mjs", + "./webhook-signature-contract.mjs", + ]); + }, + JS_TIMEOUT_MS, + ); + + it( + "runs JS CJS fixture resolution and behavioral contract checks", + async () => { + await runJsFixture("js-cjs", [ + "./resolution-load-check.cjs", + "./outbound-http-contract.cjs", + "./webhook-signature-contract.cjs", + ]); + }, + JS_TIMEOUT_MS, + ); +}); diff --git a/test/consumer/consumer-contract-packaging.spec.ts b/test/consumer/consumer-contract-packaging.spec.ts new file mode 100644 index 000000000..6d40b545d --- /dev/null +++ b/test/consumer/consumer-contract-packaging.spec.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { + buildPackedTarball, + listTarEntries, + prepareFixtureDir, + readTarPackageJson, + removeDir, +} from "./runner"; + +const repoRoot = process.cwd(); +const tempDirs: string[] = []; +let tarballPath = ""; + +afterAll(async () => { + await Promise.allSettled(tempDirs.map(dir => removeDir(dir))); +}); + +describe("dual package packaging contract", () => { + beforeAll(async () => { + const packOutDir = await prepareFixtureDir(tempDirs, "packaging-pack"); + tarballPath = await buildPackedTarball(repoRoot, packOutDir); + assert.equal(existsSync(tarballPath), true); + }); + + it("includes required export targets and metadata", async () => { + const entries = listTarEntries(repoRoot, tarballPath); + const requiredEntries = [ + "package/dist/index.js", + "package/dist/index.d.ts", + "package/dist/cjs/index.js", + "package/dist/cjs/index.d.ts", + "package/dist/cjs/package.json", + "package/package.json", + ]; + + for (const entry of requiredEntries) { + assert.equal( + entries.includes(entry), + true, + `${entry} was not found in tarball`, + ); + } + + const packagedJson = await readTarPackageJson(repoRoot, tarballPath); + assert.equal(packagedJson.main, "./dist/cjs/index.js"); + assert.equal(packagedJson.types, "./dist/index.d.ts"); + + assert.equal(packagedJson.exports["."].import.types, "./dist/index.d.ts"); + assert.equal(packagedJson.exports["."].import.default, "./dist/index.js"); + assert.equal( + packagedJson.exports["."].require.types, + "./dist/cjs/index.d.ts", + ); + assert.equal( + packagedJson.exports["."].require.default, + "./dist/cjs/index.js", + ); + }); + + it("does not include test artifacts", () => { + const entries = listTarEntries(repoRoot, tarballPath); + const testArtifacts = entries.filter(entry => + /(^|\/)tests?\//.test(entry.replace(/^package\//, "")), + ); + assert.deepEqual(testArtifacts, []); + }); +}); diff --git a/test/consumer/consumer-contract-ts5.spec.ts b/test/consumer/consumer-contract-ts5.spec.ts new file mode 100644 index 000000000..47bdaa78d --- /dev/null +++ b/test/consumer/consumer-contract-ts5.spec.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, it } from "vitest"; +import { buildPackedTarball, prepareFixtureDir, removeDir } from "./runner"; +import { runTsLane } from "./runner/ts-lane"; + +const repoRoot = process.cwd(); +const tempDirs: string[] = []; +let tarballPath = ""; +const TS5_VERSION = "5.9.3"; +const TS_TIMEOUT_MS = 240_000; + +afterAll(async () => { + await Promise.allSettled(tempDirs.map(dir => removeDir(dir))); +}); + +describe("dual package TS5 consumer contract", () => { + beforeAll(async () => { + const packOutDir = await prepareFixtureDir(tempDirs, "ts5-pack"); + tarballPath = await buildPackedTarball(repoRoot, packOutDir); + }); + + it( + `runs TS ESM modern lane (${TS5_VERSION})`, + async () => { + await runTsLane( + { repoRoot, tarballPath, tsVersion: TS5_VERSION, tempDirs }, + { + fixtureName: "ts5-esm-modern", + packageTemplateFile: "package.module.json", + tsconfigTemplateFile: "tsconfig.nodenext.json", + withRuntime: true, + }, + ); + }, + TS_TIMEOUT_MS, + ); + + it( + `runs TS CJS modern lane (${TS5_VERSION})`, + async () => { + await runTsLane( + { repoRoot, tarballPath, tsVersion: TS5_VERSION, tempDirs }, + { + fixtureName: "ts5-cjs-modern", + packageTemplateFile: "package.commonjs.json", + tsconfigTemplateFile: "tsconfig.nodenext.json", + withRuntime: true, + }, + ); + }, + TS_TIMEOUT_MS, + ); + + it( + `runs TS CJS legacy lane (${TS5_VERSION})`, + async () => { + await runTsLane( + { repoRoot, tarballPath, tsVersion: TS5_VERSION, tempDirs }, + { + fixtureName: "ts5-cjs-legacy", + packageTemplateFile: "package.commonjs.json", + tsconfigTemplateFile: "tsconfig.legacy-commonjs.json", + withRuntime: true, + }, + ); + }, + TS_TIMEOUT_MS, + ); + + it( + `runs TS bundler compile-only lane (${TS5_VERSION})`, + async () => { + await runTsLane( + { repoRoot, tarballPath, tsVersion: TS5_VERSION, tempDirs }, + { + fixtureName: "ts5-bundler", + packageTemplateFile: "package.module.json", + tsconfigTemplateFile: "tsconfig.bundler.json", + withRuntime: false, + }, + ); + }, + TS_TIMEOUT_MS, + ); +}); diff --git a/test/consumer/consumer-contract-ts6.spec.ts b/test/consumer/consumer-contract-ts6.spec.ts new file mode 100644 index 000000000..4828f0b45 --- /dev/null +++ b/test/consumer/consumer-contract-ts6.spec.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, it } from "vitest"; +import { buildPackedTarball, prepareFixtureDir, removeDir } from "./runner"; +import { runTsLane } from "./runner/ts-lane"; + +const repoRoot = process.cwd(); +const tempDirs: string[] = []; +let tarballPath = ""; +const TS6_VERSION = "6.0.3"; +const TS_TIMEOUT_MS = 240_000; + +afterAll(async () => { + await Promise.allSettled(tempDirs.map(dir => removeDir(dir))); +}); + +describe("dual package TS6 consumer contract", () => { + beforeAll(async () => { + const packOutDir = await prepareFixtureDir(tempDirs, "ts6-pack"); + tarballPath = await buildPackedTarball(repoRoot, packOutDir); + }); + + it( + `runs TS ESM modern lane (${TS6_VERSION})`, + async () => { + await runTsLane( + { repoRoot, tarballPath, tsVersion: TS6_VERSION, tempDirs }, + { + fixtureName: "ts6-esm-modern", + packageTemplateFile: "package.module.json", + tsconfigTemplateFile: "tsconfig.nodenext.json", + withRuntime: true, + }, + ); + }, + TS_TIMEOUT_MS, + ); + + it( + `runs TS CJS modern lane (${TS6_VERSION})`, + async () => { + await runTsLane( + { repoRoot, tarballPath, tsVersion: TS6_VERSION, tempDirs }, + { + fixtureName: "ts6-cjs-modern", + packageTemplateFile: "package.commonjs.json", + tsconfigTemplateFile: "tsconfig.nodenext.json", + withRuntime: true, + }, + ); + }, + TS_TIMEOUT_MS, + ); + + it( + `runs TS bundler compile-only lane (${TS6_VERSION})`, + async () => { + await runTsLane( + { repoRoot, tarballPath, tsVersion: TS6_VERSION, tempDirs }, + { + fixtureName: "ts6-bundler", + packageTemplateFile: "package.module.json", + tsconfigTemplateFile: "tsconfig.bundler.json", + withRuntime: false, + }, + ); + }, + TS_TIMEOUT_MS, + ); + + it( + `runs TS CJS node16 lane (${TS6_VERSION})`, + async () => { + await runTsLane( + { repoRoot, tarballPath, tsVersion: TS6_VERSION, tempDirs }, + { + fixtureName: "ts6-cjs-node16", + packageTemplateFile: "package.commonjs.json", + tsconfigTemplateFile: "tsconfig.node16.json", + withRuntime: true, + }, + ); + }, + TS_TIMEOUT_MS, + ); +}); diff --git a/test/consumer/fixtures/js-cjs/outbound-http-contract.cjs b/test/consumer/fixtures/js-cjs/outbound-http-contract.cjs new file mode 100644 index 000000000..972d195d7 --- /dev/null +++ b/test/consumer/fixtures/js-cjs/outbound-http-contract.cjs @@ -0,0 +1,113 @@ +const assert = require("node:assert/strict"); +const { createServer } = require("node:http"); +const { once } = require("node:events"); +const { LineBotClient } = require("@line/bot-sdk"); + +const requests = []; +const server = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const body = Buffer.concat(chunks).toString("utf8"); + requests.push({ + method: req.method, + url: req.url, + auth: req.headers.authorization, + body, + }); + + if (req.method === "GET" && req.url === "/v2/bot/info") { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + userId: "U1234567890", + basicId: "@testbot", + displayName: "Test Bot", + }), + ); + return; + } + + if ( + req.method === "GET" && + req.url === "/v2/bot/message/message-1/content/transcoding" + ) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "succeeded" })); + return; + } + + if (req.method === "POST" && req.url === "/module/auth/v1/token") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ access_token: "module-token" })); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "Not Found" })); +}); + +async function main() { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("failed to determine server address"); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + const client = LineBotClient.fromChannelAccessToken({ + channelAccessToken: "DUMMY_TOKEN", + apiBaseURL: baseUrl, + dataApiBaseURL: baseUrl, + managerBaseURL: baseUrl, + }); + + const botInfo = await client.getBotInfo(); + assert.equal(botInfo.displayName, "Test Bot"); + + const transcoding = + await client.getMessageContentTranscodingByMessageId("message-1"); + assert.equal(transcoding.status, "succeeded"); + + await client.attachModule( + "authorization_code", + "code-1", + "https://example.com/callback", + ); + + assert.equal(requests.length, 3); + + assert.deepEqual( + requests.map(request => ({ method: request.method, url: request.url })), + [ + { method: "GET", url: "/v2/bot/info" }, + { + method: "GET", + url: "/v2/bot/message/message-1/content/transcoding", + }, + { method: "POST", url: "/module/auth/v1/token" }, + ], + ); + + for (const request of requests) { + assert.equal(request.auth, "Bearer DUMMY_TOKEN"); + } + + const formBody = requests[2]?.body ?? ""; + assert.match(formBody, /grant_type=authorization_code/); + assert.match(formBody, /code=code-1/); + assert.match(formBody, /redirect_uri=https%3A%2F%2Fexample.com%2Fcallback/); + } finally { + server.close(); + } +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/consumer/fixtures/js-cjs/package.json b/test/consumer/fixtures/js-cjs/package.json new file mode 100644 index 000000000..7dbeb4f06 --- /dev/null +++ b/test/consumer/fixtures/js-cjs/package.json @@ -0,0 +1,5 @@ +{ + "name": "consumer-js-cjs", + "private": true, + "type": "commonjs" +} diff --git a/test/consumer/fixtures/js-cjs/resolution-load-check.cjs b/test/consumer/fixtures/js-cjs/resolution-load-check.cjs new file mode 100644 index 000000000..9fae3c8a8 --- /dev/null +++ b/test/consumer/fixtures/js-cjs/resolution-load-check.cjs @@ -0,0 +1,10 @@ +const assert = require("node:assert/strict"); +const sdk = require("@line/bot-sdk"); + +assert.equal(typeof sdk.middleware, "function"); +assert.equal(typeof sdk.LineBotClient.fromChannelAccessToken, "function"); + +const client = sdk.LineBotClient.fromChannelAccessToken({ + channelAccessToken: "DUMMY_TOKEN", +}); +assert.equal(typeof client.getBotInfo, "function"); diff --git a/test/consumer/fixtures/js-cjs/webhook-signature-contract.cjs b/test/consumer/fixtures/js-cjs/webhook-signature-contract.cjs new file mode 100644 index 000000000..d83031374 --- /dev/null +++ b/test/consumer/fixtures/js-cjs/webhook-signature-contract.cjs @@ -0,0 +1,73 @@ +const assert = require("node:assert/strict"); +const { createHmac } = require("node:crypto"); +const { createServer } = require("node:http"); +const { once } = require("node:events"); +const { middleware } = require("@line/bot-sdk"); + +const channelSecret = "channel-secret"; + +const makeSignature = body => { + return createHmac("sha256", channelSecret).update(body).digest("base64"); +}; + +const mw = middleware({ channelSecret }); + +const server = createServer((req, res) => { + mw(req, res, err => { + if (err) { + res.statusCode = 401; + res.end("invalid"); + return; + } + + assert.equal(Array.isArray(req.body.events), true); + res.statusCode = 200; + res.end("ok"); + }); +}); + +async function main() { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("failed to determine server address"); + } + + const url = `http://127.0.0.1:${address.port}/webhook`; + + const payload = JSON.stringify({ + destination: "UDEST", + events: [{ type: "message", message: { type: "text", text: "hi" } }], + }); + + try { + const okRes = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-line-signature": makeSignature(payload), + }, + body: payload, + }); + assert.equal(okRes.status, 200); + + const ngRes = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-line-signature": "invalid-signature", + }, + body: payload, + }); + assert.equal(ngRes.status, 401); + } finally { + server.close(); + } +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/consumer/fixtures/js-esm/outbound-http-contract.mjs b/test/consumer/fixtures/js-esm/outbound-http-contract.mjs new file mode 100644 index 000000000..462aeaca9 --- /dev/null +++ b/test/consumer/fixtures/js-esm/outbound-http-contract.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { LineBotClient } from "@line/bot-sdk"; + +const requests = []; +const server = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const body = Buffer.concat(chunks).toString("utf8"); + requests.push({ + method: req.method, + url: req.url, + auth: req.headers.authorization, + body, + }); + + if (req.method === "GET" && req.url === "/v2/bot/info") { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + userId: "U1234567890", + basicId: "@testbot", + displayName: "Test Bot", + }), + ); + return; + } + + if ( + req.method === "GET" && + req.url === "/v2/bot/message/message-1/content/transcoding" + ) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "succeeded" })); + return; + } + + if (req.method === "POST" && req.url === "/module/auth/v1/token") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ access_token: "module-token" })); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "Not Found" })); +}); + +server.listen(0, "127.0.0.1"); +await once(server, "listening"); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("failed to determine server address"); +} + +const baseUrl = `http://127.0.0.1:${address.port}`; + +try { + const client = LineBotClient.fromChannelAccessToken({ + channelAccessToken: "DUMMY_TOKEN", + apiBaseURL: baseUrl, + dataApiBaseURL: baseUrl, + managerBaseURL: baseUrl, + }); + + const botInfo = await client.getBotInfo(); + assert.equal(botInfo.displayName, "Test Bot"); + + const transcoding = + await client.getMessageContentTranscodingByMessageId("message-1"); + assert.equal(transcoding.status, "succeeded"); + + await client.attachModule( + "authorization_code", + "code-1", + "https://example.com/callback", + ); + + assert.equal(requests.length, 3); + + assert.deepEqual( + requests.map(request => ({ method: request.method, url: request.url })), + [ + { method: "GET", url: "/v2/bot/info" }, + { method: "GET", url: "/v2/bot/message/message-1/content/transcoding" }, + { method: "POST", url: "/module/auth/v1/token" }, + ], + ); + + for (const request of requests) { + assert.equal(request.auth, "Bearer DUMMY_TOKEN"); + } + + const formBody = requests[2]?.body ?? ""; + assert.match(formBody, /grant_type=authorization_code/); + assert.match(formBody, /code=code-1/); + assert.match(formBody, /redirect_uri=https%3A%2F%2Fexample.com%2Fcallback/); +} finally { + server.close(); +} diff --git a/test/consumer/fixtures/js-esm/package.json b/test/consumer/fixtures/js-esm/package.json new file mode 100644 index 000000000..d3042e00f --- /dev/null +++ b/test/consumer/fixtures/js-esm/package.json @@ -0,0 +1,5 @@ +{ + "name": "consumer-js-esm", + "private": true, + "type": "module" +} diff --git a/test/consumer/fixtures/js-esm/resolution-load-check.mjs b/test/consumer/fixtures/js-esm/resolution-load-check.mjs new file mode 100644 index 000000000..b6a865c2f --- /dev/null +++ b/test/consumer/fixtures/js-esm/resolution-load-check.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { LineBotClient, middleware } from "@line/bot-sdk"; + +assert.equal(typeof middleware, "function"); +assert.equal(typeof LineBotClient.fromChannelAccessToken, "function"); + +const client = LineBotClient.fromChannelAccessToken({ + channelAccessToken: "DUMMY_TOKEN", +}); +assert.equal(typeof client.getBotInfo, "function"); diff --git a/test/consumer/fixtures/js-esm/webhook-signature-contract.mjs b/test/consumer/fixtures/js-esm/webhook-signature-contract.mjs new file mode 100644 index 000000000..14c54689a --- /dev/null +++ b/test/consumer/fixtures/js-esm/webhook-signature-contract.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { middleware } from "@line/bot-sdk"; + +const channelSecret = "channel-secret"; + +const makeSignature = body => { + return createHmac("sha256", channelSecret).update(body).digest("base64"); +}; + +const mw = middleware({ channelSecret }); + +const server = createServer((req, res) => { + mw(req, res, err => { + if (err) { + res.statusCode = 401; + res.end("invalid"); + return; + } + + assert.equal(Array.isArray(req.body.events), true); + res.statusCode = 200; + res.end("ok"); + }); +}); + +server.listen(0, "127.0.0.1"); +await once(server, "listening"); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("failed to determine server address"); +} + +const url = `http://127.0.0.1:${address.port}/webhook`; + +const payload = JSON.stringify({ + destination: "UDEST", + events: [{ type: "message", message: { type: "text", text: "hi" } }], +}); + +try { + const okRes = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-line-signature": makeSignature(payload), + }, + body: payload, + }); + assert.equal(okRes.status, 200); + + const ngRes = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-line-signature": "invalid-signature", + }, + body: payload, + }); + assert.equal(ngRes.status, 401); +} finally { + server.close(); +} diff --git a/test/consumer/runner/index.ts b/test/consumer/runner/index.ts new file mode 100644 index 000000000..8a8b8a94a --- /dev/null +++ b/test/consumer/runner/index.ts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export interface CmdResult { + readonly stdout: string; + readonly stderr: string; +} + +export interface PackagedPackageJson { + readonly main: string; + readonly types: string; + readonly exports: { + readonly ".": { + readonly import: { + readonly types: string; + readonly default: string; + }; + readonly require: { + readonly types: string; + readonly default: string; + }; + }; + }; +} + +const COMMAND_TIMEOUT_MS = 45_000; +const NPM_TIMEOUT_MS = 60_000; + +export function runCommand( + cwd: string, + command: string, + args: string[], + env: Record = {}, + timeoutMs: number = COMMAND_TIMEOUT_MS, +): CmdResult { + const defaultEnv: Record = {}; + if (command === "npm") { + if (env.NPM_CONFIG_CACHE == null) { + defaultEnv.NPM_CONFIG_CACHE = path.join(os.tmpdir(), "bot-sdk-npm-cache"); + } + if (env.NPM_CONFIG_REGISTRY == null) { + defaultEnv.NPM_CONFIG_REGISTRY = "https://registry.npmjs.org"; + } + } + + const result = spawnSync(command, args, { + cwd, + env: { ...process.env, ...defaultEnv, ...env }, + encoding: "utf8", + timeout: timeoutMs, + }); + + if (result.error) { + const joined = [ + `$ ${command} ${args.join(" ")}`, + `cwd: ${cwd}`, + `timeoutMs: ${timeoutMs}`, + result.stdout, + result.stderr, + result.error.message, + ] + .filter(Boolean) + .join("\n"); + throw new Error(joined); + } + + if (result.status !== 0) { + const joined = [ + `$ ${command} ${args.join(" ")}`, + `cwd: ${cwd}`, + `timeoutMs: ${timeoutMs}`, + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join("\n"); + throw new Error(joined); + } + + return { + stdout: result.stdout, + stderr: result.stderr, + }; +} + +export async function createTempDir(prefix: string): Promise { + return mkdtemp(path.join(os.tmpdir(), prefix)); +} + +export async function removeDir(dirPath: string): Promise { + await rm(dirPath, { recursive: true, force: true }); +} + +export async function prepareFixtureDir( + tempDirs: string[], + name: string, +): Promise { + const dir = await createTempDir(`bot-sdk-consumer-${name}-`); + tempDirs.push(dir); + return dir; +} + +export async function copyDir(src: string, dst: string): Promise { + await cp(src, dst, { recursive: true }); +} + +export async function buildPackedTarball( + repoRoot: string, + outDir: string, +): Promise { + const { stdout } = runCommand( + repoRoot, + "npm", + ["pack", "--json", "--pack-destination", outDir], + {}, + NPM_TIMEOUT_MS, + ); + const parsed = JSON.parse(stdout) as Array<{ filename: string }>; + const filename = parsed[0]?.filename; + assert.ok(filename, "npm pack --json did not return filename"); + return path.join(outDir, filename); +} + +export async function readTarPackageJson( + repoRoot: string, + tarballPath: string, +): Promise { + const { stdout } = runCommand(repoRoot, "tar", [ + "-xOf", + tarballPath, + "package/package.json", + ]); + return JSON.parse(stdout) as PackagedPackageJson; +} + +export function listTarEntries( + repoRoot: string, + tarballPath: string, +): string[] { + const { stdout } = runCommand(repoRoot, "tar", ["-tf", tarballPath]); + return stdout + .split("\n") + .map(line => line.trim()) + .filter(line => line.length > 0); +} + +export async function materializeTsFixture(params: { + readonly repoRoot: string; + readonly outDir: string; + readonly packageTemplateFile: string; + readonly tsconfigTemplateFile?: string; + readonly tsVersion: string; +}): Promise { + const templateRoot = path.join( + params.repoRoot, + "test/consumer/templates/ts-modern", + ); + await copyDir(path.join(templateRoot, "files"), params.outDir); + + const packageTemplate = await readFile( + path.join(templateRoot, params.packageTemplateFile), + "utf8", + ); + const packageJson = packageTemplate.replace( + "__TS_VERSION__", + params.tsVersion, + ); + await writeFile( + path.join(params.outDir, "package.json"), + packageJson, + "utf8", + ); + + const tsconfig = await readFile( + path.join( + templateRoot, + params.tsconfigTemplateFile ?? "tsconfig.nodenext.json", + ), + "utf8", + ); + await writeFile(path.join(params.outDir, "tsconfig.json"), tsconfig, "utf8"); +} + +export function installSdkTarball( + projectDir: string, + tarballPath: string, +): void { + runCommand( + projectDir, + "npm", + [ + "install", + "--no-audit", + "--no-fund", + "--ignore-scripts", + "--prefer-offline", + "--no-save", + `file:${tarballPath}`, + ], + {}, + NPM_TIMEOUT_MS, + ); +} diff --git a/test/consumer/runner/ts-lane.ts b/test/consumer/runner/ts-lane.ts new file mode 100644 index 000000000..e8e35d0eb --- /dev/null +++ b/test/consumer/runner/ts-lane.ts @@ -0,0 +1,43 @@ +import { + installSdkTarball, + materializeTsFixture, + prepareFixtureDir, + runCommand, +} from "./index"; + +export interface TsLaneConfig { + readonly fixtureName: string; + readonly packageTemplateFile: "package.module.json" | "package.commonjs.json"; + readonly tsconfigTemplateFile: string; + readonly withRuntime: boolean; +} + +export interface TsLaneRunContext { + readonly repoRoot: string; + readonly tarballPath: string; + readonly tsVersion: string; + readonly tempDirs: string[]; +} + +export async function runTsLane( + context: TsLaneRunContext, + config: TsLaneConfig, +): Promise { + const fixtureDir = await prepareFixtureDir( + context.tempDirs, + config.fixtureName, + ); + await materializeTsFixture({ + repoRoot: context.repoRoot, + outDir: fixtureDir, + packageTemplateFile: config.packageTemplateFile, + tsconfigTemplateFile: config.tsconfigTemplateFile, + tsVersion: context.tsVersion, + }); + + installSdkTarball(fixtureDir, context.tarballPath); + runCommand(fixtureDir, "npm", ["run", "build"]); + if (config.withRuntime) { + runCommand(fixtureDir, "npm", ["run", "run:resolution"]); + } +} diff --git a/test/consumer/templates/ts-modern/files/src/resolution-load-check.ts b/test/consumer/templates/ts-modern/files/src/resolution-load-check.ts new file mode 100644 index 000000000..074ad257e --- /dev/null +++ b/test/consumer/templates/ts-modern/files/src/resolution-load-check.ts @@ -0,0 +1,10 @@ +import * as assert from "node:assert/strict"; +import { LineBotClient, middleware } from "@line/bot-sdk"; + +assert.equal(typeof middleware, "function"); +assert.equal(typeof LineBotClient.fromChannelAccessToken, "function"); + +const client = LineBotClient.fromChannelAccessToken({ + channelAccessToken: "DUMMY_TOKEN", +}); +assert.equal(typeof client.getBotInfo, "function"); diff --git a/test/consumer/templates/ts-modern/package.commonjs.json b/test/consumer/templates/ts-modern/package.commonjs.json new file mode 100644 index 000000000..d75ac6294 --- /dev/null +++ b/test/consumer/templates/ts-modern/package.commonjs.json @@ -0,0 +1,12 @@ +{ + "name": "consumer-ts-modern-cjs", + "private": true, + "type": "commonjs", + "devDependencies": { + "typescript": "__TS_VERSION__" + }, + "scripts": { + "build": "tsc -p ./tsconfig.json", + "run:resolution": "node ./dist/resolution-load-check.js" + } +} diff --git a/test/consumer/templates/ts-modern/package.module.json b/test/consumer/templates/ts-modern/package.module.json new file mode 100644 index 000000000..b934ce939 --- /dev/null +++ b/test/consumer/templates/ts-modern/package.module.json @@ -0,0 +1,12 @@ +{ + "name": "consumer-ts-modern-esm", + "private": true, + "type": "module", + "devDependencies": { + "typescript": "__TS_VERSION__" + }, + "scripts": { + "build": "tsc -p ./tsconfig.json", + "run:resolution": "node ./dist/resolution-load-check.js" + } +} diff --git a/test/consumer/templates/ts-modern/tsconfig.bundler.json b/test/consumer/templates/ts-modern/tsconfig.bundler.json new file mode 100644 index 000000000..f07f73f54 --- /dev/null +++ b/test/consumer/templates/ts-modern/tsconfig.bundler.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": false, + "types": [ + "node" + ], + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/test/consumer/templates/ts-modern/tsconfig.legacy-commonjs.json b/test/consumer/templates/ts-modern/tsconfig.legacy-commonjs.json new file mode 100644 index 000000000..0e77706d8 --- /dev/null +++ b/test/consumer/templates/ts-modern/tsconfig.legacy-commonjs.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "skipLibCheck": false, + "types": [ + "node" + ], + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/test/consumer/templates/ts-modern/tsconfig.node16.json b/test/consumer/templates/ts-modern/tsconfig.node16.json new file mode 100644 index 000000000..8e4d2825a --- /dev/null +++ b/test/consumer/templates/ts-modern/tsconfig.node16.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "skipLibCheck": false, + "types": [ + "node" + ], + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/test/consumer/templates/ts-modern/tsconfig.nodenext.json b/test/consumer/templates/ts-modern/tsconfig.nodenext.json new file mode 100644 index 000000000..075995082 --- /dev/null +++ b/test/consumer/templates/ts-modern/tsconfig.nodenext.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": false, + "types": [ + "node" + ], + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ] +}