diff --git a/.github/scripts/coverage-comment.mjs b/.github/scripts/coverage-comment.mjs new file mode 100644 index 0000000..1a26f49 --- /dev/null +++ b/.github/scripts/coverage-comment.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Post (or update) a coverage summary comment on a pull request. + * + * Usage: node .github/scripts/coverage-comment.mjs [--print] + * + * Reads coverage/coverage-summary.json (json-summary reporter output), + * renders a small markdown table and upserts a comment marked with + * `` so re-runs update instead of stacking. + * `--print` only prints the body (local verification without GitHub writes). + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' + +const args = process.argv.slice(2) +const printOnly = args.includes('--print') +const pr = args.find((a) => /^\d+$/.test(a)) +if (!pr) { + console.error('usage: coverage-comment.mjs [--print]') + process.exit(1) +} + +const summaryPath = 'coverage/coverage-summary.json' +if (!existsSync(summaryPath)) { + console.error(`missing ${summaryPath} - run vitest with --coverage first`) + process.exit(1) +} + +const total = JSON.parse(readFileSync(summaryPath, 'utf8')).total +const row = (metric) => { + const m = total[metric] + return `| ${metric[0].toUpperCase() + metric.slice(1)} | ${m.pct.toFixed(2)}% | ${m.covered}/${m.total} |` +} + +const body = [ + '', + '## Coverage', + '', + '| Metric | % | Covered / Total |', + '| --- | ---: | ---: |', + row('lines'), + row('statements'), + row('functions'), + row('branches'), + '', +].join('\n') + +if (printOnly) { + process.stdout.write(body) + process.exit(0) +} + +const run = (cmd, ghArgs, options = {}) => { + const res = spawnSync(cmd, ghArgs, { encoding: 'utf8', ...options }) + if (res.status !== 0) { + console.error(res.stderr || res.stdout) + process.exit(1) + } + return res.stdout.trim() +} + +const repoSlug = + process.env.GITHUB_REPOSITORY || + run('git', ['remote', 'get-url', 'origin']) + .replace(/^.*github\.com[/:]/, '') + .replace(/\.git$/, '') + +const marker = '' +const comments = JSON.parse(run('gh', ['api', `repos/${repoSlug}/issues/${pr}/comments?per_page=100`])) +const existing = comments.find((c) => c.body && c.body.includes(marker)) +const endpoint = existing + ? `repos/${repoSlug}/issues/comments/${existing.id}` + : `repos/${repoSlug}/issues/${pr}/comments` +run('gh', ['api', endpoint, '-X', existing ? 'PATCH' : 'POST', '--input', '-'], { + input: JSON.stringify({ body }), +}) +console.log(existing ? `updated comment ${existing.id}` : `posted coverage comment on #${pr}`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6d728e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run oxlint + run: pnpm lint + + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run tests + run: | + pnpm serve > /tmp/serve.log 2>&1 & + SERVE_PID=$! + trap 'kill $SERVE_PID' EXIT + for i in $(seq 1 60); do + curl -sf -o /dev/null http://localhost:8080/api/oss-config && break + sleep 1 + done + curl -sf -o /dev/null http://localhost:8080/api/oss-config || { + echo "test server did not start:"; cat /tmp/serve.log; exit 1 + } + pnpm test:ci:coverage + + - name: Comment coverage on PR + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: node .github/scripts/coverage-comment.mjs "${{ github.event.pull_request.number }}" diff --git a/test/index.spec.ts b/test/index.spec.ts index 0a77090..184e048 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeAll } from 'vitest' import { put, putSymlink, @@ -37,11 +37,20 @@ function getObjectName() { } describe('integration', () => { + let configured = false + + beforeAll(async () => { + const res = await fetch('http://localhost:8080/api/oss-config') + const data = (await res.json()) as OssConfig + configured = !!(data.accessKeyId && data.accessKeySecret && data.bucket && data.region) + }) + it('should throw if options are missing', async () => { await expect(put({} as any, 'obj', new Blob(['x']))).rejects.toThrow(/need accessKeyId/) }) - it('put', async () => { + it('put', async ({ skip }) => { + if (!configured) return skip() const content = 'put: hello 你好' const objectName = getObjectName() const res = await fetch('http://localhost:8080/api/oss-config') @@ -61,7 +70,8 @@ describe('integration', () => { } }) - it('putSymlink', async () => { + it('putSymlink', async ({ skip }) => { + if (!configured) return skip() const content = 'putSymlink: hello 你好' const objectName = getObjectName() const targetObjectName = getObjectName() @@ -85,7 +95,8 @@ describe('integration', () => { } }) - it('signatureUrl', async () => { + it('signatureUrl', async ({ skip }) => { + if (!configured) return skip() const content = 'signatureUrl: hello 你好' const objectName = getObjectName() const res = await fetch('http://localhost:8080/api/oss-config') @@ -108,7 +119,8 @@ describe('integration', () => { } }) - it('signatureUrl with non-ASCII object name', async () => { + it('signatureUrl with non-ASCII object name', async ({ skip }) => { + if (!configured) return skip() const content = 'signatureUrl 中文: hello 你好' const objectName = `中文文件-${Date.now()}.txt` const res = await fetch('http://localhost:8080/api/oss-config') @@ -129,7 +141,8 @@ describe('integration', () => { } }) - it('multipartUpload', async () => { + it('multipartUpload', async ({ skip }) => { + if (!configured) return skip() const objectName = getObjectName() const res = await fetch('http://localhost:8080/api/oss-config') const data = (await res.json()) as OssConfig @@ -168,7 +181,8 @@ describe('integration', () => { } }) - it('put stsToken', async () => { + it('put stsToken', async ({ skip }) => { + if (!configured) return skip() const content = 'put stsToken: hello 你好' const objectName = getObjectName() const res = await fetch('http://localhost:8080/api/sts') @@ -194,7 +208,8 @@ describe('integration', () => { } }) - it('signatureUrl stsToken', async () => { + it('signatureUrl stsToken', async ({ skip }) => { + if (!configured) return skip() const content = 'signatureUrl: hello 你好' const objectName = getObjectName() const res = await fetch('http://localhost:8080/api/sts') @@ -220,7 +235,8 @@ describe('integration', () => { } }) - it('put ArrayBuffer input', async () => { + it('put ArrayBuffer input', async ({ skip }) => { + if (!configured) return skip() const size = 2 * 1024 * 1024 const bytes = new Uint8Array(size) for (let i = 0; i < size; i++) bytes[i] = i % 251 @@ -246,7 +262,8 @@ describe('integration', () => { } }) - it('multipartUpload ArrayBuffer input', async () => { + it('multipartUpload ArrayBuffer input', async ({ skip }) => { + if (!configured) return skip() const size = 3 * 1024 * 1024 const bytes = new Uint8Array(size) for (let i = 0; i < size; i++) bytes[i] = i % 251 @@ -274,7 +291,8 @@ describe('integration', () => { } }) - it('multipartUpload resumes from a checkpoint after an interruption', async () => { + it('multipartUpload resumes from a checkpoint after an interruption', async ({ skip }) => { + if (!configured) return skip() const objectName = getObjectName() const res = await fetch('http://localhost:8080/api/oss-config') const data = (await res.json()) as OssConfig @@ -333,7 +351,8 @@ describe('integration', () => { } }) - it('fetch transport put and multipartUpload', async () => { + it('fetch transport put and multipartUpload', async ({ skip }) => { + if (!configured) return skip() const res = await fetch('http://localhost:8080/api/oss-config') const data = (await res.json()) as OssConfig const { accessKeyId, accessKeySecret, region, bucket } = data @@ -379,7 +398,8 @@ describe('integration', () => { } }) - it('bindOptions put', async () => { + it('bindOptions put', async ({ skip }) => { + if (!configured) return skip() const content = 'bindOptions put: hello 你好' const objectName = getObjectName() const res = await fetch('http://localhost:8080/api/oss-config') diff --git a/vitest.config.ts b/vitest.config.ts index 334e912..55f11ac 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,12 +8,11 @@ export default defineConfig({ enabled: true, provider: playwright(), // https://vitest.dev/config/browser/playwright - instances: [ - { browser: 'chromium' }, - ], + instances: [{ browser: 'chromium' }], }, coverage: { - provider: 'v8' // or 'istanbul' + provider: 'v8', // or 'istanbul' + reporter: ['text', 'html', 'json-summary'], }, }, })