mirror of
https://github.com/glitternetwork/pinme.git
synced 2026-08-28 17:42:38 +08:00
test: add CI-backed verification suite and harden create flow
- add Vitest, coverage, CLI, package, and mutation test commands - add GitHub Actions verify, mutation, and audit workflows - document testing policy and contributor commands - replace curl template download with streamed retryable axios helper - improve background install exit-code handling and tracker error reasons
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
es2022: true,
|
||||
node: true,
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['prettier'],
|
||||
ignorePatterns: [
|
||||
'dist/',
|
||||
'node_modules/',
|
||||
'coverage/',
|
||||
'reports/',
|
||||
'.stryker-tmp/',
|
||||
],
|
||||
rules: {},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.mjs'],
|
||||
parser: 'espree',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 2 * * 1'
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify on Node ${{ matrix.node-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [16.13.0, 18.x, 20.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify
|
||||
run: npm run verify
|
||||
|
||||
mutation:
|
||||
name: Mutation tests
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run mutation tests
|
||||
run: npm run test:mutation
|
||||
|
||||
audit:
|
||||
name: Dependency audit
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Audit dependencies
|
||||
run: npm audit --audit-level=high
|
||||
+4
-2
@@ -2,11 +2,13 @@ node_modules
|
||||
/dist
|
||||
.DS_Store
|
||||
.env
|
||||
/test
|
||||
/test-output
|
||||
/temp
|
||||
/test-repro
|
||||
.catpaw
|
||||
.claude/settings.local.json
|
||||
chrome-test-1
|
||||
docs/*
|
||||
tests/*
|
||||
coverage/
|
||||
.stryker-tmp/
|
||||
reports/mutation/
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
PinMe is a zero-config CLI tool for deploying static sites to IPFS. Built with TypeScript, bundled with esbuild, published to npm as `pinme`. Users run commands like `pinme upload dist` to deploy frontends.
|
||||
|
||||
## Build & Dev
|
||||
|
||||
```bash
|
||||
npm run build # Production build (esbuild → dist/index.js)
|
||||
npm run dev # Dev build
|
||||
npm run test # Unit/integration tests (Vitest)
|
||||
npm run test:cli # Real CLI black-box tests against dist/index.js
|
||||
npm run verify # Full PR gate: lint, typecheck, tests, build, CLI, pack
|
||||
npm run test:mutation # Slow mutation tests (manual/nightly)
|
||||
```
|
||||
|
||||
Build uses `build.js` (esbuild), NOT `rollup.config.js` (legacy, unused). esbuild reads `.env` via dotenv at build time and injects env vars as `process.env.*` defines.
|
||||
|
||||
The output is a single CJS file at `dist/index.js` with a shebang, used as the `pinme` CLI binary.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `bin/index.ts` — CLI entry point, uses `commander` to register all commands
|
||||
- `bin/*.ts` — Individual command implementations (upload, save, create, bind, importCar, exportCar, delete, etc.)
|
||||
- `bin/utils/` — Shared utilities:
|
||||
- `config.ts` — `APP_CONFIG` singleton, all API base URLs and tuning knobs from env vars
|
||||
- `apiClient.ts` — Axios client factory (`createPinmeApiClient`, `createCarApiClient`)
|
||||
- `pinmeApi.ts` — High-level API wrappers (domain binding, wallet, CAR export, etc.)
|
||||
- `uploadToIpfs.ts` / `uploadToIpfsSplit.ts` — IPFS upload logic (single file vs chunked)
|
||||
- `webLogin.ts` — Auth token management (reads from `~/.pinme/`)
|
||||
- `domainValidator.ts` — Domain name validation and DNS vs subdomain detection
|
||||
- `cliError.ts` — Structured CLI error types
|
||||
- `bin/services/uploadService.ts` — Upload orchestration (hash encryption, URL generation)
|
||||
- `skills/` — Codex skill definitions for this project
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Auth: AppKey stored locally at `~/.pinme/`. Auth headers injected via `getAuthHeaders()` in `webLogin.ts`.
|
||||
- API clients: Always use `createPinmeApiClient()` or `createCarApiClient()` from `apiClient.ts`, never raw axios.
|
||||
- Token expiry: `pinmeApi.ts` has centralized token-expired detection (`isTokenExpired`) — all API calls should go through wrappers there.
|
||||
- Config: All env-driven config lives in `APP_CONFIG` (`config.ts`). Don't read `process.env` directly elsewhere.
|
||||
- The `save` command reads `pinme.toml` from project root for full-stack deploy (frontend + Cloudflare Worker + D1).
|
||||
|
||||
## Code Style
|
||||
|
||||
- Prettier: single quotes, trailing commas, 80 char width
|
||||
- TypeScript with `strict: false`, target ESNext, module ESNext
|
||||
- CLI output uses `chalk` for colors, `ora` for spinners, `inquirer` for prompts, `figlet` for banner
|
||||
@@ -10,8 +10,11 @@ PinMe is a zero-config CLI tool for deploying static sites to IPFS. Built with T
|
||||
|
||||
```bash
|
||||
npm run build # Production build (esbuild → dist/index.js)
|
||||
npm run dev # Dev build (NODE_ENV=development)
|
||||
node test/build-env.test.js # Run tests (node:test, no framework)
|
||||
npm run dev # Dev build
|
||||
npm run test # Unit/integration tests (Vitest)
|
||||
npm run test:cli # Real CLI black-box tests against dist/index.js
|
||||
npm run verify # Full PR gate: lint, typecheck, tests, build, CLI, pack
|
||||
npm run test:mutation # Slow mutation tests (manual/nightly)
|
||||
```
|
||||
|
||||
Build uses `build.js` (esbuild), NOT `rollup.config.js` (legacy, unused). esbuild reads `.env` via dotenv at build time and injects env vars as `process.env.*` defines.
|
||||
|
||||
@@ -26,6 +26,7 @@ Website: [https://pinme.eth.limo/](https://pinme.eth.limo/)
|
||||
- [Authentication and Account Commands](#authentication-and-account-commands)
|
||||
- [Static Uploads and IPFS Utilities](#static-uploads-and-ipfs-utilities)
|
||||
- [Command Reference](#command-reference)
|
||||
- [Development and Testing](#development-and-testing)
|
||||
- [Limits and Operational Notes](#limits-and-operational-notes)
|
||||
- [Examples](#examples)
|
||||
- [Support](#support)
|
||||
@@ -340,6 +341,24 @@ pinme rm <value>
|
||||
| `pinme list` / `pinme ls` | Show upload history |
|
||||
| `pinme help` | Show CLI help |
|
||||
|
||||
## Development and Testing
|
||||
|
||||
PinMe uses Vitest for unit/integration tests, real `dist/index.js` CLI smoke tests, npm package checks, and Stryker for slower mutation testing.
|
||||
|
||||
```bash
|
||||
npm run test # Unit and integration tests
|
||||
npm run test:coverage # Coverage gate for core modules
|
||||
npm run test:cli # Real CLI black-box tests
|
||||
npm run test:pack # npm pack/package-shape checks
|
||||
npm run verify # Full pull-request gate
|
||||
npm run test:mutation # Slow mutation tests for manual/nightly runs
|
||||
```
|
||||
|
||||
Tests must not call live PinMe/IPFS/CAR services. Use `nock`, local loopback servers, fixtures, and temporary HOME directories for API and CLI scenarios.
|
||||
|
||||
For the full testing policy, layout, and mutation-testing guidance, see
|
||||
[TESTING.md](TESTING.md).
|
||||
|
||||
## Limits and Operational Notes
|
||||
|
||||
- Default single-file upload limit: `100MB`
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
# PinMe Testing Guide
|
||||
|
||||
This document describes the test system for the PinMe CLI. It is intended for
|
||||
maintainers, contributors, and AI coding agents working on this repository.
|
||||
|
||||
## Goals
|
||||
|
||||
The test suite is designed to catch regressions across the full CLI lifecycle:
|
||||
|
||||
- TypeScript and lint checks.
|
||||
- Unit tests for pure utility logic.
|
||||
- Mocked integration tests for API wrappers and HTTP clients.
|
||||
- Real CLI black-box tests against the bundled `dist/index.js`.
|
||||
- npm package shape tests using `npm pack`.
|
||||
- Coverage thresholds for the core in-process modules.
|
||||
- Mutation testing for stricter confidence in critical logic.
|
||||
|
||||
Normal tests must not call real PinMe, IPFS, CAR, GitHub template, or other
|
||||
external services. Use `nock`, local fixture servers, temporary HOME
|
||||
directories, and test fixtures instead.
|
||||
|
||||
## Test Commands
|
||||
|
||||
Use these commands from the repository root.
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run test:coverage
|
||||
npm run test:cli
|
||||
npm run test:pack
|
||||
npm run verify
|
||||
npm run test:mutation
|
||||
```
|
||||
|
||||
Command meanings:
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `npm run lint` | Runs ESLint over TypeScript, MJS tests, and Vitest config files. |
|
||||
| `npm run typecheck` | Runs `tsc --noEmit` with `tsconfig.test.json`. |
|
||||
| `npm run test` | Runs unit, integration, source-regression, and legacy MJS tests. |
|
||||
| `npm run test:coverage` | Runs the same in-process tests with V8 coverage thresholds. |
|
||||
| `npm run build` | Bundles the CLI to `dist/index.js` with esbuild. |
|
||||
| `npm run test:cli` | Builds and runs black-box CLI tests against `node dist/index.js`. |
|
||||
| `npm run test:pack` | Builds, packs, installs, and verifies the npm package shape. |
|
||||
| `npm run verify` | Main PR gate: lint, typecheck, tests, coverage, build, CLI, and pack. |
|
||||
| `npm run test:mutation` | Slow strict check using Stryker mutation testing. |
|
||||
|
||||
For pull requests, `npm run verify` is the required local confidence check.
|
||||
Mutation testing is intentionally slower and is best run before risky releases,
|
||||
large refactors, or from scheduled/manual CI jobs.
|
||||
|
||||
## Test Layout
|
||||
|
||||
```text
|
||||
test/
|
||||
unit/ Pure utility and service tests.
|
||||
integration/ Mocked API/client integration tests.
|
||||
cli/ Real bundled CLI black-box tests.
|
||||
pack/ npm pack and installed-tarball tests.
|
||||
helpers/ Shared test helpers.
|
||||
setup/ Global test setup such as nock network guards.
|
||||
tests/ Existing MJS regression tests.
|
||||
```
|
||||
|
||||
Important files:
|
||||
|
||||
- `vitest.config.ts` controls the normal Vitest and coverage setup.
|
||||
- `vitest.mutation.config.ts` narrows Stryker's test set to unit/integration
|
||||
tests so mutation runs do not execute slow CLI/package black-box tests.
|
||||
- `stryker.config.json` lists the core files mutation testing is allowed to
|
||||
mutate.
|
||||
- `.github/workflows/ci.yml` runs the open-source CI gates.
|
||||
|
||||
## Coverage Policy
|
||||
|
||||
Coverage focuses on in-process core modules where V8 can reliably attribute
|
||||
executed code back to TypeScript source files.
|
||||
|
||||
Currently covered core modules include:
|
||||
|
||||
- `bin/utils/domainValidator.ts`
|
||||
- `bin/utils/config.ts`
|
||||
- `bin/utils/uploadLimits.ts`
|
||||
- `bin/utils/apiClient.ts`
|
||||
- `bin/utils/cliError.ts`
|
||||
- `bin/utils/history.ts`
|
||||
- `bin/utils/pinmeApi.ts`
|
||||
- `bin/utils/webLogin.ts`
|
||||
- `bin/services/uploadService.ts`
|
||||
|
||||
The configured minimums are:
|
||||
|
||||
| Metric | Minimum |
|
||||
| --- | ---: |
|
||||
| Statements | 85% |
|
||||
| Branches | 80% |
|
||||
| Functions | 85% |
|
||||
| Lines | 85% |
|
||||
|
||||
Command files are primarily covered by `test:cli`, which executes the bundled
|
||||
CLI as a subprocess. Subprocess coverage is not reliably attributed back to the
|
||||
original TypeScript command files, so those checks live in CLI tests rather than
|
||||
the V8 coverage gate.
|
||||
|
||||
## Mutation Policy
|
||||
|
||||
Mutation testing is configured for critical utility/API/service logic rather
|
||||
than the entire repository. This keeps the signal high and avoids very slow or
|
||||
flaky mutants in CLI subprocess tests.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:mutation
|
||||
```
|
||||
|
||||
Current target:
|
||||
|
||||
- Overall mutation score should stay above the configured Stryker break
|
||||
threshold.
|
||||
- The practical project target is `80+`.
|
||||
- If the score drops, first inspect survivors in:
|
||||
|
||||
```text
|
||||
reports/mutation/index.html
|
||||
```
|
||||
|
||||
The report directory is generated output and should not be committed.
|
||||
|
||||
## Network and Filesystem Rules
|
||||
|
||||
Tests must be hermetic by default.
|
||||
|
||||
- `test/setup/nock.ts` disables accidental external network access for normal
|
||||
unit and integration tests.
|
||||
- API behavior should be mocked with `nock`.
|
||||
- CLI success-path tests may use local HTTP servers bound to `127.0.0.1`.
|
||||
- Tests that touch user auth must isolate `HOME` and `~/.pinme` with temporary
|
||||
directories.
|
||||
- Do not depend on the real user's PinMe credentials.
|
||||
- Do not write persistent files outside temporary directories unless the test is
|
||||
explicitly verifying package/build output inside the repository.
|
||||
|
||||
In restricted sandboxes, CLI success-path tests can fail with:
|
||||
|
||||
```text
|
||||
listen EPERM: operation not permitted 127.0.0.1
|
||||
```
|
||||
|
||||
That means the sandbox blocked local mock servers. Re-run the command in an
|
||||
environment that permits local loopback listeners.
|
||||
|
||||
## What To Test When Changing Code
|
||||
|
||||
Use the narrowest command while developing, then run the full gate before
|
||||
opening a PR.
|
||||
|
||||
| Change area | Recommended tests |
|
||||
| --- | --- |
|
||||
| Pure utility logic | `npm run test -- test/unit/<file>.test.ts` |
|
||||
| API wrappers or Axios client behavior | `npm run test -- test/integration` |
|
||||
| Auth file handling | `npm run test -- test/unit/webLogin.test.ts` |
|
||||
| Upload URL/result formatting | `npm run test -- test/unit/uploadService.test.ts` |
|
||||
| CLI command behavior | `npm run build && vitest run test/cli` |
|
||||
| Build or package metadata | `npm run test:pack` |
|
||||
| Release confidence | `npm run verify && npm run test:mutation` |
|
||||
|
||||
Before finishing a non-trivial change, run:
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Before finishing a risky core-logic change, also run:
|
||||
|
||||
```bash
|
||||
npm run test:mutation
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
Choose the test layer based on what can catch the bug most directly:
|
||||
|
||||
- Put pure function and local formatting tests in `test/unit/`.
|
||||
- Put mocked API behavior in `test/integration/`.
|
||||
- Put user-visible command behavior in `test/cli/`.
|
||||
- Put publish/install behavior in `test/pack/`.
|
||||
- Put old MJS regression tests in `tests/` only when matching existing
|
||||
regression-test style.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Prefer testing public or intentionally exported helper behavior.
|
||||
- Keep external services mocked.
|
||||
- Use realistic fixtures for CLI tests.
|
||||
- Assert both success output and failure messages when user behavior matters.
|
||||
- Avoid brittle snapshots for colorful CLI output; normalize ANSI output when
|
||||
necessary.
|
||||
- If an internal function is hard to test, prefer a small pure helper export
|
||||
over changing runtime behavior.
|
||||
|
||||
## CI Expectations
|
||||
|
||||
The GitHub Actions workflow keeps normal contribution feedback fast:
|
||||
|
||||
- Pull requests run `npm run verify` across supported Node versions.
|
||||
- Mutation testing is scheduled/manual rather than required for every PR.
|
||||
- Audit checks are non-blocking so dependency advisories can be triaged without
|
||||
preventing unrelated contributions.
|
||||
|
||||
Generated directories such as `coverage/`, `reports/`, and `.stryker-tmp/`
|
||||
should remain ignored and uncommitted.
|
||||
+23
-30
@@ -4,7 +4,6 @@ import path from 'path';
|
||||
import inquirer from 'inquirer';
|
||||
import axios from 'axios';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { execSync } from 'child_process';
|
||||
import { getAuthHeaders } from './utils/webLogin';
|
||||
import { startBackgroundInstall } from './utils/installProjectDependencies';
|
||||
import {
|
||||
@@ -18,6 +17,7 @@ import { uploadPath } from './services/uploadService';
|
||||
import { printHighlightedUrl } from './utils/urlDisplay';
|
||||
import { patchPrebuiltFrontendDist } from './utils/prebuiltDistConfig';
|
||||
import { getValidatedWorkerMetadataContent } from './utils/workerMetadata';
|
||||
import { downloadFileWithRetries, getDownloadErrorMessage } from './utils/downloadFile';
|
||||
import tracker, { getTrackErrorReason } from './utils/tracker';
|
||||
import {
|
||||
TRACK_EVENTS,
|
||||
@@ -212,37 +212,30 @@ export default async function createCmd(options: CreateOptions): Promise<void> {
|
||||
console.log(chalk.blue('\n2. Downloading template from repository...'));
|
||||
const zipPath = path.join(PROJECT_DIR, 'template.zip');
|
||||
const extractDir = path.join(PROJECT_DIR, `.pinme-template-${Date.now()}`);
|
||||
const templateZipUrl = getTemplateZipUrl(TEMPLATE_BRANCH);
|
||||
let downloadSuccess = false;
|
||||
const templateZipUrl =
|
||||
process.env.PINME_TEMPLATE_ZIP_URL || getTemplateZipUrl(TEMPLATE_BRANCH);
|
||||
console.log(chalk.gray(` Template branch: ${TEMPLATE_BRANCH}`));
|
||||
|
||||
// Retry download up to 3 times
|
||||
for (let attempt = 1; attempt <= 3 && !downloadSuccess; attempt++) {
|
||||
try {
|
||||
console.log(chalk.gray(` Download attempt ${attempt}/3...`));
|
||||
|
||||
// Download zip file
|
||||
execSync(`curl -L --retry 3 --retry-delay 2 -o "${zipPath}" "${templateZipUrl}"`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
// Check if file was downloaded successfully
|
||||
if (!fs.existsSync(zipPath) || fs.statSync(zipPath).size < 100) {
|
||||
throw new Error('Downloaded file is too small or empty');
|
||||
}
|
||||
|
||||
downloadSuccess = true;
|
||||
} catch (downloadError: any) {
|
||||
console.log(chalk.yellow(` Attempt ${attempt} failed: ${downloadError.message}`));
|
||||
if (fs.existsSync(zipPath)) {
|
||||
fs.removeSync(zipPath);
|
||||
}
|
||||
if (attempt === 3) {
|
||||
throw new Error(`Failed to download template after 3 attempts: ${downloadError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadResult = await downloadFileWithRetries(templateZipUrl, zipPath, {
|
||||
attempts: 3,
|
||||
retryDelayMs: 2000,
|
||||
minBytes: 100,
|
||||
onAttempt: (attempt, attempts) => {
|
||||
console.log(chalk.gray(` Download attempt ${attempt}/${attempts}...`));
|
||||
},
|
||||
onAttemptFailure: (attempt, error) => {
|
||||
console.log(chalk.yellow(` Attempt ${attempt} failed: ${getDownloadErrorMessage(error)}`));
|
||||
},
|
||||
});
|
||||
console.log(chalk.green(` Template archive downloaded (${downloadResult.bytes} bytes)`));
|
||||
} catch (error: any) {
|
||||
throw createCommandError('template download', `download "${templateZipUrl}" to "${zipPath}"`, error, [
|
||||
'Check your network connection and retry `pinme create`.',
|
||||
`Verify that the template branch exists: ${TEMPLATE_BRANCH}`,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
fs.ensureDirSync(extractDir);
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import axios from 'axios';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const pipelineAsync = promisify(pipeline);
|
||||
|
||||
export interface DownloadFileWithRetriesOptions {
|
||||
attempts?: number;
|
||||
retryDelayMs?: number;
|
||||
minBytes?: number;
|
||||
timeoutMs?: number;
|
||||
request?: (
|
||||
url: string,
|
||||
options: { timeoutMs: number; headers: Record<string, string> },
|
||||
) => Promise<{ data: NodeJS.ReadableStream }>;
|
||||
onAttempt?: (attempt: number, attempts: number) => void;
|
||||
onAttemptFailure?: (attempt: number, error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DownloadFileResult {
|
||||
attempts: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function getDownloadErrorMessage(error: any): string {
|
||||
const status = error?.response?.status;
|
||||
const statusText = error?.response?.statusText;
|
||||
|
||||
if (status) {
|
||||
return `HTTP ${status}${statusText ? ` ${statusText}` : ''}`;
|
||||
}
|
||||
|
||||
if (error?.code && error?.message) {
|
||||
return `${error.code}: ${error.message}`;
|
||||
}
|
||||
|
||||
return error?.message || String(error);
|
||||
}
|
||||
|
||||
async function requestDownload(
|
||||
url: string,
|
||||
options: { timeoutMs: number; headers: Record<string, string> },
|
||||
): Promise<{ data: NodeJS.ReadableStream }> {
|
||||
return axios.get(url, {
|
||||
responseType: 'stream',
|
||||
timeout: options.timeoutMs,
|
||||
headers: options.headers,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadFileWithRetries(
|
||||
url: string,
|
||||
destinationPath: string,
|
||||
options: DownloadFileWithRetriesOptions = {},
|
||||
): Promise<DownloadFileResult> {
|
||||
const attempts = options.attempts ?? 3;
|
||||
const retryDelayMs = options.retryDelayMs ?? 2000;
|
||||
const minBytes = options.minBytes ?? 1;
|
||||
const timeoutMs = options.timeoutMs ?? 120000;
|
||||
const request = options.request ?? requestDownload;
|
||||
const headers = {
|
||||
'User-Agent': 'pinme-cli',
|
||||
};
|
||||
let lastError: unknown;
|
||||
|
||||
fs.ensureDirSync(path.dirname(destinationPath));
|
||||
fs.removeSync(destinationPath);
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const tempPath = `${destinationPath}.download-${process.pid}-${Date.now()}-${attempt}.tmp`;
|
||||
|
||||
try {
|
||||
options.onAttempt?.(attempt, attempts);
|
||||
|
||||
const response = await request(url, { timeoutMs, headers });
|
||||
|
||||
await pipelineAsync(response.data, createWriteStream(tempPath));
|
||||
|
||||
const bytes = fs.statSync(tempPath).size;
|
||||
if (bytes < minBytes) {
|
||||
throw new Error(`Downloaded file is too small (${bytes} bytes; expected at least ${minBytes} bytes).`);
|
||||
}
|
||||
|
||||
fs.moveSync(tempPath, destinationPath, { overwrite: true });
|
||||
return { attempts: attempt, bytes };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
fs.removeSync(tempPath);
|
||||
options.onAttemptFailure?.(attempt, error);
|
||||
|
||||
if (attempt < attempts) {
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to download ${url} after ${attempts} attempts: ${getDownloadErrorMessage(lastError)}`);
|
||||
}
|
||||
@@ -244,5 +244,6 @@ export {
|
||||
saveUploadHistory,
|
||||
getUploadHistory,
|
||||
displayUploadHistory,
|
||||
clearUploadHistory
|
||||
clearUploadHistory,
|
||||
formatHistoryUrl,
|
||||
};
|
||||
|
||||
@@ -204,14 +204,59 @@ function runInstall(
|
||||
});
|
||||
}
|
||||
|
||||
function quoteForShell(value: string): string {
|
||||
type ShellPlatform = NodeJS.Platform | 'posix';
|
||||
|
||||
interface BackgroundInstallCommand {
|
||||
shellBin: string;
|
||||
shellArgs: string[];
|
||||
installCommand: string;
|
||||
}
|
||||
|
||||
function quoteForShell(value: string, platform: ShellPlatform = process.platform): string {
|
||||
// tmp paths / project paths can contain spaces; wrap everything in quotes.
|
||||
if (process.platform === 'win32') {
|
||||
if (platform === 'win32') {
|
||||
return `"${value}"`;
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function buildBackgroundInstallCommand(
|
||||
script: InstallScript,
|
||||
logPath: string,
|
||||
exitCodePath: string,
|
||||
platform: ShellPlatform = process.platform,
|
||||
): BackgroundInstallCommand {
|
||||
const args = getInstallArgs(script);
|
||||
const npmCmd = platform === 'win32' ? 'npm' : getNpmCommand();
|
||||
const installCommand = `${npmCmd} ${args.map((arg) => quoteForShell(arg, platform)).join(' ')}`;
|
||||
const qLog = quoteForShell(logPath, platform);
|
||||
const qExit = quoteForShell(exitCodePath, platform);
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Enable delayed expansion so !errorlevel! is evaluated after npm exits.
|
||||
return {
|
||||
shellBin: process.env.ComSpec || 'cmd.exe',
|
||||
shellArgs: [
|
||||
'/d',
|
||||
'/s',
|
||||
'/v:on',
|
||||
'/c',
|
||||
`${installCommand} >> ${qLog} 2>&1 & echo !errorlevel! > ${qExit}`,
|
||||
],
|
||||
installCommand,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shellBin: '/bin/sh',
|
||||
shellArgs: [
|
||||
'-c',
|
||||
`${installCommand} >> ${qLog} 2>&1; printf '%s' "$?" > ${qExit}`,
|
||||
],
|
||||
installCommand,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a dependency install in a detached background process and return
|
||||
* immediately. The child keeps running after the current CLI process exits,
|
||||
@@ -228,7 +273,6 @@ function quoteForShell(value: string): string {
|
||||
*/
|
||||
export function startBackgroundInstall(cwd: string): { logPath: string } {
|
||||
const script = getInstallScript(cwd, 'auto');
|
||||
const args = getInstallArgs(script);
|
||||
const logPath = path.join(cwd, INSTALL_LOG_FILE);
|
||||
const exitCodePath = path.join(cwd, INSTALL_EXITCODE_FILE);
|
||||
const pidPath = path.join(cwd, INSTALL_PID_FILE);
|
||||
@@ -238,24 +282,7 @@ export function startBackgroundInstall(cwd: string): { logPath: string } {
|
||||
fs.removeSync(pidPath);
|
||||
fs.writeFileSync(logPath, `[pinme] ${new Date().toISOString()} starting "npm ${script}"\n`);
|
||||
|
||||
const npmCmd = process.platform === 'win32' ? 'npm' : getNpmCommand();
|
||||
const installCmd = `${npmCmd} ${args.map(quoteForShell).join(' ')}`;
|
||||
const qLog = quoteForShell(logPath);
|
||||
const qExit = quoteForShell(exitCodePath);
|
||||
|
||||
let shellBin: string;
|
||||
let shellArgs: string[];
|
||||
if (process.platform === 'win32') {
|
||||
// cmd.exe: chain with `&`, capture %errorlevel%.
|
||||
const command = `${installCmd} >> ${qLog} 2>&1 & echo %errorlevel% > ${qExit}`;
|
||||
shellBin = process.env.ComSpec || 'cmd.exe';
|
||||
shellArgs = ['/d', '/s', '/c', command];
|
||||
} else {
|
||||
// POSIX sh: `$?` after the install is the install's exit code.
|
||||
const command = `${installCmd} >> ${qLog} 2>&1; printf '%s' "$?" > ${qExit}`;
|
||||
shellBin = '/bin/sh';
|
||||
shellArgs = ['-c', command];
|
||||
}
|
||||
const { shellBin, shellArgs } = buildBackgroundInstallCommand(script, logPath, exitCodePath);
|
||||
|
||||
const child = spawn(shellBin, shellArgs, {
|
||||
cwd,
|
||||
|
||||
+88
-7
@@ -82,14 +82,95 @@ function sanitizeTrackValue(
|
||||
}
|
||||
|
||||
export function getTrackErrorReason(error: unknown): string {
|
||||
const candidate =
|
||||
(error as any)?.response?.data?.msg ||
|
||||
(error as any)?.response?.data?.message ||
|
||||
(error as any)?.message ||
|
||||
(error as any)?.toString?.() ||
|
||||
'unknown_error';
|
||||
return sanitizeTrackValue(resolveErrorReason(error), TRACK_REASON_LIMIT) || 'unknown_error';
|
||||
}
|
||||
|
||||
return sanitizeTrackValue(candidate) || 'unknown_error';
|
||||
function responseDataMessage(data: any): string | undefined {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data?.msg
|
||||
|| data?.message
|
||||
|| data?.error
|
||||
|| data?.data?.msg
|
||||
|| data?.data?.message
|
||||
|| data?.data?.error
|
||||
|| data?.errors?.[0]?.message;
|
||||
}
|
||||
|
||||
function normalizeReason(candidate: unknown, status?: number): string | undefined {
|
||||
const value = sanitizeTrackValue(candidate, TRACK_REASON_LIMIT);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lower = value.toLowerCase();
|
||||
|
||||
if (/^\s*<!doctype\s+html/i.test(value) || /^\s*<html[\s>]/i.test(value)) {
|
||||
return 'api_returned_html';
|
||||
}
|
||||
|
||||
const statusMatch = lower.match(/request failed with status code (\d{3})/);
|
||||
const statusCode = status || (statusMatch ? Number(statusMatch[1]) : undefined);
|
||||
if (statusCode === 520) {
|
||||
return 'gateway_520';
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('token authentication failed') ||
|
||||
lower.includes('invalid token') ||
|
||||
lower.includes('token expired') ||
|
||||
lower.includes('authentication failed') ||
|
||||
lower.includes('auth failed') ||
|
||||
lower.includes('unauthorized')
|
||||
) {
|
||||
return 'token_auth_failed';
|
||||
}
|
||||
|
||||
if (lower.includes('auth not set') || lower.includes('please login first')) {
|
||||
return 'auth_not_set';
|
||||
}
|
||||
|
||||
if (lower.includes('login timeout')) {
|
||||
return 'login_timeout';
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveErrorReason(error: unknown, seen = new Set<unknown>()): string {
|
||||
if (error === undefined || error === null || seen.has(error)) {
|
||||
return 'unknown_error';
|
||||
}
|
||||
seen.add(error);
|
||||
|
||||
const maybeError = error as any;
|
||||
const responseData = maybeError?.response?.data;
|
||||
const responseStatus = maybeError?.response?.status;
|
||||
|
||||
const responseReason = normalizeReason(
|
||||
responseDataMessage(responseData),
|
||||
responseStatus,
|
||||
);
|
||||
if (responseReason) {
|
||||
return responseReason;
|
||||
}
|
||||
|
||||
if (maybeError?.cause) {
|
||||
const causeReason = resolveErrorReason(maybeError.cause, seen);
|
||||
if (causeReason && causeReason !== 'unknown_error') {
|
||||
return causeReason;
|
||||
}
|
||||
}
|
||||
|
||||
const messageReason = normalizeReason(maybeError?.message, responseStatus);
|
||||
if (messageReason) {
|
||||
return messageReason;
|
||||
}
|
||||
|
||||
const stringReason = normalizeReason(maybeError?.toString?.(), responseStatus);
|
||||
return stringReason || 'unknown_error';
|
||||
}
|
||||
|
||||
function resolveTrackAction(event: string, data: TrackData = {}): string {
|
||||
|
||||
@@ -8,6 +8,8 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { APP_CONFIG } from './config';
|
||||
|
||||
/* c8 ignore start -- Browser callback/login UI is covered by manual/e2e flows; token helpers below are unit-tested. */
|
||||
/* Stryker disable all: Browser opening is OS integration; token helpers below are mutation-tested. */
|
||||
// Cross-platform browser opener
|
||||
function openBrowser(url: string): void {
|
||||
const platform = process.platform;
|
||||
@@ -28,6 +30,7 @@ function openBrowser(url: string): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
/* Stryker restore all */
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.pinme');
|
||||
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
|
||||
@@ -54,6 +57,7 @@ const DEFAULT_OPTIONS: Required<LoginOptions> = {
|
||||
callbackPath: '/cli/callback',
|
||||
};
|
||||
|
||||
/* Stryker disable all: Interactive browser login and callback HTML are covered by manual/e2e flows. */
|
||||
export class WebLoginManager {
|
||||
private config: Required<LoginOptions>;
|
||||
private server: http.Server | null = null;
|
||||
@@ -518,6 +522,9 @@ export class WebLoginManager {
|
||||
|
||||
// Export singleton
|
||||
export const webLoginManager = new WebLoginManager();
|
||||
/* Stryker restore all */
|
||||
|
||||
/* c8 ignore stop */
|
||||
|
||||
// Legacy interface
|
||||
export function setAuthToken(combined: string): AuthConfig {
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
# Create Prebuilt Dist Config Replacement Design
|
||||
|
||||
## Context
|
||||
|
||||
`pinme create` is optimized for first-time users by downloading a template that
|
||||
already ships with `dist-worker/` and `frontend/dist/`. This avoids requiring a
|
||||
fresh `npm install` or Vite build before the first deploy.
|
||||
|
||||
The current create flow updates source files such as `pinme.toml`,
|
||||
`frontend/.env`, `frontend/src/utils/config.ts`, and backend metadata after the
|
||||
project is created. However, the first upload uses the prebuilt `frontend/dist`
|
||||
directory, so values compiled into the bundle still contain empty defaults. This
|
||||
breaks first-run frontend configuration such as the Worker API URL and auth
|
||||
client config.
|
||||
|
||||
## Goal
|
||||
|
||||
Patch only the prebuilt frontend dist used by the initial `pinme create`
|
||||
deployment after Pinme receives the latest project configuration from
|
||||
`/create_worker`, and before the dist is uploaded to IPFS.
|
||||
|
||||
Normal `pinme save`, `pinme update-web`, and developer builds remain unchanged.
|
||||
After dependencies are installed, those commands rebuild from source and do not
|
||||
need dist replacement.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add a general runtime config system.
|
||||
- Do not rebuild the frontend during `pinme create`.
|
||||
- Do not change `pinme save` or `pinme update-web` behavior.
|
||||
- Do not use empty-string replacement as the primary mechanism.
|
||||
|
||||
## Template Changes
|
||||
|
||||
The template should compile stable placeholders into `frontend/dist`.
|
||||
|
||||
Recommended placeholders:
|
||||
|
||||
```text
|
||||
__PINME_VITE_API_URL__
|
||||
__PINME_AUTH_API_KEY__
|
||||
__PINME_AUTH_DOMAIN__
|
||||
__PINME_AUTH_PROJECT_ID__
|
||||
__PINME_TENANT_ID__
|
||||
```
|
||||
|
||||
Source defaults should use those placeholders:
|
||||
|
||||
- `frontend/.env.example`
|
||||
- `VITE_API_URL="__PINME_VITE_API_URL__"`
|
||||
- `frontend/src/utils/config.ts`
|
||||
- `auth_api_key: "__PINME_AUTH_API_KEY__"`
|
||||
- `auth_domain: "__PINME_AUTH_DOMAIN__"`
|
||||
- `auth_project_id: "__PINME_AUTH_PROJECT_ID__"`
|
||||
- `tenant_id: "__PINME_TENANT_ID__"`
|
||||
|
||||
`frontend/src/pages/Auth/index.tsx` should read Firebase auth configuration from
|
||||
`public_client_config` instead of only `import.meta.env.VITE_FIREBASE_*`.
|
||||
Otherwise, the CLI can update `frontend/src/utils/config.ts`, but the prebuilt
|
||||
auth bundle will still stay unconfigured.
|
||||
|
||||
The template release process should rebuild `frontend/dist` after these source
|
||||
changes. A quick release check should confirm the placeholders are present in
|
||||
the built dist before publishing the template.
|
||||
|
||||
## CLI Changes
|
||||
|
||||
Add a create-only helper in `bin/create.ts`, or a small helper local to the
|
||||
create command if it keeps the scope clearer:
|
||||
|
||||
```ts
|
||||
patchPrebuiltFrontendDist(frontendDistDir, workerData)
|
||||
```
|
||||
|
||||
Call it in `createCmd` after the source config files are updated and before:
|
||||
|
||||
```ts
|
||||
uploadPath(frontendDistDir, { action: 'project_create', ... })
|
||||
```
|
||||
|
||||
Replacement mapping:
|
||||
|
||||
```text
|
||||
__PINME_VITE_API_URL__ <- workerData.api_domain
|
||||
__PINME_AUTH_API_KEY__ <- workerData.public_client_config.auth_api_key
|
||||
__PINME_AUTH_DOMAIN__ <- workerData.public_client_config.auth_domain
|
||||
__PINME_AUTH_PROJECT_ID__ <- workerData.public_client_config.auth_project_id
|
||||
__PINME_TENANT_ID__ <- workerData.public_client_config.tenant_id
|
||||
```
|
||||
|
||||
If `public_client_config` is absent, replace auth placeholders with empty
|
||||
strings so the Auth Demo clearly remains disabled. `workerData.api_domain` is
|
||||
required; if it is missing, fail create before upload with a clear config error.
|
||||
|
||||
## File Scanning
|
||||
|
||||
Patch text files under `frontend/dist`, including:
|
||||
|
||||
- `.html`
|
||||
- `.js`
|
||||
- `.css`
|
||||
- `.json`
|
||||
- `.map`
|
||||
|
||||
Binary files and unrelated assets should be skipped.
|
||||
|
||||
The helper should count replacements and print a concise success line such as:
|
||||
|
||||
```text
|
||||
Patched prebuilt frontend dist config
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Before upload, validate:
|
||||
|
||||
- `__PINME_VITE_API_URL__` no longer exists in `frontend/dist`.
|
||||
- If `public_client_config` was returned, none of the auth placeholders remain.
|
||||
- If no matching API URL placeholder was found, fail create with a message that
|
||||
the template prebuilt dist is missing required Pinme config placeholders.
|
||||
|
||||
This prevents uploading a known-bad first-run frontend.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use the existing CLI error style with `createConfigError` for local template
|
||||
problems. The error should explain:
|
||||
|
||||
- the prebuilt dist is missing required placeholders;
|
||||
- the template should be rebuilt from the placeholder-enabled source;
|
||||
- users can run `npm install`, `npm run build:frontend`, and `pinme save` as a
|
||||
recovery path after create, if needed.
|
||||
|
||||
## Tests
|
||||
|
||||
Add focused unit coverage for the replacement helper where practical:
|
||||
|
||||
- API URL placeholder is replaced.
|
||||
- Auth placeholders are replaced when `public_client_config` exists.
|
||||
- Auth placeholders are replaced with empty strings when auth config is absent.
|
||||
- Missing API URL placeholder fails validation.
|
||||
- Binary or unsupported files are skipped.
|
||||
|
||||
Add a template-side build check or script-level assertion that the published
|
||||
`frontend/dist` includes `__PINME_VITE_API_URL__`.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Update template source defaults and Auth config source.
|
||||
2. Rebuild template `frontend/dist`.
|
||||
3. Update CLI `pinme create` to patch the prebuilt dist before first upload.
|
||||
4. Verify `pinme create <name>` uploads a frontend whose dist contains the real
|
||||
Worker API URL and auth config values.
|
||||
Generated
+3886
-11
File diff suppressed because it is too large
Load Diff
+21
-3
@@ -8,7 +8,15 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev": "NODE_ENV=development node build.js",
|
||||
"dev": "node build.js",
|
||||
"lint": "eslint --ext .ts,.mjs bin test tests vitest.config.ts vitest.mutation.config.ts",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.test.json",
|
||||
"test": "vitest run test/unit test/integration test/login-tracking-source.test.mjs tests",
|
||||
"test:coverage": "vitest run --coverage test/unit test/integration test/login-tracking-source.test.mjs tests",
|
||||
"test:cli": "npm run build && vitest run test/cli",
|
||||
"test:pack": "npm run build && vitest run test/pack",
|
||||
"test:mutation": "stryker run",
|
||||
"verify": "npm run lint && npm run typecheck && npm run test && npm run test:coverage && npm run build && npm run test:cli && npm run test:pack",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"bin": {
|
||||
@@ -17,7 +25,6 @@
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
"keywords": [
|
||||
"ipfs",
|
||||
"cli",
|
||||
@@ -49,7 +56,12 @@
|
||||
"@rollup/plugin-commonjs": "22.0.2",
|
||||
"@rollup/plugin-json": "4.1.0",
|
||||
"@rollup/plugin-node-resolve": "14.1.0",
|
||||
"@stryker-mutator/core": "^7.3.0",
|
||||
"@stryker-mutator/vitest-runner": "^7.3.0",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"dotenv": "16.5.0",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint": "8.33.0",
|
||||
@@ -57,10 +69,16 @@
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"execa": "^7.2.0",
|
||||
"fast-check": "^3.23.2",
|
||||
"nock": "^13.5.6",
|
||||
"prettier": "2.8.3",
|
||||
"rollup": "2.79.2",
|
||||
"rollup-plugin-copy": "3.5.0",
|
||||
"rollup-plugin-terser": "7.0.2"
|
||||
"rollup-plugin-terser": "7.0.2",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.13.0"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
"testRunner": "vitest",
|
||||
"coverageAnalysis": "perTest",
|
||||
"mutate": [
|
||||
"bin/utils/domainValidator.ts",
|
||||
"bin/utils/config.ts",
|
||||
"bin/utils/uploadLimits.ts",
|
||||
"bin/utils/apiClient.ts",
|
||||
"bin/utils/cliError.ts",
|
||||
"bin/utils/history.ts",
|
||||
"bin/utils/pinmeApi.ts",
|
||||
"bin/utils/webLogin.ts",
|
||||
"bin/services/uploadService.ts"
|
||||
],
|
||||
"vitest": {
|
||||
"configFile": "vitest.mutation.config.ts"
|
||||
},
|
||||
"thresholds": {
|
||||
"high": 80,
|
||||
"low": 60,
|
||||
"break": 76
|
||||
},
|
||||
"reporters": ["progress", "clear-text", "html"],
|
||||
"htmlReporter": {
|
||||
"fileName": "reports/mutation/index.html"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import path from 'path';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
createTempHome,
|
||||
repoRoot,
|
||||
runCli,
|
||||
writeAuthConfig,
|
||||
} from '../helpers/cliRunner';
|
||||
|
||||
describe('pinme CLI', () => {
|
||||
test('prints help for --help', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['--help'], { home: temp.home });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Usage: pinme');
|
||||
expect(result.stdout).toContain('upload');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('prints package version for --version', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['--version'], { home: temp.home });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('shows banner and help with no arguments', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli([], { home: temp.home });
|
||||
const output = `${result.stdout}\n${result.stderr}`;
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(output).toContain('Usage: pinme');
|
||||
expect(output).toContain('Examples:');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('list reports empty upload history in isolated HOME', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['list'], { home: temp.home });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('No upload history found.');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('upload exits before network work when auth is missing', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['upload', 'test/fixtures/site'], {
|
||||
home: temp.home,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Please login first. Run: pinme login');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('bind exits before network work when auth is missing', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(
|
||||
['bind', 'test/fixtures/site', '--domain', 'demo'],
|
||||
{ home: temp.home },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Please login first');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('bind rejects malformed DNS domains before API calls', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(
|
||||
['bind', 'test/fixtures/site', '--domain', '-bad.com', '--dns'],
|
||||
{ home: temp.home },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Labels cannot start or end with hyphens');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('save exits before project work when auth is missing', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['save'], {
|
||||
home: temp.home,
|
||||
cwd: path.join(repoRoot, 'test', 'fixtures', 'site'),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Auth not set. Run: pinme login');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import path from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
createTempHome,
|
||||
repoRoot,
|
||||
runCli,
|
||||
writeAuthConfig,
|
||||
} from '../helpers/cliRunner';
|
||||
|
||||
function outputOf(result: { stdout: string; stderr: string }): string {
|
||||
return `${result.stdout}\n${result.stderr}`;
|
||||
}
|
||||
|
||||
describe('pinme command-level guards', () => {
|
||||
test('create requires a local login before project creation', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['create', 'demo-project'], {
|
||||
home: temp.home,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(outputOf(result)).toContain('Auth not set. Run: pinme login');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('import requires a local login before reading CAR input', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['import', 'test/fixtures/site'], {
|
||||
home: temp.home,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(outputOf(result)).toContain('Please login first. Run: pinme login');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('import rejects nonexistent paths before upload', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(['import', 'does-not-exist.car'], {
|
||||
home: temp.home,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(outputOf(result)).toContain('path does-not-exist.car does not exist');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('export rejects invalid CID arguments before CAR API calls', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['export', 'not-a-cid', '--output', temp.home], {
|
||||
home: temp.home,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(outputOf(result)).toContain('Invalid CID format');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('export rejects output paths that are files before CAR API calls', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const filePath = path.join(temp.home, 'not-a-directory');
|
||||
await writeFile(filePath, 'file');
|
||||
const result = await runCli(
|
||||
['export', 'bafyvalidcid', '--output', filePath],
|
||||
{ home: temp.home },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(outputOf(result)).toContain('exists but is not a directory');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('delete requires a local login before resolving project deletion', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(['delete', 'demo-project', '--force'], {
|
||||
home: temp.home,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(outputOf(result)).toContain('Auth not set. Run: pinme login');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('delete requires a project name when no pinme.toml is present', async () => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(['delete', '--force'], {
|
||||
home: temp.home,
|
||||
cwd: path.join(repoRoot, 'test', 'fixtures', 'site'),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(outputOf(result)).toContain('Cannot find project name');
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
['save', ['save'], 'Auth not set. Run: pinme login'],
|
||||
['update-web', ['update-web'], 'Auth not set. Run: pinme login'],
|
||||
['update-worker', ['update-worker'], 'Auth not set. Run: pinme login'],
|
||||
['update-db', ['update-db'], 'Auth not set. Run: pinme login'],
|
||||
])('%s requires login before project work', async (_name, args, message) => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
const result = await runCli(args, {
|
||||
home: temp.home,
|
||||
cwd: path.join(repoRoot, 'test', 'fixtures', 'site'),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(outputOf(result)).toContain(message);
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
['save', ['save'], 'pinme.toml` not found'],
|
||||
['update-web', ['update-web'], 'pinme.toml` not found'],
|
||||
['update-worker', ['update-worker'], 'pinme.toml` not found'],
|
||||
['update-db', ['update-db'], 'pinme.toml` not found'],
|
||||
])(
|
||||
'%s validates project config before build or deploy work',
|
||||
async (_name, args, message) => {
|
||||
const temp = await createTempHome();
|
||||
try {
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(args, {
|
||||
home: temp.home,
|
||||
cwd: path.join(repoRoot, 'test', 'fixtures', 'site'),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(outputOf(result)).toContain(message);
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,959 @@
|
||||
import path from 'path';
|
||||
import { chmod, mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildCliWithEnv,
|
||||
createTempHome,
|
||||
repoRoot,
|
||||
runCli,
|
||||
writeAuthConfig,
|
||||
} from '../helpers/cliRunner';
|
||||
import { startLocalHttpServer } from '../helpers/localHttpServer';
|
||||
|
||||
function outputOf(result: { stdout: string; stderr: string }): string {
|
||||
return `${result.stdout}\n${result.stderr}`;
|
||||
}
|
||||
|
||||
function createTemplateZipBuffer(): Buffer {
|
||||
const zip = new AdmZip();
|
||||
const root = 'pinme-worker-template-main';
|
||||
|
||||
zip.addFile(
|
||||
`${root}/package.json`,
|
||||
Buffer.from('{"scripts":{"build":"echo build"}}\n'),
|
||||
);
|
||||
zip.addFile(
|
||||
`${root}/pinme.toml`,
|
||||
Buffer.from('project_name = "template-project"\n'),
|
||||
);
|
||||
zip.addFile(
|
||||
`${root}/backend/wrangler.toml`,
|
||||
Buffer.from('name = "template-project"\n'),
|
||||
);
|
||||
zip.addFile(
|
||||
`${root}/dist-worker/worker.js`,
|
||||
Buffer.from('export default { fetch() { return new Response("ok"); } };\n'),
|
||||
);
|
||||
zip.addFile(
|
||||
`${root}/db/001_init.sql`,
|
||||
Buffer.from('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY);\n'),
|
||||
);
|
||||
zip.addFile(
|
||||
`${root}/frontend/.env.example`,
|
||||
Buffer.from('VITE_API_URL=https://your-project.example\n'),
|
||||
);
|
||||
zip.addFile(
|
||||
`${root}/frontend/src/utils/config.ts`,
|
||||
Buffer.from('export const existing = true;\n'),
|
||||
);
|
||||
zip.addFile(
|
||||
`${root}/frontend/dist/index.html`,
|
||||
Buffer.from(
|
||||
'<script>window.API="__PINME_VITE_API_URL__";window.KEY="__PINME_AUTH_API_KEY__";</script>\n',
|
||||
),
|
||||
);
|
||||
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
async function createFakeNpmBin(root: string): Promise<string> {
|
||||
const binDir = path.join(root, 'fake-bin');
|
||||
const npmPath = path.join(binDir, 'npm');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeFile(npmPath, '#!/bin/sh\nexit 0\n');
|
||||
await chmod(npmPath, 0o755);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
describe('pinme CLI success paths with local APIs', () => {
|
||||
test('upload posts chunk workflow and prints public and management URLs', async () => {
|
||||
const temp = await createTempHome();
|
||||
let bundle: Awaited<ReturnType<typeof buildCliWithEnv>> | undefined;
|
||||
const server = await startLocalHttpServer((request, response) => {
|
||||
const bodyText = request.body.toString('utf8');
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/init') {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(JSON.parse(bodyText)).toMatchObject({
|
||||
file_name: 'index.html',
|
||||
is_directory: false,
|
||||
uid: '0x1234567890abcdef',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
session_id: 'session-1',
|
||||
total_chunks: 1,
|
||||
chunk_size: 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/upload') {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(bodyText).toContain('session-1');
|
||||
expect(bodyText).toContain('0x1234567890abcdef');
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { chunk_index: 0, chunk_size: request.body.length },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/complete') {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(JSON.parse(bodyText)).toMatchObject({
|
||||
session_id: 'session-1',
|
||||
uid: '0x1234567890abcdef',
|
||||
action: 'upload',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { trace_id: 'trace-1' },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'/up_status?trace_id=trace-1&uid=0x1234567890abcdef'
|
||||
) {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
is_ready: true,
|
||||
upload_rst: {
|
||||
Bytes: 32,
|
||||
Name: 'index.html',
|
||||
Size: 32,
|
||||
Hash: 'bafy-success',
|
||||
ShortUrl: 'short-success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && request.url === '/root_domain') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { domain: 'pinme.test' },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ code: 404, msg: 'unexpected request' }));
|
||||
});
|
||||
|
||||
try {
|
||||
bundle = await buildCliWithEnv({
|
||||
IPFS_API_URL: server.baseUrl,
|
||||
PINME_API_BASE: server.baseUrl,
|
||||
POLL_INTERVAL_SECONDS: '0',
|
||||
MAX_POLL_TIME_MINUTES: '1',
|
||||
});
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(
|
||||
['upload', path.join(repoRoot, 'test', 'fixtures', 'site', 'index.html')],
|
||||
{
|
||||
home: temp.home,
|
||||
cliPath: bundle.cliPath,
|
||||
timeout: 20000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode, outputOf(result)).toBe(0);
|
||||
expect(outputOf(result)).toContain('URL');
|
||||
expect(outputOf(result)).toContain('https://short-success.pinme.test');
|
||||
expect(outputOf(result)).toContain('Management URL');
|
||||
expect(outputOf(result)).toContain(
|
||||
'https://preview.pinme.test/#/preview/bafy-success',
|
||||
);
|
||||
expect(server.requests.map((request) => request.url)).toEqual([
|
||||
'/chunk/init',
|
||||
'/chunk/upload',
|
||||
'/chunk/complete',
|
||||
'/up_status?trace_id=trace-1&uid=0x1234567890abcdef',
|
||||
'/root_domain',
|
||||
]);
|
||||
} finally {
|
||||
if (bundle) {
|
||||
await bundle.cleanup();
|
||||
}
|
||||
await server.close();
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('bind uploads content and binds a PinMe subdomain', async () => {
|
||||
const temp = await createTempHome();
|
||||
let bundle: Awaited<ReturnType<typeof buildCliWithEnv>> | undefined;
|
||||
const server = await startLocalHttpServer((request, response) => {
|
||||
const bodyText = request.body.toString('utf8');
|
||||
|
||||
if (request.method === 'GET' && request.url === '/pay/wallet/balance') {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: { wallet_balance_usd: 10 },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/check_domain') {
|
||||
expect(JSON.parse(bodyText)).toEqual({ domain_name: 'demo-bind' });
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ data: { is_valid: true } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/init') {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(JSON.parse(bodyText)).toMatchObject({
|
||||
file_name: 'index.html',
|
||||
is_directory: false,
|
||||
uid: '0x1234567890abcdef',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
session_id: 'bind-session-1',
|
||||
total_chunks: 1,
|
||||
chunk_size: 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/upload') {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(bodyText).toContain('bind-session-1');
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { chunk_index: 0, chunk_size: request.body.length },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/complete') {
|
||||
expect(JSON.parse(bodyText)).toMatchObject({
|
||||
session_id: 'bind-session-1',
|
||||
uid: '0x1234567890abcdef',
|
||||
action: 'bind',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { trace_id: 'bind-trace-1' },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'/up_status?trace_id=bind-trace-1&uid=0x1234567890abcdef'
|
||||
) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
is_ready: true,
|
||||
upload_rst: {
|
||||
Bytes: 32,
|
||||
Name: 'index.html',
|
||||
Size: 32,
|
||||
Hash: 'bafy-bind-success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/bind_pinme_domain') {
|
||||
expect(JSON.parse(bodyText)).toEqual({
|
||||
domain_name: 'demo-bind',
|
||||
hash: 'bafy-bind-success',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ code: 200, msg: 'ok' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && request.url === '/root_domain') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { domain: 'pinme.test' },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ code: 404, msg: 'unexpected request' }));
|
||||
});
|
||||
|
||||
try {
|
||||
bundle = await buildCliWithEnv({
|
||||
IPFS_API_URL: server.baseUrl,
|
||||
PINME_API_BASE: server.baseUrl,
|
||||
POLL_INTERVAL_SECONDS: '0',
|
||||
MAX_POLL_TIME_MINUTES: '1',
|
||||
});
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(
|
||||
[
|
||||
'bind',
|
||||
path.join(repoRoot, 'test', 'fixtures', 'site', 'index.html'),
|
||||
'--domain',
|
||||
'demo-bind',
|
||||
],
|
||||
{
|
||||
home: temp.home,
|
||||
cliPath: bundle.cliPath,
|
||||
timeout: 20000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode, outputOf(result)).toBe(0);
|
||||
const output = outputOf(result);
|
||||
expect(output).toContain('Wallet balance available: $10.00');
|
||||
expect(output).toContain('Domain available: demo-bind');
|
||||
expect(output).toContain('Upload success, CID: bafy-bind-success');
|
||||
expect(output).toContain('Bind success: demo-bind');
|
||||
expect(output).toContain('Visit: https://demo-bind.pinme.test');
|
||||
expect(server.requests.map((request) => request.url)).toEqual([
|
||||
'/pay/wallet/balance',
|
||||
'/check_domain',
|
||||
'/chunk/init',
|
||||
'/chunk/upload',
|
||||
'/chunk/complete',
|
||||
'/up_status?trace_id=bind-trace-1&uid=0x1234567890abcdef',
|
||||
'/bind_pinme_domain',
|
||||
'/root_domain',
|
||||
]);
|
||||
} finally {
|
||||
if (bundle) {
|
||||
await bundle.cleanup();
|
||||
}
|
||||
await server.close();
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('delete --force posts project deletion and prints success', async () => {
|
||||
const temp = await createTempHome();
|
||||
const server = await startLocalHttpServer((request, response) => {
|
||||
expect(request.method).toBe('POST');
|
||||
expect(request.url).toBe('/delete_project');
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(JSON.parse(request.body.toString('utf8'))).toEqual({
|
||||
project_name: 'demo-project',
|
||||
});
|
||||
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
project_name: 'demo-project',
|
||||
domain_deleted: true,
|
||||
worker_deleted: true,
|
||||
database_deleted: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const bundle = await buildCliWithEnv({
|
||||
PINME_API_BASE: server.baseUrl,
|
||||
});
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(
|
||||
['delete', 'demo-project', '--force'],
|
||||
{
|
||||
home: temp.home,
|
||||
cliPath: bundle.cliPath,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode, outputOf(result)).toBe(0);
|
||||
expect(outputOf(result)).toContain('Project deleted successfully');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
await bundle.cleanup();
|
||||
} finally {
|
||||
await server.close();
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('update-db uploads SQL files and prints completion', async () => {
|
||||
const temp = await createTempHome();
|
||||
const server = await startLocalHttpServer((request, response) => {
|
||||
expect(request.method).toBe('POST');
|
||||
expect(request.url).toBe('/update_db?project_name=fixture-project');
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(request.body.toString('utf8')).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS notes',
|
||||
);
|
||||
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
filename: '001_init.sql',
|
||||
status: 'complete',
|
||||
num_queries: 1,
|
||||
duration: 3,
|
||||
changes: 0,
|
||||
rows_read: 0,
|
||||
rows_written: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const bundle = await buildCliWithEnv({
|
||||
PINME_API_BASE: server.baseUrl,
|
||||
});
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(['update-db'], {
|
||||
home: temp.home,
|
||||
cwd: path.join(repoRoot, 'test', 'fixtures', 'project'),
|
||||
cliPath: bundle.cliPath,
|
||||
});
|
||||
|
||||
expect(result.exitCode, outputOf(result)).toBe(0);
|
||||
expect(outputOf(result)).toContain('Database update complete.');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
await bundle.cleanup();
|
||||
} finally {
|
||||
await server.close();
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('import uploads CAR content and binds a PinMe subdomain', async () => {
|
||||
const temp = await createTempHome();
|
||||
let bundle: Awaited<ReturnType<typeof buildCliWithEnv>> | undefined;
|
||||
const server = await startLocalHttpServer((request, response) => {
|
||||
const bodyText = request.body.toString('utf8');
|
||||
|
||||
if (request.method === 'POST' && request.url === '/check_domain') {
|
||||
expect(JSON.parse(bodyText)).toEqual({ domain_name: 'demo-import' });
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ data: { is_valid: true } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/init') {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(JSON.parse(bodyText)).toMatchObject({
|
||||
file_name: 'index.html',
|
||||
is_directory: false,
|
||||
uid: '0x1234567890abcdef',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
session_id: 'import-session-1',
|
||||
total_chunks: 1,
|
||||
chunk_size: 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/upload') {
|
||||
expect(bodyText).toContain('import-session-1');
|
||||
expect(bodyText).toContain('0x1234567890abcdef');
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { chunk_index: 0, chunk_size: request.body.length },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/complete') {
|
||||
expect(JSON.parse(bodyText)).toMatchObject({
|
||||
session_id: 'import-session-1',
|
||||
uid: '0x1234567890abcdef',
|
||||
action: 'import',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { trace_id: 'import-trace-1' },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'/up_status?trace_id=import-trace-1&uid=0x1234567890abcdef'
|
||||
) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
is_ready: true,
|
||||
upload_rst: {
|
||||
Bytes: 32,
|
||||
Name: 'index.html',
|
||||
Size: 32,
|
||||
Hash: 'bafy-import-success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/bind_pinme_domain') {
|
||||
expect(JSON.parse(bodyText)).toEqual({
|
||||
domain_name: 'demo-import',
|
||||
hash: 'bafy-import-success',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ code: 200, msg: 'ok' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && request.url === '/root_domain') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { domain: 'pinme.test' },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ code: 404, msg: 'unexpected request' }));
|
||||
});
|
||||
|
||||
try {
|
||||
bundle = await buildCliWithEnv({
|
||||
IPFS_API_URL: server.baseUrl,
|
||||
PINME_API_BASE: server.baseUrl,
|
||||
POLL_INTERVAL_SECONDS: '0',
|
||||
MAX_POLL_TIME_MINUTES: '1',
|
||||
});
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(
|
||||
[
|
||||
'import',
|
||||
path.join(repoRoot, 'test', 'fixtures', 'site', 'index.html'),
|
||||
'--domain',
|
||||
'demo-import',
|
||||
],
|
||||
{
|
||||
home: temp.home,
|
||||
cliPath: bundle.cliPath,
|
||||
timeout: 20000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode, outputOf(result)).toBe(0);
|
||||
const output = outputOf(result);
|
||||
expect(output).toContain('Domain available: demo-import');
|
||||
expect(output).toContain('URL');
|
||||
expect(output).toContain('bafy-import-success');
|
||||
expect(output).toContain('Bind success: demo-import');
|
||||
expect(output).toContain(
|
||||
'Visit (Pinme subdomain example): https://demo-import.pinme.test',
|
||||
);
|
||||
expect(server.requests.map((request) => request.url)).toEqual([
|
||||
'/check_domain',
|
||||
'/chunk/init',
|
||||
'/chunk/upload',
|
||||
'/chunk/complete',
|
||||
'/up_status?trace_id=import-trace-1&uid=0x1234567890abcdef',
|
||||
'/bind_pinme_domain',
|
||||
'/root_domain',
|
||||
]);
|
||||
} finally {
|
||||
if (bundle) {
|
||||
await bundle.cleanup();
|
||||
}
|
||||
await server.close();
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('export requests CAR generation and downloads the completed file', async () => {
|
||||
const temp = await createTempHome();
|
||||
let bundle: Awaited<ReturnType<typeof buildCliWithEnv>> | undefined;
|
||||
const cid = 'bafyexportsuccess';
|
||||
const outputDir = path.join(temp.home, 'exports');
|
||||
const server = await startLocalHttpServer((request, response) => {
|
||||
if (
|
||||
request.method === 'POST' &&
|
||||
request.url ===
|
||||
`/car/export?cid=${cid}&uid=0x1234567890abcdef`
|
||||
) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
cid,
|
||||
status: 'processing',
|
||||
task_id: 'export-task-1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === 'GET' &&
|
||||
request.url === '/car/export/status?task_id=export-task-1'
|
||||
) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
task_id: 'export-task-1',
|
||||
cid,
|
||||
status: 'completed',
|
||||
download_url: `${server.baseUrl}/downloads/${cid}.car`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && request.url === `/downloads/${cid}.car`) {
|
||||
const body = Buffer.from('fixture car payload');
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': String(body.length),
|
||||
});
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ code: 404, msg: 'unexpected request' }));
|
||||
});
|
||||
|
||||
try {
|
||||
bundle = await buildCliWithEnv({
|
||||
CAR_API_BASE: server.baseUrl,
|
||||
});
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(
|
||||
['export', cid, '--output', outputDir],
|
||||
{
|
||||
home: temp.home,
|
||||
cliPath: bundle.cliPath,
|
||||
timeout: 20000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode, outputOf(result)).toBe(0);
|
||||
const output = outputOf(result);
|
||||
expect(output).toContain('Export task created: export-task-1');
|
||||
expect(output).toContain('Export successful');
|
||||
expect(output).toContain(`CID: ${cid}`);
|
||||
await expect(readFile(path.join(outputDir, `${cid}.car`), 'utf8'))
|
||||
.resolves.toBe('fixture car payload');
|
||||
expect(server.requests.map((request) => request.url)).toEqual([
|
||||
`/car/export?cid=${cid}&uid=0x1234567890abcdef`,
|
||||
'/car/export/status?task_id=export-task-1',
|
||||
`/downloads/${cid}.car`,
|
||||
]);
|
||||
} finally {
|
||||
if (bundle) {
|
||||
await bundle.cleanup();
|
||||
}
|
||||
await server.close();
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('create scaffolds a project, deploys worker, and uploads frontend dist', async () => {
|
||||
const temp = await createTempHome();
|
||||
let bundle: Awaited<ReturnType<typeof buildCliWithEnv>> | undefined;
|
||||
const templateZip = createTemplateZipBuffer();
|
||||
const server = await startLocalHttpServer((request, response) => {
|
||||
const bodyText = request.body.toString('utf8');
|
||||
|
||||
if (request.method === 'POST' && request.url === '/create_worker') {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(JSON.parse(bodyText)).toEqual({
|
||||
project_name: 'demo-create',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
api_domain: 'https://api.demo-create.pinme.test',
|
||||
metadata: {
|
||||
project_name: 'demo-create',
|
||||
bindings: [
|
||||
{ name: 'API_KEY', text: 'real-api-key' },
|
||||
{ name: 'PROJECT_NAME', text: 'demo-create' },
|
||||
],
|
||||
},
|
||||
project_name: 'demo-create',
|
||||
uuid: 'worker-uuid-1',
|
||||
api_key: 'real-api-key',
|
||||
public_client_config: {
|
||||
auth_api_key: 'public-auth-key',
|
||||
auth_domain: 'auth.pinme.test',
|
||||
auth_project_id: 'pinme-project',
|
||||
tenant_id: 'tenant-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && request.url === '/template.zip') {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Length': String(templateZip.length),
|
||||
});
|
||||
response.end(templateZip);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === 'PUT' &&
|
||||
request.url === '/save_worker?project_name=demo-create'
|
||||
) {
|
||||
expect(request.headers['token-address']).toBe('0x1234567890abcdef');
|
||||
expect(request.headers['authentication-tokens']).toBe('test-token');
|
||||
expect(bodyText).toContain('metadata.json');
|
||||
expect(bodyText).toContain('worker.js');
|
||||
expect(bodyText).toContain('001_init.sql');
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
sql_results: [
|
||||
{ filename: '001_init.sql', status: 'complete' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/init') {
|
||||
expect(JSON.parse(bodyText)).toMatchObject({
|
||||
is_directory: true,
|
||||
uid: '0x1234567890abcdef',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
session_id: 'create-session-1',
|
||||
total_chunks: 1,
|
||||
chunk_size: 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/upload') {
|
||||
expect(bodyText).toContain('create-session-1');
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { chunk_index: 0, chunk_size: request.body.length },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/chunk/complete') {
|
||||
expect(JSON.parse(bodyText)).toMatchObject({
|
||||
session_id: 'create-session-1',
|
||||
uid: '0x1234567890abcdef',
|
||||
action: 'project_create',
|
||||
project_name: 'demo-create',
|
||||
});
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { trace_id: 'create-trace-1' },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'/up_status?trace_id=create-trace-1&uid=0x1234567890abcdef&project_name=demo-create'
|
||||
) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
is_ready: true,
|
||||
upload_rst: {
|
||||
Bytes: 64,
|
||||
Name: 'dist',
|
||||
Size: 64,
|
||||
Hash: 'bafy-create-frontend',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ code: 404, msg: 'unexpected request' }));
|
||||
});
|
||||
|
||||
try {
|
||||
const fakeBin = await createFakeNpmBin(temp.home);
|
||||
bundle = await buildCliWithEnv({
|
||||
PINME_API_BASE: server.baseUrl,
|
||||
IPFS_API_URL: server.baseUrl,
|
||||
PINME_TEMPLATE_ZIP_URL: `${server.baseUrl}/template.zip`,
|
||||
POLL_INTERVAL_SECONDS: '0',
|
||||
MAX_POLL_TIME_MINUTES: '1',
|
||||
});
|
||||
await writeAuthConfig(temp.home);
|
||||
const result = await runCli(['create', 'Demo-Create', '--force'], {
|
||||
home: temp.home,
|
||||
cwd: temp.home,
|
||||
cliPath: bundle.cliPath,
|
||||
timeout: 25000,
|
||||
env: {
|
||||
PATH: `${fakeBin}:${process.env.PATH || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode, outputOf(result)).toBe(0);
|
||||
const output = outputOf(result);
|
||||
expect(output).toContain('Project created successfully.');
|
||||
expect(output).toContain('Worker deployed');
|
||||
expect(output).toContain('Frontend URL');
|
||||
expect(output).toContain('Project Management URL');
|
||||
|
||||
const projectDir = path.join(temp.home, 'Demo-Create');
|
||||
await expect(readFile(path.join(projectDir, 'pinme.toml'), 'utf8'))
|
||||
.resolves.toContain('project_name = "demo-create"');
|
||||
await expect(readFile(path.join(projectDir, 'pinme.toml'), 'utf8'))
|
||||
.resolves.toContain(
|
||||
'api_url = "https://api.demo-create.pinme.test"',
|
||||
);
|
||||
await expect(readFile(path.join(projectDir, 'pinme.toml'), 'utf8'))
|
||||
.resolves.toContain(
|
||||
'frontend_url = "https://project.pinme.test/demo-create"',
|
||||
);
|
||||
await expect(
|
||||
readFile(path.join(projectDir, 'backend', 'metadata.json'), 'utf8'),
|
||||
).resolves.toContain('"PROJECT_NAME"');
|
||||
await expect(
|
||||
readFile(
|
||||
path.join(projectDir, 'frontend', 'src', 'utils', 'config.ts'),
|
||||
'utf8',
|
||||
),
|
||||
).resolves.toContain('public_client_config');
|
||||
await expect(
|
||||
readFile(path.join(projectDir, 'frontend', 'dist', 'index.html'), 'utf8'),
|
||||
).resolves.toContain('https://api.demo-create.pinme.test');
|
||||
expect(server.requests.map((request) => request.url)).toEqual([
|
||||
'/create_worker',
|
||||
'/template.zip',
|
||||
'/save_worker?project_name=demo-create',
|
||||
'/chunk/init',
|
||||
'/chunk/upload',
|
||||
'/chunk/complete',
|
||||
'/up_status?trace_id=create-trace-1&uid=0x1234567890abcdef&project_name=demo-create',
|
||||
]);
|
||||
} finally {
|
||||
if (bundle) {
|
||||
await bundle.cleanup();
|
||||
}
|
||||
await server.close();
|
||||
await temp.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL
|
||||
);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
Fixture project dist
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
project_name = "fixture-project"
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>PinMe fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Fixture site</h1>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { mkdir, rm, writeFile } from 'fs/promises';
|
||||
import { execaNode } from 'execa';
|
||||
import { dir } from 'tmp-promise';
|
||||
import { build } from 'esbuild';
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
export const cliEntry = path.join(repoRoot, 'dist', 'index.js');
|
||||
|
||||
export interface TempHome {
|
||||
home: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createTempHome(): Promise<TempHome> {
|
||||
const temp = await dir({
|
||||
prefix: 'pinme-cli-home-',
|
||||
unsafeCleanup: true,
|
||||
});
|
||||
|
||||
return {
|
||||
home: temp.path,
|
||||
cleanup: temp.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeAuthConfig(home: string): Promise<void> {
|
||||
const configDir = path.join(home, '.pinme');
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(configDir, 'auth.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
address: '0x1234567890abcdef',
|
||||
token: 'test-token',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function runCli(
|
||||
args: string[],
|
||||
options: {
|
||||
home: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
env?: Record<string, string>;
|
||||
cliPath?: string;
|
||||
},
|
||||
): any {
|
||||
return execaNode(options.cliPath || cliEntry, args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
reject: false,
|
||||
timeout: options.timeout || 15000,
|
||||
env: {
|
||||
HOME: options.home,
|
||||
USERPROFILE: options.home,
|
||||
PINME_TRACKING_DISABLED: '1',
|
||||
PINME_API_BASE: 'http://127.0.0.1:9',
|
||||
IPFS_API_URL: 'http://127.0.0.1:9',
|
||||
CAR_API_BASE: 'http://127.0.0.1:9',
|
||||
IPFS_PREVIEW_URL: 'https://preview.pinme.test/#/preview/',
|
||||
PROJECT_PREVIEW_URL: 'https://project.pinme.test/',
|
||||
PINME_WEB_URL: 'https://app.pinme.test',
|
||||
...options.env,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildCliWithEnv(
|
||||
env: Record<string, string>,
|
||||
): Promise<{ cliPath: string; cleanup: () => Promise<void> }> {
|
||||
const outfile = path.join(
|
||||
repoRoot,
|
||||
'dist',
|
||||
`.test-cli-${process.pid}-${Date.now()}.js`,
|
||||
);
|
||||
const define: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
define[`process.env.${key}`] = JSON.stringify(value);
|
||||
}
|
||||
|
||||
await build({
|
||||
entryPoints: [path.join(repoRoot, 'bin', 'index.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node14',
|
||||
format: 'cjs',
|
||||
external: Object.keys(packageJson.dependencies || {}).filter(
|
||||
(dependency) => dependency !== 'axios',
|
||||
),
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
logLevel: 'silent',
|
||||
define,
|
||||
});
|
||||
|
||||
return {
|
||||
cliPath: outfile,
|
||||
cleanup: () => rm(outfile, { force: true }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import http, { type IncomingMessage, type ServerResponse } from 'http';
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
export interface LocalHttpServer {
|
||||
baseUrl: string;
|
||||
requests: RecordedRequest[];
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startLocalHttpServer(
|
||||
handler: (
|
||||
request: RecordedRequest,
|
||||
response: ServerResponse,
|
||||
) => void | Promise<void>,
|
||||
): Promise<LocalHttpServer> {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const server = http.createServer(
|
||||
async (request: IncomingMessage, response: ServerResponse) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', async () => {
|
||||
const recorded: RecordedRequest = {
|
||||
method: request.method || 'GET',
|
||||
url: request.url || '/',
|
||||
headers: request.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
};
|
||||
requests.push(recorded);
|
||||
|
||||
try {
|
||||
await handler(recorded, response);
|
||||
} catch (error: any) {
|
||||
response.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 500,
|
||||
msg: error?.message || 'local test server error',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to start local HTTP server');
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
async function loadApiClient(env: Record<string, string> = {}) {
|
||||
vi.resetModules();
|
||||
vi.doUnmock('../../bin/utils/webLogin');
|
||||
process.env.PINME_API_BASE = env.PINME_API_BASE || 'https://api.pinme.test';
|
||||
return import('../../bin/utils/apiClient');
|
||||
}
|
||||
|
||||
async function loadApiClientWithAuthMock() {
|
||||
vi.resetModules();
|
||||
process.env.PINME_API_BASE = 'https://api.pinme.test';
|
||||
vi.doMock('../../bin/utils/webLogin', () => ({
|
||||
getAuthHeaders: () => ({
|
||||
'token-address': '0xabc',
|
||||
'authentication-tokens': 'secret-token',
|
||||
}),
|
||||
}));
|
||||
return import('../../bin/utils/apiClient');
|
||||
}
|
||||
|
||||
async function loadApiClientWithThrowingAuthMock() {
|
||||
vi.resetModules();
|
||||
process.env.PINME_API_BASE = 'https://api.pinme.test';
|
||||
vi.doMock('../../bin/utils/webLogin', () => ({
|
||||
getAuthHeaders: () => {
|
||||
throw new Error('auth config unreadable');
|
||||
},
|
||||
}));
|
||||
return import('../../bin/utils/apiClient');
|
||||
}
|
||||
|
||||
describe('apiClient', () => {
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
test('returns successful business responses', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClient();
|
||||
nock('https://api.pinme.test')
|
||||
.get('/root_domain')
|
||||
.reply(200, { code: 200, data: { domain: 'pinme.test' } });
|
||||
|
||||
const response = await createPinmeApiClient().get('/root_domain');
|
||||
|
||||
expect(response.data.data.domain).toBe('pinme.test');
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
test('turns non-200 business codes into CliError', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClient();
|
||||
nock('https://api.pinme.test')
|
||||
.post('/bind_pinme_domain')
|
||||
.reply(200, { code: 500, msg: 'Domain is taken' });
|
||||
|
||||
await expect(
|
||||
createPinmeApiClient().post('/bind_pinme_domain', {
|
||||
domain_name: 'demo',
|
||||
hash: 'bafy',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'CliError',
|
||||
stage: 'API request',
|
||||
message: 'Domain is taken',
|
||||
details: ['Request: POST /bind_pinme_domain', 'Business code: 500'],
|
||||
});
|
||||
});
|
||||
|
||||
test('wraps HTTP failures with request context', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClient();
|
||||
nock('https://api.pinme.test')
|
||||
.get('/my_domains')
|
||||
.reply(503, { message: 'Temporarily unavailable' });
|
||||
|
||||
await expect(createPinmeApiClient().get('/my_domains')).rejects.toMatchObject(
|
||||
{
|
||||
name: 'CliError',
|
||||
stage: 'API request',
|
||||
message: 'Temporarily unavailable',
|
||||
details: ['Request: GET /my_domains', 'HTTP status: 503'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('injects auth headers from local auth config by default', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClientWithAuthMock();
|
||||
let capturedHeaders: Record<string, string | string[] | undefined> = {};
|
||||
nock('https://api.pinme.test')
|
||||
.get('/my_domains')
|
||||
.reply(function () {
|
||||
capturedHeaders = this.req.headers;
|
||||
return [200, { code: 200, data: [] }];
|
||||
});
|
||||
|
||||
await expect(createPinmeApiClient().get('/my_domains')).resolves.toMatchObject(
|
||||
{
|
||||
data: { code: 200, data: [] },
|
||||
},
|
||||
);
|
||||
expect(capturedHeaders['token-address']).toBe('0xabc');
|
||||
expect(capturedHeaders['authentication-tokens']).toBe('secret-token');
|
||||
});
|
||||
|
||||
test('omits auth headers when includeAuth is false', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClientWithAuthMock();
|
||||
nock('https://api.pinme.test', {
|
||||
badheaders: ['token-address', 'authentication-tokens'],
|
||||
})
|
||||
.get('/public')
|
||||
.reply(200, { ok: true });
|
||||
|
||||
const response = await createPinmeApiClient({ includeAuth: false }).get(
|
||||
'/public',
|
||||
);
|
||||
|
||||
expect(response.data).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('merges custom headers over defaults', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClient();
|
||||
nock('https://api.pinme.test', {
|
||||
reqheaders: {
|
||||
'user-agent': 'Custom-Agent',
|
||||
'x-test-suite': 'api-client',
|
||||
},
|
||||
})
|
||||
.get('/headers')
|
||||
.reply(200, { ok: true });
|
||||
|
||||
await createPinmeApiClient({
|
||||
includeAuth: false,
|
||||
headers: {
|
||||
'User-Agent': 'Custom-Agent',
|
||||
'X-Test-Suite': 'api-client',
|
||||
},
|
||||
}).get('/headers');
|
||||
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
test('sends default JSON CLI headers', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClient();
|
||||
let capturedHeaders: Record<string, string | string[] | undefined> = {};
|
||||
nock('https://api.pinme.test')
|
||||
.get('/headers')
|
||||
.reply(function () {
|
||||
capturedHeaders = this.req.headers;
|
||||
return [200, { ok: true }];
|
||||
});
|
||||
|
||||
await createPinmeApiClient({ includeAuth: false }).get('/headers');
|
||||
|
||||
expect(capturedHeaders.accept).toBe('*/*');
|
||||
expect(capturedHeaders['content-type']).toBe('application/json');
|
||||
expect(capturedHeaders['user-agent']).toBe('Pinme-CLI');
|
||||
expect(capturedHeaders.connection).toBe('keep-alive');
|
||||
});
|
||||
|
||||
test('continues without auth headers when local auth config is unreadable', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClientWithThrowingAuthMock();
|
||||
nock('https://api.pinme.test', {
|
||||
badheaders: ['token-address', 'authentication-tokens'],
|
||||
})
|
||||
.get('/public')
|
||||
.reply(200, { ok: true });
|
||||
|
||||
await expect(createPinmeApiClient().get('/public')).resolves.toMatchObject({
|
||||
data: { ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('does not treat non-object response bodies as business errors', async () => {
|
||||
const { createPinmeApiClient } = await loadApiClient();
|
||||
nock('https://api.pinme.test').get('/plain').reply(200, 'code 500');
|
||||
|
||||
const response = await createPinmeApiClient({ includeAuth: false }).get(
|
||||
'/plain',
|
||||
);
|
||||
|
||||
expect(response.data).toBe('code 500');
|
||||
});
|
||||
|
||||
test('omits request context when axios config has no URL', async () => {
|
||||
const { createApiClient } = await loadApiClient();
|
||||
const client = createApiClient({
|
||||
includeAuth: false,
|
||||
baseURL: 'https://api.pinme.test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.request({
|
||||
adapter: async (config) => ({
|
||||
data: { code: 500, msg: 'Adapter business failure' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Adapter business failure',
|
||||
details: ['Business code: 500'],
|
||||
});
|
||||
});
|
||||
|
||||
test('uses GET as the default request descriptor method', async () => {
|
||||
const { createApiClient } = await loadApiClient();
|
||||
const client = createApiClient({
|
||||
includeAuth: false,
|
||||
baseURL: 'https://api.pinme.test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.request({
|
||||
url: '/adapter-default-method',
|
||||
adapter: async (config) => ({
|
||||
data: { code: 500, msg: 'Adapter business failure' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Adapter business failure',
|
||||
details: [
|
||||
'Request: GET /adapter-default-method',
|
||||
'Business code: 500',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
async function loadPinmeApi(env: Record<string, string>) {
|
||||
vi.resetModules();
|
||||
Object.assign(process.env, env);
|
||||
return import('../../bin/utils/pinmeApi');
|
||||
}
|
||||
|
||||
describe('pinmeApi', () => {
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
test('returns DNS domains as available without an API call', async () => {
|
||||
const { checkDomainAvailable } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
|
||||
await expect(checkDomainAvailable('example.com')).resolves.toEqual({
|
||||
is_valid: true,
|
||||
});
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
test('checks PinMe subdomain availability through configured endpoint', async () => {
|
||||
const { checkDomainAvailable } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
PINME_CHECK_DOMAIN_PATH: '/check_domain',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/check_domain', { domain_name: 'demo' })
|
||||
.reply(200, { data: { is_valid: false, error: 'taken' } });
|
||||
|
||||
await expect(checkDomainAvailable('demo')).resolves.toEqual({
|
||||
is_valid: false,
|
||||
error: 'taken',
|
||||
});
|
||||
});
|
||||
|
||||
test('defaults subdomain availability to true for unexpected successful shapes', async () => {
|
||||
const { checkDomainAvailable } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
PINME_CHECK_DOMAIN_PATH: '/check_domain',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/check_domain', { domain_name: 'demo' })
|
||||
.reply(200, { code: 200, data: { unexpected: true } });
|
||||
|
||||
await expect(checkDomainAvailable('demo')).resolves.toEqual({
|
||||
is_valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('checks top-level domain availability and surfaces recoverable HTTP failures', async () => {
|
||||
const { checkDomainAvailable } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
PINME_CHECK_DOMAIN_PATH: '/check_domain',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/check_domain', { domain_name: 'direct' })
|
||||
.reply(200, { is_valid: false, error: 'reserved' })
|
||||
.post('/check_domain', { domain_name: 'missing' })
|
||||
.reply(404, { message: 'not found' });
|
||||
|
||||
await expect(checkDomainAvailable('direct')).resolves.toEqual({
|
||||
is_valid: false,
|
||||
error: 'reserved',
|
||||
});
|
||||
await expect(checkDomainAvailable('missing')).rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
test('caches root domain until force refresh', async () => {
|
||||
const { getRootDomain } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.get('/root_domain')
|
||||
.reply(200, { code: 200, data: { domain: 'first.pinme.test' } })
|
||||
.get('/root_domain')
|
||||
.reply(200, { code: 200, data: { domain: 'second.pinme.test' } });
|
||||
|
||||
await expect(getRootDomain()).resolves.toBe('first.pinme.test');
|
||||
await expect(getRootDomain()).resolves.toBe('first.pinme.test');
|
||||
await expect(getRootDomain(true)).resolves.toBe('second.pinme.test');
|
||||
});
|
||||
|
||||
test('getRootDomain rejects successful responses without a domain', async () => {
|
||||
const { getRootDomain } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.get('/root_domain')
|
||||
.reply(200, { code: 200, msg: 'domain missing', data: {} });
|
||||
|
||||
await expect(getRootDomain(true)).rejects.toThrow('domain missing');
|
||||
});
|
||||
|
||||
test('binds anonymous devices and returns false on token expiration', async () => {
|
||||
const { bindAnonymousDevice } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/bind_anonymous', { anonymous_uid: 'anon-1' })
|
||||
.reply(200, { code: 200 })
|
||||
.post('/bind_anonymous', { anonymous_uid: 'anon-2' })
|
||||
.reply(401, { message: 'token expired' });
|
||||
|
||||
await expect(bindAnonymousDevice('anon-1')).resolves.toBe(true);
|
||||
await expect(bindAnonymousDevice('anon-2')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('bindAnonymousDevice returns false for non-token failures', async () => {
|
||||
const { bindAnonymousDevice } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/bind_anonymous', { anonymous_uid: 'anon-fail' })
|
||||
.reply(500, { message: 'server exploded' });
|
||||
|
||||
await expect(bindAnonymousDevice('anon-fail')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('throws token expired for auth failures', async () => {
|
||||
const { getMyDomains } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.get('/my_domains')
|
||||
.reply(401, { message: 'token expired' });
|
||||
|
||||
await expect(getMyDomains()).rejects.toThrow('Token expired');
|
||||
});
|
||||
|
||||
test('detects token expiration from business codes and localized messages', async () => {
|
||||
const { checkDomainAvailable, getMyDomains } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
PINME_CHECK_DOMAIN_PATH: '/check_domain',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/check_domain', { domain_name: 'demo' })
|
||||
.reply(200, { code: 10001, msg: '登录已过期' })
|
||||
.get('/my_domains')
|
||||
.reply(200, { code: 'TOKEN_EXPIRED', msg: 'token expired' });
|
||||
|
||||
await expect(checkDomainAvailable('demo')).rejects.toThrow(
|
||||
'Token expired',
|
||||
);
|
||||
await expect(getMyDomains()).rejects.toThrow('Token expired');
|
||||
});
|
||||
|
||||
test('reads domain list variants and returns empty arrays for business failures', async () => {
|
||||
const { getMyDomains } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.get('/my_domains')
|
||||
.reply(200, {
|
||||
code: 200,
|
||||
data: {
|
||||
list: [
|
||||
{
|
||||
domain_name: 'demo',
|
||||
domain_type: 1,
|
||||
bind_time: 1,
|
||||
expire_time: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.get('/my_domains')
|
||||
.reply(200, { msg: 'missing code' });
|
||||
|
||||
await expect(getMyDomains()).resolves.toHaveLength(1);
|
||||
await expect(getMyDomains()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test('reads array domain lists and treats business auth codes as expired', async () => {
|
||||
const { getMyDomains } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.get('/my_domains')
|
||||
.reply(200, {
|
||||
code: 200,
|
||||
data: [
|
||||
{
|
||||
domain_name: 'array-demo',
|
||||
domain_type: 1,
|
||||
bind_time: 1,
|
||||
expire_time: 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
.get('/my_domains')
|
||||
.reply(200, { code: 403, msg: 'auth failed' });
|
||||
|
||||
await expect(getMyDomains()).resolves.toEqual([
|
||||
expect.objectContaining({ domain_name: 'array-demo' }),
|
||||
]);
|
||||
await expect(getMyDomains()).rejects.toThrow('Token expired');
|
||||
});
|
||||
|
||||
test('getMyDomains handles unsupported payloads and both auth business codes', async () => {
|
||||
const { getMyDomains } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.get('/my_domains')
|
||||
.reply(200, { code: 200, data: { list: 'not an array' } })
|
||||
.get('/my_domains')
|
||||
.reply(200, { code: 401, msg: 'auth failed' });
|
||||
|
||||
await expect(getMyDomains()).resolves.toEqual([]);
|
||||
await expect(getMyDomains()).rejects.toThrow('Token expired');
|
||||
});
|
||||
|
||||
test('binds DNS domains with auth headers', async () => {
|
||||
const { bindDnsDomainV4 } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test', {
|
||||
reqheaders: {
|
||||
'x-auth-token': 'token',
|
||||
'x-token-address': '0xabc',
|
||||
},
|
||||
})
|
||||
.post('/bind_dns', { domain_name: 'example.com', hash: 'bafy' })
|
||||
.reply(200, {
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: { domain_name: 'example.com', hash: 'bafy' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
bindDnsDomainV4('example.com', 'bafy', '0xabc', 'token'),
|
||||
).resolves.toMatchObject({
|
||||
code: 200,
|
||||
data: { domain_name: 'example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
test('binds PinMe subdomains and reports wallet balance and VIP status', async () => {
|
||||
const { bindPinmeDomain, getWalletBalance, isVip } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/bind_pinme_domain', {
|
||||
domain_name: 'demo',
|
||||
hash: 'bafy',
|
||||
project_name: 'project',
|
||||
})
|
||||
.reply(200, { code: 200 })
|
||||
.get('/pay/wallet/balance')
|
||||
.reply(200, {
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: { wallet_balance_usd: 12.5 },
|
||||
})
|
||||
.get('/is_vip')
|
||||
.reply(200, {
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: { is_vip: true },
|
||||
});
|
||||
|
||||
await expect(bindPinmeDomain('demo', 'bafy', 'project')).resolves.toBe(true);
|
||||
await expect(getWalletBalance('0xabc', 'token')).resolves.toMatchObject({
|
||||
data: { wallet_balance_usd: 12.5 },
|
||||
});
|
||||
await expect(isVip('0xabc', 'token')).resolves.toMatchObject({
|
||||
data: { is_vip: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('bindPinmeDomain returns false for successful responses without code 200', async () => {
|
||||
const { bindPinmeDomain } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/bind_pinme_domain', {
|
||||
domain_name: 'demo',
|
||||
hash: 'bafy',
|
||||
})
|
||||
.reply(200, { msg: 'missing code' })
|
||||
.post('/bind_pinme_domain', {
|
||||
domain_name: 'project-demo',
|
||||
hash: 'bafy-project',
|
||||
project_name: 'project',
|
||||
})
|
||||
.reply(200, { code: 200, msg: 'ok' });
|
||||
|
||||
await expect(bindPinmeDomain('demo', 'bafy')).resolves.toBe(false);
|
||||
await expect(
|
||||
bindPinmeDomain('project-demo', 'bafy-project', 'project'),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('sends account auth headers for wallet and VIP requests', async () => {
|
||||
const { getWalletBalance, isVip } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test', {
|
||||
reqheaders: {
|
||||
'authentication-tokens': 'wallet-token',
|
||||
'token-address': '0xwallet',
|
||||
},
|
||||
})
|
||||
.get('/pay/wallet/balance')
|
||||
.reply(200, { code: 200, msg: 'ok', data: { wallet_balance_usd: 1 } });
|
||||
nock('https://api.pinme.test', {
|
||||
reqheaders: {
|
||||
'x-auth-token': 'vip-token',
|
||||
'x-token-address': '0xvip',
|
||||
},
|
||||
})
|
||||
.get('/is_vip')
|
||||
.reply(200, { code: 200, msg: 'ok', data: { is_vip: false } });
|
||||
|
||||
await expect(getWalletBalance('0xwallet', 'wallet-token')).resolves.toEqual(
|
||||
{
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: { wallet_balance_usd: 1 },
|
||||
},
|
||||
);
|
||||
await expect(isVip('0xvip', 'vip-token')).resolves.toEqual({
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: { is_vip: false },
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces token expiration from bind and account APIs', async () => {
|
||||
const { bindPinmeDomain, bindDnsDomainV4, getWalletBalance, isVip } =
|
||||
await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
});
|
||||
nock('https://api.pinme.test')
|
||||
.post('/bind_pinme_domain')
|
||||
.reply(403, { message: 'invalid token' })
|
||||
.post('/bind_dns')
|
||||
.reply(401, { message: 'token expired' })
|
||||
.get('/pay/wallet/balance')
|
||||
.reply(401, { message: 'unauthorized' })
|
||||
.get('/is_vip')
|
||||
.reply(401, { message: 'auth failed' });
|
||||
|
||||
await expect(bindPinmeDomain('demo', 'bafy')).rejects.toThrow(
|
||||
'Token expired',
|
||||
);
|
||||
await expect(
|
||||
bindDnsDomainV4('example.com', 'bafy', '0xabc', 'token'),
|
||||
).rejects.toThrow('Token expired');
|
||||
await expect(getWalletBalance('0xabc', 'token')).rejects.toThrow(
|
||||
'Token expired',
|
||||
);
|
||||
await expect(isVip('0xabc', 'token')).rejects.toThrow('Token expired');
|
||||
});
|
||||
|
||||
test('requests CAR export through the CAR API client', async () => {
|
||||
const { requestCarExport } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
CAR_API_BASE: 'https://car.pinme.test',
|
||||
});
|
||||
nock('https://car.pinme.test')
|
||||
.post('/car/export')
|
||||
.query({ cid: 'bafy', uid: 'uid-1' })
|
||||
.reply(200, {
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
cid: 'bafy',
|
||||
status: 'processing',
|
||||
task_id: 'task-1',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(requestCarExport('bafy', 'uid-1')).resolves.toEqual({
|
||||
cid: 'bafy',
|
||||
status: 'processing',
|
||||
task_id: 'task-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('checks CAR export status and normalizes API failures', async () => {
|
||||
const { checkCarExportStatus, requestCarExport } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
CAR_API_BASE: 'https://car.pinme.test',
|
||||
});
|
||||
nock('https://car.pinme.test')
|
||||
.get('/car/export/status')
|
||||
.query({ task_id: 'task-1' })
|
||||
.reply(200, {
|
||||
code: 200,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
task_id: 'task-1',
|
||||
cid: 'bafy',
|
||||
status: 'completed',
|
||||
download_url: 'https://download.pinme.test/file.car',
|
||||
},
|
||||
})
|
||||
.post('/car/export')
|
||||
.query({ cid: 'bad', uid: 'uid-1' })
|
||||
.reply(200, { code: 500, msg: 'Export failed' });
|
||||
|
||||
await expect(checkCarExportStatus('task-1')).resolves.toMatchObject({
|
||||
status: 'completed',
|
||||
download_url: 'https://download.pinme.test/file.car',
|
||||
});
|
||||
await expect(requestCarExport('bad', 'uid-1')).rejects.toThrow(
|
||||
/Failed to request CAR export: Export failed|Export failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects CAR success codes that omit payload data', async () => {
|
||||
const { checkCarExportStatus, requestCarExport } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
CAR_API_BASE: 'https://car.pinme.test',
|
||||
});
|
||||
nock('https://car.pinme.test')
|
||||
.post('/car/export')
|
||||
.query({ cid: 'empty', uid: 'uid-1' })
|
||||
.reply(200, { code: 200, msg: 'missing export data' })
|
||||
.get('/car/export/status')
|
||||
.query({ task_id: 'empty-task' })
|
||||
.reply(200, { code: 200, msg: 'missing status data' });
|
||||
|
||||
await expect(requestCarExport('empty', 'uid-1')).rejects.toThrow(
|
||||
/missing export data/,
|
||||
);
|
||||
await expect(checkCarExportStatus('empty-task')).rejects.toThrow(
|
||||
/missing status data/,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes CAR status token expiration and response messages', async () => {
|
||||
const { checkCarExportStatus } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
CAR_API_BASE: 'https://car.pinme.test',
|
||||
});
|
||||
nock('https://car.pinme.test')
|
||||
.get('/car/export/status')
|
||||
.query({ task_id: 'expired' })
|
||||
.reply(401, { message: 'token expired' })
|
||||
.get('/car/export/status')
|
||||
.query({ task_id: 'failed' })
|
||||
.reply(200, { code: 500, msg: 'Task failed' });
|
||||
|
||||
await expect(checkCarExportStatus('expired')).rejects.toThrow(
|
||||
'Token expired',
|
||||
);
|
||||
await expect(checkCarExportStatus('failed')).rejects.toThrow(
|
||||
/Failed to check export status: Task failed|Task failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes CAR HTTP response messages and generic failures', async () => {
|
||||
const { checkCarExportStatus, requestCarExport } = await loadPinmeApi({
|
||||
PINME_API_BASE: 'https://api.pinme.test',
|
||||
CAR_API_BASE: 'https://car.pinme.test',
|
||||
});
|
||||
nock('https://car.pinme.test')
|
||||
.post('/car/export')
|
||||
.query({ cid: 'http-fail', uid: 'uid-1' })
|
||||
.reply(503, { msg: 'CAR export unavailable' })
|
||||
.post('/car/export')
|
||||
.query({ cid: 'network-fail', uid: 'uid-1' })
|
||||
.replyWithError('socket closed')
|
||||
.get('/car/export/status')
|
||||
.query({ task_id: 'http-fail' })
|
||||
.reply(500, { msg: 'status unavailable' })
|
||||
.get('/car/export/status')
|
||||
.query({ task_id: 'network-fail' })
|
||||
.replyWithError('status socket closed');
|
||||
|
||||
await expect(requestCarExport('http-fail', 'uid-1')).rejects.toThrow(
|
||||
'CAR export unavailable',
|
||||
);
|
||||
await expect(requestCarExport('network-fail', 'uid-1')).rejects.toThrow(
|
||||
/Failed to request CAR export: socket closed/,
|
||||
);
|
||||
await expect(checkCarExportStatus('http-fail')).rejects.toThrow(
|
||||
'status unavailable',
|
||||
);
|
||||
await expect(checkCarExportStatus('network-fail')).rejects.toThrow(
|
||||
/Failed to check export status: status socket closed/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
const loginSource = readFileSync(new URL("../bin/login.ts", import.meta.url), "utf8");
|
||||
|
||||
test("pinme login tracking sends login method through source", () => {
|
||||
assert.match(loginSource, /source:\s*['"]cli['"]/);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import path from 'path';
|
||||
import { mkdir, readFile } from 'fs/promises';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { execa } from 'execa';
|
||||
import { dir } from 'tmp-promise';
|
||||
import { cliEntry, repoRoot } from '../helpers/cliRunner';
|
||||
|
||||
describe('npm package', () => {
|
||||
test('build output has a shebang and package bin points to it', async () => {
|
||||
const [entry, packageJsonRaw] = await Promise.all([
|
||||
readFile(cliEntry, 'utf8'),
|
||||
readFile(path.join(repoRoot, 'package.json'), 'utf8'),
|
||||
]);
|
||||
const packageJson = JSON.parse(packageJsonRaw);
|
||||
|
||||
expect(entry.split('\n')[0]).toBe('#!/usr/bin/env node');
|
||||
expect(packageJson.bin.pinme).toBe('./dist/index.js');
|
||||
});
|
||||
|
||||
test(
|
||||
'npm pack includes the CLI bundle and installable bin',
|
||||
async () => {
|
||||
const temp = await dir({
|
||||
prefix: 'pinme-pack-',
|
||||
unsafeCleanup: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const npmCache = path.join(temp.path, 'npm-cache');
|
||||
const pack = await execa(
|
||||
'npm',
|
||||
['pack', '--json', '--pack-destination', temp.path],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
npm_config_cache: npmCache,
|
||||
},
|
||||
},
|
||||
);
|
||||
const [packed] = JSON.parse(pack.stdout);
|
||||
const files = packed.files.map((file: { path: string }) => file.path);
|
||||
const tarball = path.join(temp.path, packed.filename);
|
||||
|
||||
expect(files.sort()).toEqual([
|
||||
'LICENSE',
|
||||
'README.md',
|
||||
'dist/index.js',
|
||||
'package.json',
|
||||
]);
|
||||
|
||||
const extractDir = path.join(temp.path, 'extract');
|
||||
await mkdir(extractDir);
|
||||
await execa('tar', ['-xzf', tarball, '-C', extractDir]);
|
||||
|
||||
const extractedPackageJson = JSON.parse(
|
||||
await readFile(path.join(extractDir, 'package', 'package.json'), 'utf8'),
|
||||
);
|
||||
const extractedEntry = await readFile(
|
||||
path.join(extractDir, 'package', 'dist', 'index.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(extractedPackageJson.bin.pinme).toBe('./dist/index.js');
|
||||
expect(extractedEntry.split('\n')[0]).toBe('#!/usr/bin/env node');
|
||||
await execa('node', [
|
||||
'--check',
|
||||
path.join(extractDir, 'package', 'dist', 'index.js'),
|
||||
]);
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { afterAll, afterEach, beforeAll } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
nock.enableNetConnect((host) => host.startsWith('127.0.0.1'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
CliError,
|
||||
createApiError,
|
||||
createConfigError,
|
||||
createCommandError,
|
||||
normalizeCliError,
|
||||
printCliError,
|
||||
printRechargeUrl,
|
||||
} from '../../bin/utils/cliError';
|
||||
|
||||
describe('cliError', () => {
|
||||
test('CliError constructor defaults details and suggestions', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new CliError({
|
||||
summary: 'Plain failure.',
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Plain failure.');
|
||||
expect(error.name).toBe('CliError');
|
||||
expect(error.stage).toBeUndefined();
|
||||
expect(error.details).toEqual([]);
|
||||
expect(error.suggestions).toEqual([]);
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
test('normalizes API business errors with request context', () => {
|
||||
const error = createApiError(
|
||||
'API request',
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
code: 40001,
|
||||
msg: 'Insufficient wallet balance',
|
||||
},
|
||||
},
|
||||
},
|
||||
['Request: POST /bind_dns'],
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(CliError);
|
||||
expect(error.message).toBe('Insufficient wallet balance');
|
||||
expect(error.details).toContain('Request: POST /bind_dns');
|
||||
expect(error.details).toContain('Business code: 40001');
|
||||
expect(error.details.join('\n')).toMatch(/Recharge URL:/);
|
||||
});
|
||||
|
||||
test('prefers nested API messages in documented order', () => {
|
||||
expect(
|
||||
createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
message: 'Nested message',
|
||||
error: 'Nested error',
|
||||
},
|
||||
errors: [{ message: 'Array message' }],
|
||||
error: 'Top-level error',
|
||||
},
|
||||
},
|
||||
}).message,
|
||||
).toBe('Nested message');
|
||||
|
||||
expect(
|
||||
createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
errors: [{ message: 'Array message' }],
|
||||
error: 'Top-level error',
|
||||
},
|
||||
},
|
||||
}).message,
|
||||
).toBe('Array message');
|
||||
});
|
||||
|
||||
test('normalizes HTTP errors without business code', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 503,
|
||||
data: { message: 'Service unavailable' },
|
||||
},
|
||||
message: 'Request failed with status code 503',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Service unavailable');
|
||||
expect(error.details).toContain('HTTP status: 503');
|
||||
expect(error.details.join('\n')).not.toMatch(/Reason:/);
|
||||
});
|
||||
|
||||
test('creates command errors with exit metadata', () => {
|
||||
const error = createCommandError(
|
||||
'frontend build',
|
||||
'npm run build:web',
|
||||
{
|
||||
status: 127,
|
||||
message: 'vite: command not found',
|
||||
},
|
||||
['Run npm install'],
|
||||
);
|
||||
|
||||
expect(error.details).toEqual([
|
||||
'Command: npm run build:web',
|
||||
'Exit code: 127',
|
||||
'Reason: vite: command not found',
|
||||
]);
|
||||
expect(error.suggestions).toEqual(['Run npm install']);
|
||||
});
|
||||
|
||||
test('creates command errors with code and signal metadata without suggestions', () => {
|
||||
const error = createCommandError('worker deploy', 'wrangler deploy', {
|
||||
code: 1,
|
||||
signal: 'SIGTERM',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('worker deploy failed.');
|
||||
expect(error.details).toEqual([
|
||||
'Command: wrangler deploy',
|
||||
'Exit code: 1',
|
||||
'Signal: SIGTERM',
|
||||
]);
|
||||
expect(error.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
test('normalizes unknown thrown values', () => {
|
||||
const error = normalizeCliError({ raw: true }, 'Fallback failed.');
|
||||
|
||||
expect(error.message).toBe('Fallback failed.');
|
||||
expect(error.details).toEqual(['Raw error: {"raw":true}']);
|
||||
});
|
||||
|
||||
test('normalizes null and primitive thrown values', () => {
|
||||
expect(normalizeCliError(undefined, 'Fallback failed.').details).toEqual([
|
||||
'Raw error: ',
|
||||
]);
|
||||
expect(normalizeCliError(null, 'Fallback failed.').details).toEqual([
|
||||
'Raw error: ',
|
||||
]);
|
||||
expect(normalizeCliError('plain failure', 'Fallback failed.').details).toEqual(
|
||||
['Raw error: plain failure'],
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes circular thrown values through string fallback', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
const error = normalizeCliError(circular, 'Fallback failed.');
|
||||
|
||||
expect(error.details).toEqual(['Raw error: [object Object]']);
|
||||
});
|
||||
|
||||
test('returns existing CliError instances unchanged', () => {
|
||||
const original = createConfigError('Missing config.', ['Create pinme.toml']);
|
||||
|
||||
expect(normalizeCliError(original, 'Fallback failed.')).toBe(original);
|
||||
});
|
||||
|
||||
test('normalizes regular Error instances with deduped suggestions', () => {
|
||||
const error = normalizeCliError(new Error('Boom'), 'Fallback failed.', [
|
||||
'Retry',
|
||||
'Retry',
|
||||
]);
|
||||
|
||||
expect(error.message).toBe('Boom');
|
||||
expect(error.suggestions).toEqual(['Retry']);
|
||||
});
|
||||
|
||||
test('normalizes Error instances with empty messages to the fallback summary', () => {
|
||||
const error = normalizeCliError(new Error(''), 'Fallback failed.');
|
||||
|
||||
expect(error.message).toBe('Fallback failed.');
|
||||
});
|
||||
|
||||
test('normalizes API-shaped thrown objects', () => {
|
||||
const error = normalizeCliError(
|
||||
{
|
||||
config: { method: 'get', url: '/wallet' },
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
error: 'Nested API failure',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Fallback failed.',
|
||||
);
|
||||
|
||||
expect(error.message).toBe('Nested API failure');
|
||||
expect(error.stage).toBe('API request');
|
||||
});
|
||||
|
||||
test('includes non-Axios error code when response data is absent', () => {
|
||||
const error = createApiError('API request', {
|
||||
code: 'ECONNRESET',
|
||||
message: 'socket hang up',
|
||||
});
|
||||
|
||||
expect(error.details).toContain('Error code: ECONNRESET');
|
||||
expect(error.details).not.toContain('Reason: socket hang up');
|
||||
});
|
||||
|
||||
test('uses string response data as summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 502,
|
||||
data: 'Bad gateway',
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Bad gateway');
|
||||
expect(error.details).toContain('HTTP status: 502');
|
||||
});
|
||||
|
||||
test('includes nested API detail when it differs from the summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
message: 'Top-level summary',
|
||||
data: {
|
||||
error: 'Nested detail',
|
||||
},
|
||||
},
|
||||
},
|
||||
message: 'Request failed with status code 400',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Top-level summary');
|
||||
expect(error.details).toContain('HTTP status: 400');
|
||||
expect(error.details).toContain('Error detail: Nested detail');
|
||||
expect(error.details).not.toContain(
|
||||
'Reason: Request failed with status code 400',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the API stage when no response message exists', () => {
|
||||
const error = createApiError('wallet lookup', {});
|
||||
|
||||
expect(error.message).toBe('wallet lookup failed.');
|
||||
expect(error.stage).toBe('wallet lookup');
|
||||
expect(error.details).toEqual([]);
|
||||
});
|
||||
|
||||
test('includes raw reasons for non-generic API failures', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 502,
|
||||
data: { error: 'Gateway wrapper failed' },
|
||||
},
|
||||
message: 'socket hang up',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Gateway wrapper failed');
|
||||
expect(error.details).toContain('HTTP status: 502');
|
||||
expect(error.details).toContain('Reason: socket hang up');
|
||||
});
|
||||
|
||||
test('includes stringified business messages when they differ from summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
code: 409,
|
||||
msg: 123,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.message).toBe('123');
|
||||
expect(error.details).toContain('Business code: 409');
|
||||
expect(error.details).toContain('Business message: 123');
|
||||
});
|
||||
|
||||
test('printRechargeUrl writes to stdout by default', () => {
|
||||
const messages: string[] = [];
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printRechargeUrl('https://wallet.pinme.test');
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Recharge URL:');
|
||||
expect(messages.join('\n')).toContain('https://wallet.pinme.test');
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('printCliError writes structured details and suggestions', () => {
|
||||
const originalError = createConfigError('Missing config.', [
|
||||
'Run pinme create app',
|
||||
]);
|
||||
originalError.details = ['Project root: /tmp/app'];
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(originalError, 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Error: Missing config.');
|
||||
expect(messages.join('\n')).toContain('Stage: configuration');
|
||||
expect(messages.join('\n')).toContain('Project root: /tmp/app');
|
||||
expect(messages.join('\n')).toContain('Run pinme create app');
|
||||
});
|
||||
|
||||
test('printCliError prints recharge URLs through the highlighted branch', () => {
|
||||
const error = createConfigError('Needs funds.');
|
||||
error.details = ['Recharge URL: https://wallet.pinme.test'];
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(error, 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Recharge URL:');
|
||||
expect(messages.join('\n')).toContain('https://wallet.pinme.test');
|
||||
});
|
||||
|
||||
test('printCliError skips next steps when suggestions are empty', () => {
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(createConfigError('No suggestions.'), 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).not.toContain('Next steps:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'PINME_API_BASE',
|
||||
'IPFS_API_URL',
|
||||
'CAR_API_BASE',
|
||||
'PINME_WEB_URL',
|
||||
'MAX_RETRIES',
|
||||
'RETRY_DELAY_MS',
|
||||
'TIMEOUT_MS',
|
||||
'MAX_POLL_TIME_MINUTES',
|
||||
'POLL_INTERVAL_SECONDS',
|
||||
'POLL_TIMEOUT_SECONDS',
|
||||
];
|
||||
|
||||
async function loadConfig(env: Record<string, string | undefined> = {}) {
|
||||
vi.resetModules();
|
||||
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, env);
|
||||
|
||||
return import('../../bin/utils/config');
|
||||
}
|
||||
|
||||
describe('config', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test('trims trailing slashes from configured base URLs', async () => {
|
||||
const { APP_CONFIG, getPinmeApiUrl, getIpfsApiUrl, getCarApiUrl } =
|
||||
await loadConfig({
|
||||
PINME_API_BASE: 'https://api.pinme.test///',
|
||||
IPFS_API_URL: 'https://ipfs.pinme.test/',
|
||||
CAR_API_BASE: 'https://car.pinme.test/',
|
||||
});
|
||||
|
||||
expect(APP_CONFIG.pinmeApiBase).toBe('https://api.pinme.test');
|
||||
expect(getPinmeApiUrl('root_domain')).toBe(
|
||||
'https://api.pinme.test/root_domain',
|
||||
);
|
||||
expect(getIpfsApiUrl('/upload')).toBe('https://ipfs.pinme.test/upload');
|
||||
expect(getCarApiUrl('car/export')).toBe(
|
||||
'https://car.pinme.test/car/export',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back when numeric environment values are invalid', async () => {
|
||||
const { APP_CONFIG } = await loadConfig({
|
||||
MAX_RETRIES: 'not-a-number',
|
||||
RETRY_DELAY_MS: '25',
|
||||
MAX_POLL_TIME_MINUTES: '2',
|
||||
POLL_INTERVAL_SECONDS: '3',
|
||||
POLL_TIMEOUT_SECONDS: '4',
|
||||
});
|
||||
|
||||
expect(APP_CONFIG.upload.maxRetries).toBe(2);
|
||||
expect(APP_CONFIG.upload.retryDelayMs).toBe(25);
|
||||
expect(APP_CONFIG.upload.maxPollTimeMs).toBe(120000);
|
||||
expect(APP_CONFIG.upload.pollIntervalMs).toBe(3000);
|
||||
expect(APP_CONFIG.upload.pollTimeoutMs).toBe(4000);
|
||||
});
|
||||
|
||||
test('selects test wallet recharge URL for test-like API bases', async () => {
|
||||
const { getWalletRechargeUrl } = await loadConfig({
|
||||
IPFS_API_URL: 'https://test-pinme.example/api',
|
||||
});
|
||||
|
||||
expect(getWalletRechargeUrl()).toContain('test-pinme');
|
||||
});
|
||||
|
||||
test('selects production wallet recharge URL by default', async () => {
|
||||
const { getWalletRechargeUrl } = await loadConfig({
|
||||
IPFS_API_URL: 'https://prod.example/api',
|
||||
});
|
||||
|
||||
expect(getWalletRechargeUrl()).toContain('pinme.eth.limo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
isDnsDomain,
|
||||
normalizeDomain,
|
||||
validateDnsDomain,
|
||||
} from '../../bin/utils/domainValidator';
|
||||
|
||||
describe('domainValidator', () => {
|
||||
test('normalizes protocol and a single trailing slash', () => {
|
||||
expect(normalizeDomain('https://example.com/')).toBe('example.com');
|
||||
expect(normalizeDomain('http://demo.pinme/')).toBe('demo.pinme');
|
||||
expect(normalizeDomain('xhttps://example.com/')).toBe(
|
||||
'xhttps://example.com',
|
||||
);
|
||||
expect(normalizeDomain('https://example.com/path/')).toBe(
|
||||
'example.com/path',
|
||||
);
|
||||
});
|
||||
|
||||
test('detects DNS domains after normalization', () => {
|
||||
expect(isDnsDomain('https://example.com/')).toBe(true);
|
||||
expect(isDnsDomain('my-site')).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts complete DNS domains', () => {
|
||||
expect(validateDnsDomain('example.com')).toEqual({ valid: true });
|
||||
expect(validateDnsDomain('sub.example.co')).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test('rejects incomplete and malformed DNS domains', () => {
|
||||
expect(validateDnsDomain('localhost').valid).toBe(false);
|
||||
expect(validateDnsDomain('example..com').message).toMatch(/Consecutive/);
|
||||
expect(validateDnsDomain('-example.com').message).toMatch(/hyphens/);
|
||||
expect(validateDnsDomain('example-.com').message).toMatch(/hyphens/);
|
||||
expect(validateDnsDomain('exa_mple.com').message).toMatch(
|
||||
/letters, numbers, and hyphens/,
|
||||
);
|
||||
expect(validateDnsDomain('example.com.evil1').valid).toBe(false);
|
||||
expect(validateDnsDomain('prefix example.com').valid).toBe(false);
|
||||
expect(validateDnsDomain('example.com/path').valid).toBe(false);
|
||||
expect(validateDnsDomain('example.com?x=1').valid).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects labels longer than 63 characters', () => {
|
||||
const maxLabel = 'a'.repeat(63);
|
||||
const longLabel = 'a'.repeat(64);
|
||||
expect(validateDnsDomain(`${maxLabel}.com`).valid).toBe(true);
|
||||
expect(validateDnsDomain(`${longLabel}.com`).message).toMatch(
|
||||
/63 characters/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects empty labels in multiple positions', () => {
|
||||
expect(validateDnsDomain('.example.com').message).toMatch(/Consecutive/);
|
||||
expect(validateDnsDomain('example.com.').message).toMatch(/Consecutive/);
|
||||
expect(validateDnsDomain('example...com').message).toMatch(/Consecutive/);
|
||||
});
|
||||
|
||||
test('property: valid simple domains are accepted', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.stringMatching(/^[a-z0-9]([a-z0-9-]{0,10}[a-z0-9])?$/), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
fc.stringMatching(/^[a-z]{2,10}$/),
|
||||
(labels, tld) => {
|
||||
fc.pre(labels.every((label) => label.length > 0));
|
||||
expect(validateDnsDomain([...labels, tld].join('.')).valid).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
let tempHome: string | undefined;
|
||||
let originalHome: string | undefined;
|
||||
const trackEvent = vi.fn();
|
||||
const getRootDomain = vi.fn(async () => 'pinme.test');
|
||||
|
||||
async function loadHistory(fsMock?: Record<string, unknown>) {
|
||||
vi.resetModules();
|
||||
tempHome = mkdtempSync(path.join(tmpdir(), 'pinme-history-home-'));
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tempHome;
|
||||
if (fsMock) {
|
||||
vi.doMock('fs-extra', () => ({
|
||||
...fsMock,
|
||||
default: fsMock,
|
||||
}));
|
||||
}
|
||||
vi.doMock('os', () => ({
|
||||
homedir: () => tempHome,
|
||||
default: {
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
}));
|
||||
vi.doMock('node:os', () => ({
|
||||
homedir: () => tempHome,
|
||||
default: {
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../bin/utils/tracker', () => ({
|
||||
default: { trackEvent },
|
||||
getTrackErrorReason: (error: unknown) =>
|
||||
error instanceof Error ? error.message : 'unknown_error',
|
||||
}));
|
||||
vi.doMock('../../bin/utils/pinmeApi', () => ({
|
||||
getRootDomain,
|
||||
}));
|
||||
return import('../../bin/utils/history');
|
||||
}
|
||||
|
||||
describe('history', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('os');
|
||||
vi.doUnmock('node:os');
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
originalHome = undefined;
|
||||
vi.doUnmock('fs-extra');
|
||||
vi.doUnmock('../../bin/utils/tracker');
|
||||
vi.doUnmock('../../bin/utils/pinmeApi');
|
||||
vi.clearAllMocks();
|
||||
if (tempHome) {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('saves and reads upload history newest first', async () => {
|
||||
const { saveUploadHistory, getUploadHistory } = await loadHistory();
|
||||
|
||||
expect(
|
||||
saveUploadHistory({
|
||||
path: '/tmp/one',
|
||||
contentHash: 'bafy-one',
|
||||
previewHash: null,
|
||||
size: 10,
|
||||
fileCount: 1,
|
||||
isDirectory: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
saveUploadHistory({
|
||||
path: '/tmp/two',
|
||||
filename: 'two',
|
||||
contentHash: 'bafy-two',
|
||||
previewHash: null,
|
||||
size: 20,
|
||||
fileCount: 2,
|
||||
isDirectory: true,
|
||||
pinmeUrl: 'demo',
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(getUploadHistory(1)).toMatchObject([
|
||||
{
|
||||
filename: 'two',
|
||||
contentHash: 'bafy-two',
|
||||
fileCount: 2,
|
||||
type: 'directory',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('displays preferred URLs and totals', async () => {
|
||||
const { saveUploadHistory, displayUploadHistory } = await loadHistory();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
filename: 'site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 2048,
|
||||
fileCount: 3,
|
||||
isDirectory: true,
|
||||
pinmeUrl: 'demo',
|
||||
});
|
||||
await displayUploadHistory(10);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const output = messages.join('\n');
|
||||
expect(output).toContain('Upload History:');
|
||||
expect(output).toContain('1. site');
|
||||
expect(output).toContain('Path: /tmp/site');
|
||||
expect(output).toContain('IPFS CID: bafy-site');
|
||||
expect(output).toContain('https://demo.pinme.test');
|
||||
expect(output).toContain('Total Uploads: 1');
|
||||
expect(output).toContain('Total Files: 3');
|
||||
expect(output).toContain('Total Size: 2.00 KB');
|
||||
expect(output).toContain('Type: Directory');
|
||||
expect(trackEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearUploadHistory empties records', async () => {
|
||||
const { saveUploadHistory, getUploadHistory, clearUploadHistory } =
|
||||
await loadHistory();
|
||||
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 1,
|
||||
});
|
||||
|
||||
expect(clearUploadHistory()).toBe(true);
|
||||
expect(getUploadHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
test('displayUploadHistory handles missing root domain for bare URLs', async () => {
|
||||
const { saveUploadHistory, displayUploadHistory } = await loadHistory();
|
||||
getRootDomain.mockRejectedValueOnce(new Error('root domain unavailable'));
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
filename: 'site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 1,
|
||||
shortUrl: 'short',
|
||||
});
|
||||
await displayUploadHistory(10);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('https://short');
|
||||
});
|
||||
|
||||
test('displayUploadHistory reports an empty isolated history', async () => {
|
||||
const { displayUploadHistory } = await loadHistory();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
await displayUploadHistory(5);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('No upload history found.');
|
||||
expect(trackEvent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
record_count: 0,
|
||||
limit: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('displayUploadHistory prefers DNS URLs over PinMe and short URLs', async () => {
|
||||
const { saveUploadHistory, displayUploadHistory } = await loadHistory();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
filename: 'site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 1024,
|
||||
pinmeUrl: 'demo',
|
||||
shortUrl: 'short',
|
||||
dnsUrl: 'https://docs.example.com/',
|
||||
});
|
||||
await displayUploadHistory(10);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const output = messages.join('\n');
|
||||
expect(output).toContain('URL: https://docs.example.com');
|
||||
expect(output).not.toContain('https://demo.pinme.test');
|
||||
expect(output).not.toContain('https://short.pinme.test');
|
||||
});
|
||||
|
||||
test('getUploadHistory returns an empty list for malformed history files', async () => {
|
||||
const { getUploadHistory } = await loadHistory();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
mkdirSync(path.join(tempHome!, '.pinme'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tempHome!, '.pinme', 'upload-history.json'),
|
||||
'{not json',
|
||||
);
|
||||
|
||||
try {
|
||||
expect(getUploadHistory()).toEqual([]);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Error reading upload history:');
|
||||
});
|
||||
|
||||
test('save and clear report false when history storage cannot be written', async () => {
|
||||
const fsMock = {
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
readJsonSync: vi.fn(() => ({ uploads: [] })),
|
||||
writeJsonSync: vi.fn(() => {
|
||||
throw new Error('disk denied');
|
||||
}),
|
||||
};
|
||||
const { saveUploadHistory, clearUploadHistory } = await loadHistory(fsMock);
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
expect(
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(clearUploadHistory()).toBe(false);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fsMock.mkdirSync).toHaveBeenCalledWith(expect.any(String), {
|
||||
recursive: true,
|
||||
});
|
||||
expect(messages.join('\n')).toContain('Error saving upload history:');
|
||||
expect(messages.join('\n')).toContain('Error clearing upload history:');
|
||||
expect(trackEvent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({ action: 'clear', reason: 'disk denied' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('formatHistoryUrl normalizes blank, absolute, dotted, and bare values', async () => {
|
||||
const { formatHistoryUrl } = await loadHistory();
|
||||
|
||||
await expect(formatHistoryUrl()).resolves.toBeNull();
|
||||
await expect(formatHistoryUrl(' ')).resolves.toBeNull();
|
||||
await expect(formatHistoryUrl('https://example.com/path/')).resolves.toBe(
|
||||
'https://example.com/path',
|
||||
);
|
||||
await expect(formatHistoryUrl('demo.example')).resolves.toBe(
|
||||
'https://demo.example',
|
||||
);
|
||||
await expect(
|
||||
formatHistoryUrl('demo', {
|
||||
appendRootDomain: true,
|
||||
rootDomain: 'pinme.test',
|
||||
}),
|
||||
).resolves.toBe('https://demo.pinme.test');
|
||||
await expect(
|
||||
formatHistoryUrl('http://demo/', {
|
||||
appendRootDomain: true,
|
||||
rootDomain: 'pinme.test',
|
||||
}),
|
||||
).resolves.toBe('http://demo.pinme.test');
|
||||
await expect(
|
||||
formatHistoryUrl('http://[bad', {
|
||||
appendRootDomain: true,
|
||||
rootDomain: 'pinme.test',
|
||||
}),
|
||||
).resolves.toBe('http://[bad');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
calculateDirectorySize,
|
||||
checkDirectorySizeLimit,
|
||||
checkFileSizeLimit,
|
||||
formatSize,
|
||||
} from '../../bin/utils/uploadLimits';
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
function makeTempDir(): string {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-upload-limits-'));
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
describe('uploadLimits', () => {
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('checks file size against the default limit', () => {
|
||||
const root = makeTempDir();
|
||||
const filePath = path.join(root, 'index.html');
|
||||
writeFileSync(filePath, Buffer.alloc(128));
|
||||
|
||||
expect(checkFileSizeLimit(filePath)).toMatchObject({
|
||||
size: 128,
|
||||
exceeds: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('detects file and directory sizes above configured limits', async () => {
|
||||
vi.resetModules();
|
||||
process.env.FILE_SIZE_LIMIT = '0';
|
||||
process.env.DIRECTORY_SIZE_LIMIT = '0';
|
||||
const limits = await import('../../bin/utils/uploadLimits');
|
||||
const root = makeTempDir();
|
||||
const filePath = path.join(root, 'index.html');
|
||||
writeFileSync(filePath, Buffer.alloc(1));
|
||||
|
||||
expect(limits.checkFileSizeLimit(filePath)).toMatchObject({
|
||||
size: 1,
|
||||
limit: 0,
|
||||
exceeds: true,
|
||||
});
|
||||
expect(limits.checkDirectorySizeLimit(root)).toMatchObject({
|
||||
size: 1,
|
||||
limit: 0,
|
||||
exceeds: true,
|
||||
});
|
||||
delete process.env.FILE_SIZE_LIMIT;
|
||||
delete process.env.DIRECTORY_SIZE_LIMIT;
|
||||
});
|
||||
|
||||
test('treats sizes equal to the configured limit as not exceeding', async () => {
|
||||
vi.resetModules();
|
||||
process.env.FILE_SIZE_LIMIT = '1';
|
||||
process.env.DIRECTORY_SIZE_LIMIT = '1';
|
||||
const limits = await import('../../bin/utils/uploadLimits');
|
||||
const root = makeTempDir();
|
||||
const filePath = path.join(root, 'one-mb.bin');
|
||||
writeFileSync(filePath, Buffer.alloc(1024 * 1024));
|
||||
|
||||
expect(limits.checkFileSizeLimit(filePath).exceeds).toBe(false);
|
||||
expect(limits.checkDirectorySizeLimit(root).exceeds).toBe(false);
|
||||
delete process.env.FILE_SIZE_LIMIT;
|
||||
delete process.env.DIRECTORY_SIZE_LIMIT;
|
||||
});
|
||||
|
||||
test('calculates nested directory size', () => {
|
||||
const root = makeTempDir();
|
||||
mkdirSync(path.join(root, 'assets'), { recursive: true });
|
||||
writeFileSync(path.join(root, 'index.html'), Buffer.alloc(10));
|
||||
writeFileSync(path.join(root, 'assets', 'app.js'), Buffer.alloc(15));
|
||||
|
||||
expect(calculateDirectorySize(root)).toBe(25);
|
||||
expect(checkDirectorySizeLimit(root)).toMatchObject({
|
||||
size: 25,
|
||||
exceeds: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('formats human readable sizes', () => {
|
||||
expect(formatSize(12)).toBe('12 bytes');
|
||||
expect(formatSize(1024)).toBe('1.00 KB');
|
||||
expect(formatSize(2048)).toBe('2.00 KB');
|
||||
expect(formatSize(1024 * 1024)).toBe('1.00 MB');
|
||||
expect(formatSize(3 * 1024 * 1024)).toBe('3.00 MB');
|
||||
expect(formatSize(1024 * 1024 * 1024)).toBe('1.00 GB');
|
||||
expect(formatSize(2 * 1024 * 1024 * 1024)).toBe('2.00 GB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const uploadToIpfsSplit = vi.fn();
|
||||
const getAuthConfig = vi.fn();
|
||||
const getRootDomain = vi.fn(async () => 'pinme.test');
|
||||
const getUid = vi.fn(() => 'device-uid');
|
||||
|
||||
vi.mock('../../bin/utils/uploadToIpfsSplit', () => ({
|
||||
default: uploadToIpfsSplit,
|
||||
}));
|
||||
|
||||
vi.mock('../../bin/utils/webLogin', () => ({
|
||||
getAuthConfig,
|
||||
}));
|
||||
|
||||
vi.mock('../../bin/utils/pinmeApi', () => ({
|
||||
getRootDomain,
|
||||
}));
|
||||
|
||||
vi.mock('../../bin/utils/getDeviceId', () => ({
|
||||
getUid,
|
||||
}));
|
||||
|
||||
async function loadService(env: Record<string, string | undefined> = {}) {
|
||||
vi.resetModules();
|
||||
process.env.IPFS_PREVIEW_URL =
|
||||
env.IPFS_PREVIEW_URL || 'https://preview.pinme.test/#/preview/';
|
||||
process.env.PROJECT_PREVIEW_URL =
|
||||
env.PROJECT_PREVIEW_URL || 'https://project.pinme.test/';
|
||||
if ('SECRET_KEY' in env) {
|
||||
if (env.SECRET_KEY === undefined) {
|
||||
delete process.env.SECRET_KEY;
|
||||
} else {
|
||||
process.env.SECRET_KEY = env.SECRET_KEY;
|
||||
}
|
||||
} else {
|
||||
delete process.env.SECRET_KEY;
|
||||
}
|
||||
return import('../../bin/services/uploadService');
|
||||
}
|
||||
|
||||
describe('uploadService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.doUnmock('crypto-js');
|
||||
getRootDomain.mockResolvedValue('pinme.test');
|
||||
getUid.mockReturnValue('device-uid');
|
||||
});
|
||||
|
||||
test('prefers DNS URL over PinMe, short, and management URLs', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
const result = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{
|
||||
dnsUrl: 'example.com/',
|
||||
pinmeUrl: 'my-site',
|
||||
shortUrl: 'short',
|
||||
},
|
||||
undefined,
|
||||
'uid-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
publicUrl: 'https://example.com',
|
||||
managementUrl: 'https://preview.pinme.test/#/preview/bafybeicid',
|
||||
});
|
||||
});
|
||||
|
||||
test('appends root domain for bare PinMe subdomains', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls('bafybeicid', { pinmeUrl: 'demo' }, undefined, 'uid-1'),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://demo.pinme.test',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps absolute and dotted short URLs without root-domain lookup', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ shortUrl: 'https://already.example/path/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://already.example/path/',
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ shortUrl: 'http://already.example/path/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'http://already.example/path/',
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ shortUrl: 'short.example' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://short.example',
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ shortUrl: 'xhttp://short.example/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://xhttp://short.example/',
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts http PinMe URLs and preserves explicit protocol', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ pinmeUrl: 'http://demo.pinme.test/path/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'http://demo.pinme.test/path',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to protocol-prefixed text for invalid preferred URLs', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ dnsUrl: 'bad host/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://bad host',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not treat embedded protocol text as an absolute preferred URL', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ dnsUrl: 'xhttp://example.com/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://xhttp//example.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to bare subdomain when root domain lookup fails', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
getRootDomain.mockRejectedValueOnce(new Error('root domain failed'));
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls('bafybeicid', { pinmeUrl: 'demo' }, undefined, 'uid-1'),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://demo',
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores blank preferred URLs and falls back to management URL', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ dnsUrl: ' ', pinmeUrl: '', shortUrl: ' ' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toEqual({
|
||||
publicUrl: 'https://preview.pinme.test/#/preview/bafybeicid',
|
||||
managementUrl: 'https://preview.pinme.test/#/preview/bafybeicid',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to project management URL when project name is present', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls('bafybeicid', undefined, 'demo-project', 'uid-1'),
|
||||
).resolves.toEqual({
|
||||
publicUrl: 'https://project.pinme.test/demo-project',
|
||||
managementUrl: 'https://project.pinme.test/demo-project',
|
||||
});
|
||||
});
|
||||
|
||||
test('trims project names and falls back to device uid when uid is blank', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls('bafybeicid', undefined, ' demo-project ', ' '),
|
||||
).resolves.toEqual({
|
||||
publicUrl: 'https://project.pinme.test/demo-project',
|
||||
managementUrl: 'https://project.pinme.test/demo-project',
|
||||
});
|
||||
expect(getUid).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uses secretKey to hide raw CID in preview management URLs', async () => {
|
||||
const { resolveUploadUrls } = await loadService({ SECRET_KEY: 'secret' });
|
||||
|
||||
const result = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
undefined,
|
||||
undefined,
|
||||
'uid-1',
|
||||
);
|
||||
|
||||
expect(result.managementUrl).toMatch(
|
||||
/^https:\/\/preview\.pinme\.test\/#\/preview\/.+/,
|
||||
);
|
||||
expect(result.managementUrl).not.toContain('bafybeicid');
|
||||
expect(result.publicUrl).toBe(result.managementUrl);
|
||||
});
|
||||
|
||||
test('secretKey encryption produces URL-safe CID tokens that include uid input', async () => {
|
||||
const { resolveUploadUrls } = await loadService({ SECRET_KEY: 'secret' });
|
||||
|
||||
const first = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
undefined,
|
||||
undefined,
|
||||
'uid-1',
|
||||
);
|
||||
const second = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
undefined,
|
||||
undefined,
|
||||
'uid-2',
|
||||
);
|
||||
const firstToken = first.managementUrl.split('/preview/').at(-1)!;
|
||||
const secondToken = second.managementUrl.split('/preview/').at(-1)!;
|
||||
|
||||
expect(firstToken).not.toBe(secondToken);
|
||||
expect(firstToken).not.toContain('bafybeicid');
|
||||
expect(firstToken).not.toMatch(/[+/=]/);
|
||||
expect(secondToken).not.toMatch(/[+/=]/);
|
||||
});
|
||||
|
||||
test('secretKey encryption sanitizes plus slash and padding deterministically', async () => {
|
||||
const encrypt = vi.fn((message: string) => ({
|
||||
toString: () => `+/${message}==`,
|
||||
}));
|
||||
vi.doMock('crypto-js', () => ({
|
||||
default: {
|
||||
RC4: { encrypt },
|
||||
},
|
||||
}));
|
||||
const { resolveUploadUrls } = await loadService({ SECRET_KEY: 'secret' });
|
||||
|
||||
const result = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
undefined,
|
||||
undefined,
|
||||
'uid-1',
|
||||
);
|
||||
|
||||
expect(encrypt).toHaveBeenCalledWith('bafybeicid-uid-1', 'secret');
|
||||
expect(result.managementUrl).toBe(
|
||||
'https://preview.pinme.test/#/preview/-_bafybeicid-uid-1',
|
||||
);
|
||||
expect(result.managementUrl.split('/preview/').at(-1)).not.toMatch(
|
||||
/[+/=]/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('uploadPath rejects when auth config is absent', async () => {
|
||||
const { uploadPath } = await loadService();
|
||||
getAuthConfig.mockReturnValue(null);
|
||||
|
||||
await expect(uploadPath('/tmp/site')).rejects.toThrow(/Please login first/);
|
||||
expect(uploadToIpfsSplit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uploadPath returns normalized upload result URLs', async () => {
|
||||
const { uploadPath } = await loadService();
|
||||
getAuthConfig.mockReturnValue({
|
||||
address: '0xabc',
|
||||
token: 'token',
|
||||
});
|
||||
uploadToIpfsSplit.mockResolvedValue({
|
||||
contentHash: 'bafybeicid',
|
||||
shortUrl: 'short',
|
||||
});
|
||||
|
||||
await expect(uploadPath('/tmp/site', { action: 'upload' })).resolves.toEqual({
|
||||
contentHash: 'bafybeicid',
|
||||
shortUrl: 'short',
|
||||
pinmeUrl: undefined,
|
||||
dnsUrl: undefined,
|
||||
publicUrl: 'https://short.pinme.test',
|
||||
managementUrl: 'https://preview.pinme.test/#/preview/bafybeicid',
|
||||
});
|
||||
expect(uploadToIpfsSplit).toHaveBeenCalledWith('/tmp/site', {
|
||||
action: 'upload',
|
||||
importAsCar: undefined,
|
||||
projectName: undefined,
|
||||
uid: '0xabc',
|
||||
});
|
||||
});
|
||||
|
||||
test('uploadPath rejects upload responses without a content hash', async () => {
|
||||
const { uploadPath } = await loadService();
|
||||
getAuthConfig.mockReturnValue({
|
||||
address: '0xabc',
|
||||
token: 'token',
|
||||
});
|
||||
uploadToIpfsSplit.mockResolvedValue({});
|
||||
|
||||
await expect(uploadPath('/tmp/site')).rejects.toThrow(/no content hash/);
|
||||
|
||||
uploadToIpfsSplit.mockResolvedValueOnce(null);
|
||||
await expect(uploadPath('/tmp/site')).rejects.toThrow(/no content hash/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import fs from 'fs-extra';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
let tempHome: string | undefined;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
async function loadWebLogin() {
|
||||
vi.resetModules();
|
||||
tempHome = mkdtempSync(path.join(tmpdir(), 'pinme-web-login-home-'));
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tempHome;
|
||||
vi.doMock('os', () => ({
|
||||
homedir: () => tempHome,
|
||||
default: {
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
}));
|
||||
vi.doMock('node:os', () => ({
|
||||
homedir: () => tempHome,
|
||||
default: {
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
}));
|
||||
return import('../../bin/utils/webLogin');
|
||||
}
|
||||
|
||||
describe('webLogin', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('os');
|
||||
vi.doUnmock('node:os');
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
originalHome = undefined;
|
||||
if (tempHome) {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('stores and reads auth tokens with auth headers', async () => {
|
||||
const { setAuthToken, getAuthConfig, getAuthHeaders } = await loadWebLogin();
|
||||
|
||||
expect(setAuthToken('0xabc-jwt-token')).toEqual({
|
||||
address: '0xabc',
|
||||
token: 'jwt-token',
|
||||
});
|
||||
expect(getAuthConfig()).toEqual({
|
||||
address: '0xabc',
|
||||
token: 'jwt-token',
|
||||
});
|
||||
expect(getAuthHeaders()).toEqual({
|
||||
'token-address': '0xabc',
|
||||
'authentication-tokens': 'jwt-token',
|
||||
});
|
||||
|
||||
expect(getAuthConfig()).toMatchObject({ address: '0xabc' });
|
||||
});
|
||||
|
||||
test('trims address and token content before storing auth', async () => {
|
||||
const { setAuthToken, getAuthConfig, getAuthHeaders } = await loadWebLogin();
|
||||
|
||||
expect(setAuthToken(' 0xabc - jwt-token ')).toEqual({
|
||||
address: '0xabc',
|
||||
token: 'jwt-token',
|
||||
});
|
||||
expect(getAuthConfig()).toEqual({
|
||||
address: '0xabc',
|
||||
token: 'jwt-token',
|
||||
});
|
||||
expect(getAuthHeaders()).toEqual({
|
||||
'token-address': '0xabc',
|
||||
'authentication-tokens': 'jwt-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects malformed combined auth tokens', async () => {
|
||||
const { setAuthToken } = await loadWebLogin();
|
||||
|
||||
expect(() => setAuthToken('-jwt-token')).toThrow(
|
||||
/Address or token is empty|Invalid token/,
|
||||
);
|
||||
expect(() => setAuthToken('0xabc-')).toThrow(/Invalid token format/);
|
||||
expect(() => setAuthToken('missingdash')).toThrow(/Invalid token format/);
|
||||
expect(() => setAuthToken(' -jwt-token')).toThrow(
|
||||
'Invalid token content. Address or token is empty.',
|
||||
);
|
||||
expect(() => setAuthToken('0xabc- ')).toThrow(
|
||||
'Invalid token content. Address or token is empty.',
|
||||
);
|
||||
});
|
||||
|
||||
test('clears auth tokens and makes headers unavailable', async () => {
|
||||
const { setAuthToken, clearAuthToken, getAuthConfig, getAuthHeaders } =
|
||||
await loadWebLogin();
|
||||
|
||||
setAuthToken('0xabc-jwt-token');
|
||||
clearAuthToken();
|
||||
|
||||
expect(getAuthConfig()).toBeNull();
|
||||
expect(() => getAuthHeaders()).toThrow('Auth not set. Run: pinme login');
|
||||
});
|
||||
|
||||
test('getAuthConfig returns null for malformed or incomplete auth files', async () => {
|
||||
const { getAuthConfig } = await loadWebLogin();
|
||||
const authDir = path.join(tempHome!, '.pinme');
|
||||
const authFile = path.join(authDir, 'auth.json');
|
||||
fs.ensureDirSync(authDir);
|
||||
|
||||
fs.writeJsonSync(authFile, { address: '0xabc' });
|
||||
expect(getAuthConfig()).toBeNull();
|
||||
|
||||
fs.writeFileSync(authFile, '{bad');
|
||||
expect(getAuthConfig()).toBeNull();
|
||||
});
|
||||
|
||||
test('login delegates to the singleton web login manager', async () => {
|
||||
const { login, webLoginManager } = await loadWebLogin();
|
||||
const authConfig = { address: '0xabc', token: 'jwt-token' };
|
||||
const spy = vi
|
||||
.spyOn(webLoginManager, 'login')
|
||||
.mockResolvedValue(authConfig);
|
||||
|
||||
try {
|
||||
await expect(login()).resolves.toBe(authConfig);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('logout clears auth and reports success', async () => {
|
||||
const { setAuthToken, logout, getAuthConfig } = await loadWebLogin();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
setAuthToken('0xabc-jwt-token');
|
||||
await logout();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(getAuthConfig()).toBeNull();
|
||||
expect(messages.join('\n')).toContain('Logged out successfully');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { test } from 'vitest';
|
||||
|
||||
async function loadHelper() {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-install-helper-'));
|
||||
const outfile = path.join(tempDir, 'installProjectDependencies.cjs');
|
||||
|
||||
await build({
|
||||
entryPoints: [path.resolve('bin/utils/installProjectDependencies.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
return import(pathToFileURL(outfile).href);
|
||||
}
|
||||
|
||||
function makeProject() {
|
||||
return mkdtempSync(path.join(tmpdir(), 'pinme-install-project-'));
|
||||
}
|
||||
|
||||
test('readBackgroundInstallStatus treats fresh log-only markers as running', async () => {
|
||||
const {
|
||||
INSTALL_LOG_FILE,
|
||||
readBackgroundInstallStatus,
|
||||
} = await loadHelper();
|
||||
const projectDir = makeProject();
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(projectDir, INSTALL_LOG_FILE), 'starting npm ci\n');
|
||||
|
||||
assert.deepEqual(readBackgroundInstallStatus(projectDir), {
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
logPath: path.join(projectDir, INSTALL_LOG_FILE),
|
||||
});
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readBackgroundInstallStatus detects stale log-only markers as interrupted', async () => {
|
||||
const {
|
||||
INSTALL_LOG_FILE,
|
||||
readBackgroundInstallStatus,
|
||||
} = await loadHelper();
|
||||
const projectDir = makeProject();
|
||||
const logPath = path.join(projectDir, INSTALL_LOG_FILE);
|
||||
|
||||
try {
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(logPath, 'starting npm ci\n');
|
||||
const staleTime = new Date(Date.now() - 2 * 60 * 1000);
|
||||
utimesSync(logPath, staleTime, staleTime);
|
||||
|
||||
assert.deepEqual(readBackgroundInstallStatus(projectDir), {
|
||||
status: 'interrupted',
|
||||
exitCode: null,
|
||||
logPath,
|
||||
});
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readBackgroundInstallStatus detects dead background install pid as interrupted', async () => {
|
||||
const {
|
||||
INSTALL_LOG_FILE,
|
||||
INSTALL_PID_FILE,
|
||||
readBackgroundInstallStatus,
|
||||
} = await loadHelper();
|
||||
const projectDir = makeProject();
|
||||
const logPath = path.join(projectDir, INSTALL_LOG_FILE);
|
||||
|
||||
try {
|
||||
writeFileSync(logPath, 'starting npm ci\n');
|
||||
writeFileSync(path.join(projectDir, INSTALL_PID_FILE), '999999999');
|
||||
|
||||
assert.deepEqual(readBackgroundInstallStatus(projectDir), {
|
||||
status: 'interrupted',
|
||||
exitCode: null,
|
||||
logPath,
|
||||
});
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stopBackgroundInstall clears a dead background install pid marker', async () => {
|
||||
const {
|
||||
INSTALL_LOG_FILE,
|
||||
INSTALL_PID_FILE,
|
||||
stopBackgroundInstall,
|
||||
} = await loadHelper();
|
||||
const projectDir = makeProject();
|
||||
const logPath = path.join(projectDir, INSTALL_LOG_FILE);
|
||||
const pidPath = path.join(projectDir, INSTALL_PID_FILE);
|
||||
|
||||
try {
|
||||
writeFileSync(logPath, 'starting npm ci\n');
|
||||
writeFileSync(pidPath, '999999999');
|
||||
|
||||
assert.equal(await stopBackgroundInstall(projectDir), false);
|
||||
assert.equal(readFileExists(pidPath), false);
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('buildBackgroundInstallCommand captures Windows npm exit code after command finishes', async () => {
|
||||
const {
|
||||
buildBackgroundInstallCommand,
|
||||
} = await loadHelper();
|
||||
|
||||
const command = buildBackgroundInstallCommand(
|
||||
'ci',
|
||||
'C:\\Users\\Pin Me\\project\\.pinme-install.log',
|
||||
'C:\\Users\\Pin Me\\project\\.pinme-install.exitcode',
|
||||
'win32',
|
||||
);
|
||||
|
||||
assert.deepEqual(command.shellArgs.slice(0, 4), ['/d', '/s', '/v:on', '/c']);
|
||||
assert.match(command.shellArgs[4], /echo !errorlevel! >/);
|
||||
assert.doesNotMatch(command.shellArgs[4], /%errorlevel%/);
|
||||
assert.match(command.shellArgs[4], /"C:\\Users\\Pin Me\\project\\.pinme-install.log"/);
|
||||
});
|
||||
|
||||
function readFileExists(filePath) {
|
||||
return existsSync(filePath);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { test } from 'vitest';
|
||||
|
||||
async function loadHelper() {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-download-helper-'));
|
||||
const outfile = path.join(tempDir, 'downloadFile.cjs');
|
||||
|
||||
await build({
|
||||
entryPoints: [path.resolve('bin/utils/downloadFile.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
const helper = await import(pathToFileURL(outfile).href);
|
||||
return {
|
||||
helper,
|
||||
cleanup: () => rmSync(tempDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
test('downloadFileWithRetries downloads through Node HTTP without curl', async () => {
|
||||
const { helper, cleanup } = await loadHelper();
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-download-target-'));
|
||||
const destination = path.join(tempDir, 'template.zip');
|
||||
const body = Buffer.alloc(256, 'a');
|
||||
|
||||
try {
|
||||
const result = await helper.downloadFileWithRetries('https://example.test/template.zip', destination, {
|
||||
attempts: 3,
|
||||
retryDelayMs: 1,
|
||||
minBytes: 100,
|
||||
request: async () => ({ data: Readable.from([body]) }),
|
||||
});
|
||||
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(result.bytes, body.length);
|
||||
assert.deepEqual(readFileSync(destination), body);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('downloadFileWithRetries retries HTTP failures', async () => {
|
||||
const { helper, cleanup } = await loadHelper();
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-download-retry-'));
|
||||
const destination = path.join(tempDir, 'template.zip');
|
||||
const body = Buffer.alloc(256, 'b');
|
||||
let requests = 0;
|
||||
|
||||
try {
|
||||
const failures = [];
|
||||
const result = await helper.downloadFileWithRetries('https://example.test/template.zip', destination, {
|
||||
attempts: 3,
|
||||
retryDelayMs: 1,
|
||||
minBytes: 100,
|
||||
request: async () => {
|
||||
requests += 1;
|
||||
|
||||
if (requests < 3) {
|
||||
const error = new Error('Request failed with status code 503');
|
||||
error.response = {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { data: Readable.from([body]) };
|
||||
},
|
||||
onAttemptFailure: (attempt, error) => {
|
||||
failures.push({ attempt, message: helper.getDownloadErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.attempts, 3);
|
||||
assert.equal(requests, 3);
|
||||
assert.deepEqual(failures, [
|
||||
{ attempt: 1, message: 'HTTP 503 Service Unavailable' },
|
||||
{ attempt: 2, message: 'HTTP 503 Service Unavailable' },
|
||||
]);
|
||||
assert.deepEqual(readFileSync(destination), body);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('downloadFileWithRetries rejects tiny downloads and removes partial files', async () => {
|
||||
const { helper, cleanup } = await loadHelper();
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-download-small-'));
|
||||
const destination = path.join(tempDir, 'template.zip');
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => helper.downloadFileWithRetries('https://example.test/template.zip', destination, {
|
||||
attempts: 2,
|
||||
retryDelayMs: 1,
|
||||
minBytes: 100,
|
||||
request: async () => ({ data: Readable.from(['tiny']) }),
|
||||
}),
|
||||
/Downloaded file is too small/,
|
||||
);
|
||||
|
||||
assert.throws(() => readFileSync(destination), /ENOENT/);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { test } from 'vitest';
|
||||
|
||||
async function loadHelper() {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-prebuilt-helper-'));
|
||||
const outfile = path.join(tempDir, 'prebuiltDistConfig.cjs');
|
||||
|
||||
await build({
|
||||
entryPoints: [path.resolve('bin/utils/prebuiltDistConfig.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
return import(pathToFileURL(outfile).href);
|
||||
}
|
||||
|
||||
function makeDist(files) {
|
||||
const distDir = mkdtempSync(path.join(tmpdir(), 'pinme-dist-'));
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const filePath = path.join(distDir, relativePath);
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
return distDir;
|
||||
}
|
||||
|
||||
test('patchPrebuiltFrontendDist replaces API and auth placeholders', async () => {
|
||||
const { patchPrebuiltFrontendDist } = await loadHelper();
|
||||
const distDir = makeDist({
|
||||
'index.html': '<script src="/assets/index.js"></script>',
|
||||
'assets/index.js': [
|
||||
'const api="__PINME_VITE_API_URL__";',
|
||||
'const key="__PINME_AUTH_API_KEY__";',
|
||||
'const domain="__PINME_AUTH_DOMAIN__";',
|
||||
'const project="__PINME_AUTH_PROJECT_ID__";',
|
||||
'const tenant="__PINME_TENANT_ID__";',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = patchPrebuiltFrontendDist(distDir, {
|
||||
api_domain: 'https://demo.pinme.pro',
|
||||
public_client_config: {
|
||||
auth_api_key: 'firebase-key',
|
||||
auth_domain: 'demo.firebaseapp.com',
|
||||
auth_project_id: 'firebase-project',
|
||||
tenant_id: 'tenant-123',
|
||||
},
|
||||
});
|
||||
|
||||
const bundle = readFileSync(path.join(distDir, 'assets/index.js'), 'utf8');
|
||||
assert.equal(result.apiUrlReplacements, 1);
|
||||
assert.equal(result.authReplacements, 4);
|
||||
assert.match(bundle, /https:\/\/demo\.pinme\.pro/);
|
||||
assert.match(bundle, /firebase-key/);
|
||||
assert.match(bundle, /demo\.firebaseapp\.com/);
|
||||
assert.match(bundle, /firebase-project/);
|
||||
assert.match(bundle, /tenant-123/);
|
||||
assert.doesNotMatch(bundle, /__PINME_/);
|
||||
} finally {
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('patchPrebuiltFrontendDist clears auth placeholders when auth config is absent', async () => {
|
||||
const { patchPrebuiltFrontendDist } = await loadHelper();
|
||||
const distDir = makeDist({
|
||||
'assets/index.js': [
|
||||
'const api="__PINME_VITE_API_URL__";',
|
||||
'const key="__PINME_AUTH_API_KEY__";',
|
||||
'const domain="__PINME_AUTH_DOMAIN__";',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = patchPrebuiltFrontendDist(distDir, {
|
||||
api_domain: 'https://demo.pinme.pro',
|
||||
});
|
||||
|
||||
const bundle = readFileSync(path.join(distDir, 'assets/index.js'), 'utf8');
|
||||
assert.equal(result.apiUrlReplacements, 1);
|
||||
assert.equal(result.authReplacements, 2);
|
||||
assert.equal(
|
||||
bundle,
|
||||
[
|
||||
'const api="https://demo.pinme.pro";',
|
||||
'const key="";',
|
||||
'const domain="";',
|
||||
].join('\n'),
|
||||
);
|
||||
} finally {
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('patchPrebuiltFrontendDist fails when API placeholder is missing', async () => {
|
||||
const { patchPrebuiltFrontendDist } = await loadHelper();
|
||||
const distDir = makeDist({
|
||||
'assets/index.js': 'const key="__PINME_AUTH_API_KEY__";',
|
||||
});
|
||||
|
||||
try {
|
||||
assert.throws(
|
||||
() => patchPrebuiltFrontendDist(distDir, {
|
||||
api_domain: 'https://demo.pinme.pro',
|
||||
}),
|
||||
/missing required Pinme config placeholder/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('patchPrebuiltFrontendDist skips unsupported files', async () => {
|
||||
const { patchPrebuiltFrontendDist } = await loadHelper();
|
||||
const distDir = makeDist({
|
||||
'assets/index.js': 'const api="__PINME_VITE_API_URL__";',
|
||||
'assets/logo.svg': '<svg>__PINME_VITE_API_URL__</svg>',
|
||||
});
|
||||
|
||||
try {
|
||||
patchPrebuiltFrontendDist(distDir, {
|
||||
api_domain: 'https://demo.pinme.pro',
|
||||
});
|
||||
assert.equal(
|
||||
readFileSync(path.join(distDir, 'assets/logo.svg'), 'utf8'),
|
||||
'<svg>__PINME_VITE_API_URL__</svg>',
|
||||
);
|
||||
} finally {
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { test } from 'vitest';
|
||||
|
||||
async function loadHelper() {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-tracker-helper-'));
|
||||
const outfile = path.join(tempDir, 'tracker.cjs');
|
||||
|
||||
await build({
|
||||
entryPoints: [path.resolve('bin/utils/tracker.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
const helper = await import(pathToFileURL(outfile).href);
|
||||
return {
|
||||
helper,
|
||||
cleanup: () => rmSync(tempDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
test('getTrackErrorReason normalizes HTML API responses', async () => {
|
||||
const { helper, cleanup } = await loadHelper();
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
helper.getTrackErrorReason({
|
||||
response: {
|
||||
status: 502,
|
||||
data: '<!DOCTYPE html><html><body>Bad Gateway</body></html>',
|
||||
},
|
||||
message: 'Request failed with status code 502',
|
||||
}),
|
||||
'api_returned_html',
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrackErrorReason normalizes gateway status failures', async () => {
|
||||
const { helper, cleanup } = await loadHelper();
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
helper.getTrackErrorReason(new Error('Request failed with status code 520')),
|
||||
'gateway_520',
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrackErrorReason prefers nested cause over command wrapper messages', async () => {
|
||||
const { helper, cleanup } = await loadHelper();
|
||||
|
||||
try {
|
||||
const htmlError = {
|
||||
response: {
|
||||
status: 520,
|
||||
data: '<html>edge error</html>',
|
||||
},
|
||||
message: 'Request failed with status code 520',
|
||||
};
|
||||
const wrappedError = new Error('frontend deploy failed.');
|
||||
wrappedError.cause = htmlError;
|
||||
|
||||
assert.equal(
|
||||
helper.getTrackErrorReason(wrappedError),
|
||||
'api_returned_html',
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrackErrorReason normalizes authentication failures', async () => {
|
||||
const { helper, cleanup } = await loadHelper();
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
helper.getTrackErrorReason(new Error('Token authentication failed')),
|
||||
'token_auth_failed',
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { test } from 'vitest';
|
||||
|
||||
async function loadHelper() {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-worker-metadata-'));
|
||||
const outfile = path.join(tempDir, 'workerMetadata.cjs');
|
||||
|
||||
await build({
|
||||
entryPoints: [path.resolve('bin/utils/workerMetadata.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
return import(pathToFileURL(outfile).href);
|
||||
}
|
||||
|
||||
function validMetadata(projectName = 'demo-project') {
|
||||
return JSON.stringify({
|
||||
main_module: 'worker.js',
|
||||
project_name: projectName,
|
||||
bindings: [
|
||||
{
|
||||
type: 'secret_text',
|
||||
name: 'API_KEY',
|
||||
text: 'real-api-key',
|
||||
},
|
||||
{
|
||||
type: 'plain_text',
|
||||
name: 'PROJECT_NAME',
|
||||
text: projectName,
|
||||
},
|
||||
],
|
||||
compatibility_date: '2024-01-01',
|
||||
});
|
||||
}
|
||||
|
||||
test('validateWorkerMetadataForCreate accepts platform metadata', async () => {
|
||||
const { validateWorkerMetadataForCreate } = await loadHelper();
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
validateWorkerMetadataForCreate(validMetadata(), 'demo-project');
|
||||
});
|
||||
});
|
||||
|
||||
test('validateWorkerMetadataForCreate rejects template placeholder metadata', async () => {
|
||||
const { validateWorkerMetadataForCreate } = await loadHelper();
|
||||
const templateMetadata = JSON.stringify({
|
||||
api_key: 'xxx',
|
||||
main_module: 'worker.js',
|
||||
project_name: 'project_name',
|
||||
compatibility_date: '2024-01-01',
|
||||
bindings: [
|
||||
{
|
||||
type: 'secret_text',
|
||||
name: 'API_KEY',
|
||||
text: 'xxx',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => validateWorkerMetadataForCreate(templateMetadata, 'demo-project'),
|
||||
/real API_KEY binding/,
|
||||
);
|
||||
});
|
||||
|
||||
test('validateWorkerMetadataForCreate rejects flagged API_KEY placeholder', async () => {
|
||||
const { validateWorkerMetadataForCreate } = await loadHelper();
|
||||
const metadata = JSON.stringify({
|
||||
main_module: 'worker.js',
|
||||
project_name: 'demo-project',
|
||||
bindings: [
|
||||
{
|
||||
type: 'secret_text',
|
||||
name: 'API_KEY',
|
||||
text: '__PINME_API_KEY__',
|
||||
},
|
||||
{
|
||||
type: 'plain_text',
|
||||
name: 'PROJECT_NAME',
|
||||
text: 'demo-project',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => validateWorkerMetadataForCreate(metadata, 'demo-project'),
|
||||
/real API_KEY binding/,
|
||||
);
|
||||
});
|
||||
|
||||
test('validateWorkerMetadataForCreate requires matching PROJECT_NAME binding', async () => {
|
||||
const { validateWorkerMetadataForCreate } = await loadHelper();
|
||||
const metadata = JSON.stringify({
|
||||
main_module: 'worker.js',
|
||||
project_name: 'demo-project',
|
||||
bindings: [
|
||||
{
|
||||
type: 'secret_text',
|
||||
name: 'API_KEY',
|
||||
text: 'real-api-key',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => validateWorkerMetadataForCreate(metadata, 'demo-project'),
|
||||
/PROJECT_NAME binding/,
|
||||
);
|
||||
});
|
||||
|
||||
test('validateWorkerMetadataForCreate rejects invalid JSON', async () => {
|
||||
const { validateWorkerMetadataForCreate } = await loadHelper();
|
||||
|
||||
assert.throws(
|
||||
() => validateWorkerMetadataForCreate('{not json', 'demo-project'),
|
||||
/valid JSON/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": [
|
||||
"bin/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"vitest.config.ts",
|
||||
"vitest.mutation.config.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "coverage", "reports"]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: [
|
||||
'test/**/*.{test,spec}.ts',
|
||||
'test/**/*.{test,spec}.mjs',
|
||||
'tests/**/*.{test,spec}.mjs',
|
||||
],
|
||||
exclude: ['node_modules', 'dist', 'coverage', 'reports', '.stryker-tmp'],
|
||||
setupFiles: ['test/setup/nock.ts'],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 30000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
// Keep the coverage gate on in-process core modules. Command files are
|
||||
// exercised by test:cli against the bundled CLI; that subprocess coverage
|
||||
// is not reliably attributed back to TypeScript source files.
|
||||
include: [
|
||||
'bin/utils/domainValidator.ts',
|
||||
'bin/utils/config.ts',
|
||||
'bin/utils/uploadLimits.ts',
|
||||
'bin/utils/apiClient.ts',
|
||||
'bin/utils/cliError.ts',
|
||||
'bin/utils/history.ts',
|
||||
'bin/utils/pinmeApi.ts',
|
||||
'bin/utils/webLogin.ts',
|
||||
'bin/services/uploadService.ts',
|
||||
],
|
||||
exclude: [
|
||||
// Login callback/browser UI and CLI entrypoints are covered by CLI tests.
|
||||
'bin/index.ts',
|
||||
'bin/login.ts',
|
||||
],
|
||||
statements: 85,
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { mergeConfig } from 'vitest/config';
|
||||
|
||||
import baseConfig from './vitest.config';
|
||||
|
||||
export default mergeConfig(baseConfig, {
|
||||
test: {
|
||||
include: [
|
||||
'test/unit/**/*.{test,spec}.ts',
|
||||
'test/integration/**/*.{test,spec}.ts',
|
||||
'test/login-tracking-source.test.mjs',
|
||||
'tests/**/*.{test,spec}.mjs',
|
||||
],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'coverage',
|
||||
'reports',
|
||||
'.stryker-tmp',
|
||||
'test/cli/**',
|
||||
'test/pack/**',
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user