chore: update env example and gitignore, add implementation plan
- Update .env.example with documentation and default values - Add .resources to gitignore (dummy test files) - Add APK parsing fix implementation plan document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
532
docs/plans/2026-03-04-apk-parsing-fix.md
Normal file
532
docs/plans/2026-03-04-apk-parsing-fix.md
Normal file
@@ -0,0 +1,532 @@
|
||||
# APK Parsing Fix Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix APK filename parsing to use version number as delimiter and support configurable environment tabs.
|
||||
|
||||
**Architecture:** Parse APK filenames by splitting on `_<version>` to extract prefix. If prefix has no underscore → product. If prefix has underscore → extract environment from last segment. Match against configurable tabs (env var), unmatched → other.
|
||||
|
||||
**Tech Stack:** Next.js, TypeScript, environment variables
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add APK_TABS Environment Variable
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/constants.ts`
|
||||
|
||||
**Step 1: Add APK_TABS constant**
|
||||
|
||||
```typescript
|
||||
// Add to lib/constants.ts
|
||||
export const APK_TABS = (process.env.APK_TABS || 'dev,sandbox')
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean);
|
||||
```
|
||||
|
||||
**Step 2: Verify no build errors**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/constants.ts
|
||||
git commit -m "feat: add APK_TABS environment variable for configurable tabs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite APK Parser Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/apk-parser.ts`
|
||||
- Modify: `lib/__tests__/apk-parser.test.ts`
|
||||
|
||||
**Step 1: Write failing tests for new parsing logic**
|
||||
|
||||
Replace `lib/__tests__/apk-parser.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { parseApkFilename, detectEnvironment, getApkTabs } from '../apk-parser';
|
||||
|
||||
describe('parseApkFilename', () => {
|
||||
it('should parse product APK (no environment)', () => {
|
||||
const result = parseApkFilename('ft_0_44_bfeddf2d0.apk', '0_44');
|
||||
expect(result.environment).toBe('product');
|
||||
expect(result.commit).toBe('bfeddf2d0');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse dev APK', () => {
|
||||
const result = parseApkFilename('ft_dev_0_44_bfeddf2d0.apk', '0_44');
|
||||
expect(result.environment).toBe('dev');
|
||||
expect(result.commit).toBe('bfeddf2d0');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse sandbox APK', () => {
|
||||
const result = parseApkFilename('ft_sandbox_0_44_bfeddf2d0.apk', '0_44');
|
||||
expect(result.environment).toBe('sandbox');
|
||||
expect(result.commit).toBe('bfeddf2d0');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should map unknown environment to other', () => {
|
||||
const result = parseApkFilename('ft_lan_0_44_bfeddf2d0.apk', '0_44');
|
||||
expect(result.environment).toBe('other');
|
||||
expect(result.commit).toBe('bfeddf2d0');
|
||||
});
|
||||
|
||||
it('should map timeshift to other', () => {
|
||||
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk', '0_42');
|
||||
expect(result.environment).toBe('other');
|
||||
});
|
||||
|
||||
it('should handle invalid format without commit', () => {
|
||||
const result = parseApkFilename('ft_dev_0_44.apk', '0_44');
|
||||
expect(result.environment).toBe('dev');
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle completely invalid filename', () => {
|
||||
const result = parseApkFilename('invalid_filename.apk', '0_44');
|
||||
expect(result.environment).toBe('other');
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectEnvironment', () => {
|
||||
it('should detect product when no underscore in prefix', () => {
|
||||
expect(detectEnvironment('ft', ['dev', 'sandbox'])).toBe('product');
|
||||
});
|
||||
|
||||
it('should detect configured environment', () => {
|
||||
expect(detectEnvironment('ft_dev', ['dev', 'sandbox'])).toBe('dev');
|
||||
expect(detectEnvironment('ft_sandbox', ['dev', 'sandbox'])).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('should return other for unconfigured environment', () => {
|
||||
expect(detectEnvironment('ft_lan', ['dev', 'sandbox'])).toBe('other');
|
||||
expect(detectEnvironment('ft_timeshift', ['dev', 'sandbox'])).toBe('other');
|
||||
});
|
||||
|
||||
it('should return other for empty prefix', () => {
|
||||
expect(detectEnvironment('', ['dev', 'sandbox'])).toBe('other');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- lib/__tests__/apk-parser.test.ts`
|
||||
Expected: Tests fail with missing `getApkTabs` and wrong behavior
|
||||
|
||||
**Step 3: Implement new parsing logic**
|
||||
|
||||
Replace `lib/apk-parser.ts`:
|
||||
|
||||
```typescript
|
||||
import { APK_TABS } from './constants';
|
||||
|
||||
export type ApkEnvironment = 'product' | 'dev' | 'sandbox' | 'other';
|
||||
|
||||
export interface ParsedApkMetadata {
|
||||
environment: ApkEnvironment;
|
||||
commit: string | null;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export function getApkTabs(): string[] {
|
||||
return APK_TABS;
|
||||
}
|
||||
|
||||
export function detectEnvironment(prefix: string, configuredTabs: string[]): ApkEnvironment {
|
||||
// Empty prefix → other
|
||||
if (!prefix) {
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// No underscore means product (e.g., "ft")
|
||||
if (!prefix.includes('_')) {
|
||||
return 'product';
|
||||
}
|
||||
|
||||
// Extract environment from last segment after underscore
|
||||
const lastUnderscoreIndex = prefix.lastIndexOf('_');
|
||||
const env = prefix.substring(lastUnderscoreIndex + 1);
|
||||
|
||||
// Check if environment is configured
|
||||
if (configuredTabs.includes(env)) {
|
||||
return env as ApkEnvironment;
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function parseApkFilename(filename: string, version: string): ParsedApkMetadata {
|
||||
// Use version as delimiter (with leading underscore)
|
||||
const delimiter = `_${version}`;
|
||||
const delimiterIndex = filename.indexOf(delimiter);
|
||||
|
||||
let prefix: string;
|
||||
if (delimiterIndex > 0) {
|
||||
prefix = filename.substring(0, delimiterIndex);
|
||||
} else {
|
||||
// Fallback: can't find version delimiter
|
||||
prefix = '';
|
||||
}
|
||||
|
||||
const environment = detectEnvironment(prefix, APK_TABS);
|
||||
|
||||
// Extract commit ID
|
||||
const commitMatch = filename.match(/([a-f0-9]{7,})\.apk$/);
|
||||
const commit = commitMatch ? commitMatch[1] : null;
|
||||
|
||||
return {
|
||||
environment,
|
||||
commit,
|
||||
isValid: commit !== null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- lib/__tests__/apk-parser.test.ts`
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/apk-parser.ts lib/__tests__/apk-parser.test.ts
|
||||
git commit -m "feat: rewrite APK parser to use version as delimiter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update API Route to Pass Version
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/api/apks/route.ts`
|
||||
- Modify: `app/api/__tests__/apks.test.ts`
|
||||
|
||||
**Step 1: Check existing API test**
|
||||
|
||||
Read: `app/api/__tests__/apks.test.ts`
|
||||
|
||||
**Step 2: Update API route to pass version**
|
||||
|
||||
Modify `app/api/apks/route.ts` line 22:
|
||||
|
||||
```typescript
|
||||
// Change from:
|
||||
const parsed = parseApkFilename(apk.name);
|
||||
// To:
|
||||
const parsed = parseApkFilename(apk.name, version);
|
||||
```
|
||||
|
||||
**Step 3: Verify build succeeds**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/api/apks/route.ts
|
||||
git commit -m "feat: pass version parameter to APK parser"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update ApkList to Use Configured Tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/constants.ts`
|
||||
- Modify: `app/download/ApkList.tsx`
|
||||
- Add: `app/api/config/route.ts`
|
||||
|
||||
**Step 1: Create config API endpoint**
|
||||
|
||||
Create `app/api/config/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from 'next/server';
|
||||
import { APK_TABS } from '@/lib/constants';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
tabs: APK_TABS,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update ApkList to fetch and use configured tabs**
|
||||
|
||||
Modify `app/download/ApkList.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { type ApkEnvironment } from '@/lib/apk-parser';
|
||||
import ApkTable from './ApkTable';
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
interface Apk {
|
||||
name: string;
|
||||
environment: ApkEnvironment;
|
||||
commit: string | null;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
interface ApkListProps {
|
||||
project: string;
|
||||
version: string;
|
||||
commitId: string;
|
||||
}
|
||||
|
||||
export default function ApkList({ project, version, commitId }: ApkListProps) {
|
||||
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
|
||||
|
||||
// Fetch config on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(data => setConfiguredTabs(data.tabs))
|
||||
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
|
||||
}, []);
|
||||
|
||||
const { data: apks, isLoading, error } = useSWR<Apk[]>(
|
||||
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
|
||||
fetcher
|
||||
);
|
||||
|
||||
// Build tabs: product → configured → other
|
||||
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8 text-red-600">
|
||||
加载失败,请重试
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!apks || apks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-12 text-center">
|
||||
<p className="text-gray-500">未找到匹配的 APK 文件</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group APKs by environment
|
||||
const groupedApks = apks.reduce((acc, apk) => {
|
||||
acc[apk.environment] = [...(acc[apk.environment] || []), apk];
|
||||
return acc;
|
||||
}, {} as Record<ApkEnvironment, Apk[]>);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white rounded-t-lg border-b border-gray-200">
|
||||
<nav className="flex space-x-8 px-6" aria-label="Tabs">
|
||||
{TABS.map((tab) => {
|
||||
const count = groupedApks[tab]?.length || 0;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => {}}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
false // activeTab logic removed for simplicity
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-4">
|
||||
<ApkTable apks={groupedApks['product'] || []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Wait, I need to preserve the activeTab state. Let me revise:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { type ApkEnvironment } from '@/lib/apk-parser';
|
||||
import ApkTable from './ApkTable';
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
interface Apk {
|
||||
name: string;
|
||||
environment: ApkEnvironment;
|
||||
commit: string | null;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
interface ApkListProps {
|
||||
project: string;
|
||||
version: string;
|
||||
commitId: string;
|
||||
}
|
||||
|
||||
export default function ApkList({ project, version, commitId }: ApkListProps) {
|
||||
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<ApkEnvironment>('product');
|
||||
|
||||
// Fetch config on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(data => setConfiguredTabs(data.tabs))
|
||||
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
|
||||
}, []);
|
||||
|
||||
const { data: apks, isLoading, error } = useSWR<Apk[]>(
|
||||
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
|
||||
fetcher
|
||||
);
|
||||
|
||||
// Build tabs: product → configured → other
|
||||
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8 text-red-600">
|
||||
加载失败,请重试
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!apks || apks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-12 text-center">
|
||||
<p className="text-gray-500">未找到匹配的 APK 文件</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group APKs by environment
|
||||
const groupedApks = apks.reduce((acc, apk) => {
|
||||
acc[apk.environment] = [...(acc[apk.environment] || []), apk];
|
||||
return acc;
|
||||
}, {} as Record<ApkEnvironment, Apk[]>);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white rounded-t-lg border-b border-gray-200">
|
||||
<nav className="flex space-x-8 px-6" aria-label="Tabs">
|
||||
{TABS.map((tab) => {
|
||||
const count = groupedApks[tab]?.length || 0;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === tab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-4">
|
||||
<ApkTable apks={groupedApks[activeTab] || []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Run build to verify**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/api/config/route.ts app/download/ApkList.tsx
|
||||
git commit -m "feat: add config API and update ApkList to use configured tabs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Final Verification
|
||||
|
||||
**Step 1: Run all tests**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 2: Run build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 3: Manual test with dev server**
|
||||
|
||||
Run: `npm run dev`
|
||||
Test: Navigate to download page, select project and version, verify tabs display correctly
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `lib/constants.ts` | Add `APK_TABS` env var |
|
||||
| `lib/apk-parser.ts` | Rewrite parsing logic with version delimiter |
|
||||
| `lib/__tests__/apk-parser.test.ts` | Update tests |
|
||||
| `app/api/apks/route.ts` | Pass version to parser |
|
||||
| `app/api/config/route.ts` | New endpoint for tab config |
|
||||
| `app/download/ApkList.tsx` | Use configured tabs from API |
|
||||
Reference in New Issue
Block a user