- 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>
533 lines
14 KiB
Markdown
533 lines
14 KiB
Markdown
# 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 |
|