Compare commits
10 Commits
10fa6782e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d1ad58b504 | |||
| 8d7d2f37d7 | |||
| 31bbebc2ab | |||
| 0b86887680 | |||
| d22aa276e8 | |||
| ddf2729426 | |||
| 1eba2f5f39 | |||
| 7268b91ed3 | |||
| 0b817984bb | |||
| df4daea1c7 |
12
.env.example
12
.env.example
@@ -1,3 +1,9 @@
|
||||
AUTH_USERNAME=
|
||||
AUTH_PASSWORD=
|
||||
RESOURCE_PATH=
|
||||
# Authentication credentials (change in production!)
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD=changeme
|
||||
|
||||
# Path to APK storage directory
|
||||
RESOURCE_PATH=./resources
|
||||
|
||||
# Comma-separated list of tab names for APK filtering
|
||||
APK_TABS=dev,sandbox
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,3 +40,6 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env
|
||||
|
||||
# dumy files for download tests
|
||||
.resources
|
||||
|
||||
@@ -19,11 +19,12 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const apks = await getApks(project, version, commitId);
|
||||
const apksWithMetadata = apks.map(apk => {
|
||||
const parsed = parseApkFilename(apk.name);
|
||||
const parsed = parseApkFilename(apk.name, version);
|
||||
return {
|
||||
name: apk.name,
|
||||
size: apk.size,
|
||||
environment: parsed.environment,
|
||||
rawEnvironment: parsed.rawEnvironment,
|
||||
commit: parsed.commit,
|
||||
modifiedAt: apk.modifiedAt.toISOString(),
|
||||
downloadUrl: `/api/download?project=${encodeURIComponent(project)}&version=${encodeURIComponent(version)}&filename=${encodeURIComponent(apk.name)}`,
|
||||
|
||||
8
app/api/config/route.ts
Normal file
8
app/api/config/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { APK_TABS } from '@/lib/constants';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
tabs: APK_TABS,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { type ApkEnvironment } from '@/lib/apk-parser';
|
||||
import ApkTable from './ApkTable';
|
||||
@@ -10,6 +10,7 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
interface Apk {
|
||||
name: string;
|
||||
environment: ApkEnvironment;
|
||||
rawEnvironment: string;
|
||||
commit: string | null;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
@@ -22,16 +23,26 @@ interface ApkListProps {
|
||||
commitId: string;
|
||||
}
|
||||
|
||||
const TABS: ApkEnvironment[] = ['dev', 'product', 'sandbox', 'other'];
|
||||
|
||||
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">
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { formatFileSize, formatDateTime } from '@/lib/utils';
|
||||
import { type ApkEnvironment } from '@/lib/apk-parser';
|
||||
|
||||
interface Apk {
|
||||
name: string;
|
||||
environment: ApkEnvironment;
|
||||
rawEnvironment: string;
|
||||
commit: string | null;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
@@ -14,6 +17,18 @@ interface ApkTableProps {
|
||||
apks: Apk[];
|
||||
}
|
||||
|
||||
function getEnvironmentBadgeClass(env: ApkEnvironment): string {
|
||||
switch (env) {
|
||||
case 'product':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'dev':
|
||||
case 'sandbox':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ApkTable({ apks }: ApkTableProps) {
|
||||
if (!apks || apks.length === 0) {
|
||||
return (
|
||||
@@ -46,9 +61,14 @@ export default function ApkTable({ apks }: ApkTableProps) {
|
||||
{apks.map((apk) => (
|
||||
<tr key={apk.name} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-mono text-sm text-gray-900">
|
||||
{apk.commit || '-'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-gray-900">
|
||||
{apk.commit || '-'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${getEnvironmentBadgeClass(apk.environment)}`}>
|
||||
{apk.rawEnvironment}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatFileSize(apk.size)}
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
|
||||
setSelectedProject(e.target.value);
|
||||
setSelectedVersion('');
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
|
||||
required
|
||||
>
|
||||
<option value="">选择项目</option>
|
||||
@@ -83,7 +83,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
|
||||
value={selectedVersion}
|
||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||
disabled={!selectedProject}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed text-gray-900"
|
||||
required
|
||||
>
|
||||
<option value="">选择版本</option>
|
||||
@@ -109,7 +109,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
|
||||
value={commitId}
|
||||
onChange={(e) => setCommitId(e.target.value)}
|
||||
placeholder="输入 commit id..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import ApkList from '../ApkList';
|
||||
import useSWR from 'swr';
|
||||
|
||||
jest.mock('swr');
|
||||
|
||||
// Mock fetch for config API
|
||||
const mockFetch = jest.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe('ApkList', () => {
|
||||
const mockApks = [
|
||||
{
|
||||
name: 'ft_timeshift_0_42_57ef3a60d.apk',
|
||||
name: 'ft_dev_0_42_57ef3a60d.apk',
|
||||
environment: 'dev',
|
||||
rawEnvironment: 'dev',
|
||||
commit: '57ef3a60d',
|
||||
size: 15728640,
|
||||
modifiedAt: '2026-03-04T14:30:00.000Z',
|
||||
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_timeshift_0_42_57ef3a60d.apk',
|
||||
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_dev_0_42_57ef3a60d.apk',
|
||||
},
|
||||
{
|
||||
name: 'ft_0_42_ff9ff3441.apk',
|
||||
environment: 'product',
|
||||
rawEnvironment: 'product',
|
||||
commit: 'ff9ff3441',
|
||||
size: 15728640,
|
||||
modifiedAt: '2026-03-04T14:30:00.000Z',
|
||||
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_0_42_ff9ff3441.apk',
|
||||
},
|
||||
{
|
||||
name: 'ft_sandbox_0_42_57ef3a60d.apk',
|
||||
name: 'ft_sandbox_0_42_abc123456.apk',
|
||||
environment: 'sandbox',
|
||||
commit: '57ef3a60d',
|
||||
rawEnvironment: 'sandbox',
|
||||
commit: 'abc123456',
|
||||
size: 15728640,
|
||||
modifiedAt: '2026-03-04T14:30:00.000Z',
|
||||
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_sandbox_0_42_57ef3a60d.apk',
|
||||
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_sandbox_0_42_abc123456.apk',
|
||||
},
|
||||
{
|
||||
name: 'ft_timeshift_0_42_xyz987654.apk',
|
||||
environment: 'other',
|
||||
rawEnvironment: 'timeshift',
|
||||
commit: 'xyz987654',
|
||||
size: 15728640,
|
||||
modifiedAt: '2026-03-04T14:30:00.000Z',
|
||||
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_timeshift_0_42_xyz987654.apk',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -39,19 +55,38 @@ describe('ApkList', () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
// Mock config API response
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ tabs: ['dev', 'sandbox'] }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all 4 tabs', () => {
|
||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
||||
|
||||
expect(screen.getByText(/dev/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/product/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/sandbox/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/other/)).toBeInTheDocument();
|
||||
afterEach(() => {
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
it('should switch tabs correctly', () => {
|
||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
||||
it('should render all 4 tabs', async () => {
|
||||
await act(async () => {
|
||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Use getAllByText since text appears in both tab and badge
|
||||
expect(screen.getAllByText(/dev/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/product/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/sandbox/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/other/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch tabs correctly', async () => {
|
||||
await act(async () => {
|
||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/dev/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const devTab = screen.getByText(/dev/);
|
||||
fireEvent.click(devTab);
|
||||
@@ -59,26 +94,30 @@ describe('ApkList', () => {
|
||||
expect(screen.getByText('57ef3a60d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading state', () => {
|
||||
it('should show loading state', async () => {
|
||||
(useSWR as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
||||
await act(async () => {
|
||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('加载中...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error state', () => {
|
||||
it('should show error state', async () => {
|
||||
(useSWR as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('API error'),
|
||||
});
|
||||
|
||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
||||
await act(async () => {
|
||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('加载失败,请重试')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 |
|
||||
53
docs/plans/2026-03-05-download-page-readability-design.md
Normal file
53
docs/plans/2026-03-05-download-page-readability-design.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Download Page 可读性优化设计
|
||||
|
||||
## 概述
|
||||
|
||||
提高 `download` page 的可读性,解决两个问题:
|
||||
1. SearchForm 内填写/选择的文字颜色过浅
|
||||
2. ApkList 的 commit id 列需要显示环境标签
|
||||
|
||||
## 修改项
|
||||
|
||||
### 1. SearchForm 文字颜色修复
|
||||
|
||||
**问题**: 下拉框选中后的文字和输入框内的文字颜色太浅,用户看不清。
|
||||
|
||||
**解决方案**: 为 `select` 和 `input` 元素添加 `text-gray-900` 类。
|
||||
|
||||
**修改文件**: `app/download/SearchForm.tsx`
|
||||
|
||||
**改动点**:
|
||||
- 项目下拉框 (line ~61): 添加 `text-gray-900`
|
||||
- 版本下拉框 (line ~86): 添加 `text-gray-900`
|
||||
- Commit ID 输入框 (line ~112): 添加 `text-gray-900`
|
||||
|
||||
### 2. ApkList commit id 列显示环境标签
|
||||
|
||||
**问题**: 当前 commit id 列只显示 commit hash,无法直观看出 APK 的环境。
|
||||
|
||||
**解决方案**: 在 commit id 后面添加彩色环境标签。
|
||||
|
||||
**修改文件**:
|
||||
- `app/download/ApkTable.tsx` - 接收并显示 environment
|
||||
- `app/download/ApkList.tsx` - 传递 environment 属性给 ApkTable
|
||||
|
||||
**标签样式**:
|
||||
| 环境 | 样式 |
|
||||
|------|------|
|
||||
| product | 蓝色背景 `bg-blue-100 text-blue-800` |
|
||||
| dev | 绿色背景 `bg-green-100 text-green-800` |
|
||||
| sandbox | 绿色背景 `bg-green-100 text-green-800` |
|
||||
| other | 灰色背景 `bg-gray-100 text-gray-800` |
|
||||
|
||||
**显示效果示例**:
|
||||
```
|
||||
abc1234 [product]
|
||||
def5678 [dev]
|
||||
xyz9012 [other]
|
||||
```
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 修改 `SearchForm.tsx` - 为 3 个表单控件添加 `text-gray-900`
|
||||
2. 修改 `ApkList.tsx` - 确保 Apk interface 包含 environment 并传递给 ApkTable
|
||||
3. 修改 `ApkTable.tsx` - 更新 Apk interface,在 commit 列显示环境标签
|
||||
170
docs/plans/2026-03-05-download-page-readability.md
Normal file
170
docs/plans/2026-03-05-download-page-readability.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Download Page 可读性优化 实现计划
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 提高 download page 的可读性:修复 SearchForm 文字颜色、在 commit id 列显示环境标签
|
||||
|
||||
**Architecture:** 直接修改现有组件,为表单控件添加文字颜色类,为 ApkTable 添加环境标签显示
|
||||
|
||||
**Tech Stack:** Next.js, React, Tailwind CSS, TypeScript
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 修复 SearchForm 文字颜色
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/download/SearchForm.tsx:61,86,112`
|
||||
|
||||
**Step 1: 为项目下拉框添加文字颜色**
|
||||
|
||||
修改 `app/download/SearchForm.tsx` 第 61 行,为项目 select 添加 `text-gray-900`:
|
||||
|
||||
```tsx
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
|
||||
```
|
||||
|
||||
**Step 2: 为版本下拉框添加文字颜色**
|
||||
|
||||
修改第 86 行,为版本 select 添加 `text-gray-900`:
|
||||
|
||||
```tsx
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed text-gray-900"
|
||||
```
|
||||
|
||||
**Step 3: 为 Commit ID 输入框添加文字颜色**
|
||||
|
||||
修改第 112 行,为 input 添加 `text-gray-900`:
|
||||
|
||||
```tsx
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
|
||||
```
|
||||
|
||||
**Step 4: 验证修改**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Build succeeds without errors
|
||||
|
||||
**Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add app/download/SearchForm.tsx
|
||||
git commit -m "fix: improve SearchForm text visibility with darker color
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ApkTable 添加环境标签显示
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/download/ApkTable.tsx`
|
||||
|
||||
**Step 1: 更新 Apk interface 添加 environment 字段**
|
||||
|
||||
修改 `app/download/ApkTable.tsx` 的 Apk interface (约第 5-11 行):
|
||||
|
||||
```tsx
|
||||
import { formatFileSize, formatDateTime } from '@/lib/utils';
|
||||
import { type ApkEnvironment } from '@/lib/apk-parser';
|
||||
|
||||
interface Apk {
|
||||
name: string;
|
||||
environment: ApkEnvironment;
|
||||
commit: string | null;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 添加环境标签渲染辅助函数**
|
||||
|
||||
在 interface 后、组件前添加:
|
||||
|
||||
```tsx
|
||||
function getEnvironmentBadgeClass(env: ApkEnvironment): string {
|
||||
switch (env) {
|
||||
case 'product':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'dev':
|
||||
case 'sandbox':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnvironment(env: ApkEnvironment): string {
|
||||
return env;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 修改 commit id 列显示环境标签**
|
||||
|
||||
修改约第 48-52 行的 commit id 单元格:
|
||||
|
||||
```tsx
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-gray-900">
|
||||
{apk.commit || '-'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${getEnvironmentBadgeClass(apk.environment)}`}>
|
||||
{formatEnvironment(apk.environment)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
```
|
||||
|
||||
**Step 4: 验证修改**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Build succeeds without errors
|
||||
|
||||
**Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add app/download/ApkTable.tsx
|
||||
git commit -m "feat: add environment badge to commit id column in ApkTable
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 验证 ApkList 传递 environment
|
||||
|
||||
**Files:**
|
||||
- Verify: `app/download/ApkList.tsx`
|
||||
|
||||
**Step 1: 检查 ApkList 是否已正确传递 environment**
|
||||
|
||||
查看 `app/download/ApkList.tsx`:
|
||||
- Apk interface 已包含 `environment: ApkEnvironment` (第 12 行)
|
||||
- ApkList 传给 ApkTable 的 apks 来自 `groupedApks[activeTab]` (第 101 行)
|
||||
- 这些 apks 已包含 environment 字段
|
||||
|
||||
**结论**: 无需修改,environment 已正确传递。
|
||||
|
||||
**Step 2: 最终验证**
|
||||
|
||||
Run: `npm run build && npm test`
|
||||
Expected: Build succeeds, all tests pass
|
||||
|
||||
**Step 3: 最终提交(如有未提交的更改)**
|
||||
|
||||
```bash
|
||||
git status
|
||||
# If clean, no action needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. SearchForm 中选择项目/版本后,文字清晰可见(深灰色)
|
||||
2. ApkTable 的 commit id 列显示环境标签,如 `abc1234 [product]`
|
||||
3. 不同环境有不同颜色:product=蓝色, dev/sandbox=绿色, other=灰色
|
||||
4. `npm run build` 成功
|
||||
5. `npm test` 全部通过
|
||||
@@ -1,60 +1,49 @@
|
||||
import { parseApkFilename, detectEnvironment } from '../apk-parser';
|
||||
import { parseApkFilename, detectEnvironment, getApkTabs } from '../apk-parser';
|
||||
|
||||
describe('parseApkFilename', () => {
|
||||
it('should parse APK with environment suffix', () => {
|
||||
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk');
|
||||
expect(result.environment).toBe('dev');
|
||||
expect(result.commit).toBe('57ef3a60d');
|
||||
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 APK without environment (product)', () => {
|
||||
const result = parseApkFilename('ft_0_42_ff9ff3441.apk');
|
||||
expect(result.environment).toBe('product');
|
||||
expect(result.commit).toBe('ff9ff3441');
|
||||
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_42_57ef3a60d.apk');
|
||||
const result = parseApkFilename('ft_sandbox_0_44_bfeddf2d0.apk', '0_44');
|
||||
expect(result.environment).toBe('sandbox');
|
||||
expect(result.commit).toBe('57ef3a60d');
|
||||
expect(result.commit).toBe('bfeddf2d0');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid format', () => {
|
||||
const result = parseApkFilename('invalid_filename.apk');
|
||||
expect(result.environment).toBe('product');
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.isValid).toBe(false);
|
||||
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 handle missing commit ID', () => {
|
||||
const result = parseApkFilename('ft_dev_0_42.apk');
|
||||
expect(result.environment).toBe('dev');
|
||||
expect(result.commit).toBeNull();
|
||||
expect(result.isValid).toBe(false);
|
||||
it('should map timeshift to other', () => {
|
||||
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk', '0_42');
|
||||
expect(result.environment).toBe('other');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('detectEnvironment', () => {
|
||||
it('should detect dev environment', () => {
|
||||
expect(detectEnvironment('ft_timeshift_0_42_57ef3a60d.apk')).toBe('dev');
|
||||
|
||||
it('should detect configured environment', () => {
|
||||
expect(detectEnvironment('ft_dev', ['dev', 'sandbox'])).toEqual({ environment: 'dev', rawEnvironment: 'dev' });
|
||||
expect(detectEnvironment('ft_sandbox', ['dev', 'sandbox'])).toEqual({ environment: 'sandbox', rawEnvironment: 'sandbox' });
|
||||
});
|
||||
|
||||
it('should detect sandbox environment', () => {
|
||||
expect(detectEnvironment('ft_sandbox_0_42_57ef3a60d.apk')).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('should detect product environment (no suffix)', () => {
|
||||
expect(detectEnvironment('ft_0_42_ff9ff3441.apk')).toBe('product');
|
||||
});
|
||||
|
||||
it('should detect lan environment', () => {
|
||||
expect(detectEnvironment('ft_lan_0_42_57ef3a60d.apk')).toBe('dev');
|
||||
});
|
||||
|
||||
it('should return other for unknown environments', () => {
|
||||
expect(detectEnvironment('ft_unknown_0_42_57ef3a60d.apk')).toBe('other');
|
||||
it('should return other for unconfigured environment', () => {
|
||||
expect(detectEnvironment('ft_lan', ['dev', 'sandbox'])).toEqual({ environment: 'other', rawEnvironment: 'lan' });
|
||||
expect(detectEnvironment('ft_timeshift', ['dev', 'sandbox'])).toEqual({ environment: 'other', rawEnvironment: 'timeshift' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,6 @@ describe('getProjects', () => {
|
||||
|
||||
const result = await getProjects();
|
||||
|
||||
expect(mockedFs.readdir).toHaveBeenCalledWith('./resources');
|
||||
expect(result).toEqual(['project1', 'project2']);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
export type ApkEnvironment = 'dev' | 'sandbox' | 'product' | 'other';
|
||||
import { APK_TABS } from './constants';
|
||||
|
||||
export type ApkEnvironment = 'product' | 'dev' | 'sandbox' | 'other';
|
||||
|
||||
export interface ParsedApkMetadata {
|
||||
environment: ApkEnvironment;
|
||||
rawEnvironment: string;
|
||||
commit: string | null;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
// Environment mapping based on filename patterns
|
||||
const ENVIRONMENT_MAP: Record<string, ApkEnvironment> = {
|
||||
'timeshift': 'dev',
|
||||
'lan': 'dev',
|
||||
'dev': 'dev',
|
||||
'sandbox': 'sandbox',
|
||||
};
|
||||
|
||||
export function detectEnvironment(filename: string): ApkEnvironment {
|
||||
// Pattern: ft_[environment]_version_commit.apk
|
||||
// Extract the environment segment (alphabetic only) between first and second underscores
|
||||
const match = filename.match(/^ft_([a-z]+)_/i);
|
||||
|
||||
if (!match) {
|
||||
// No environment suffix, default to product
|
||||
return 'product';
|
||||
}
|
||||
|
||||
const env = match[1].toLowerCase();
|
||||
return ENVIRONMENT_MAP[env] || 'other';
|
||||
export function getApkTabs(): string[] {
|
||||
return APK_TABS;
|
||||
}
|
||||
|
||||
export function parseApkFilename(filename: string): ParsedApkMetadata {
|
||||
const environment = detectEnvironment(filename);
|
||||
const commitMatch = filename.match(/([a-f0-9]{7,})\.apk$/);
|
||||
const commit = commitMatch ? commitMatch[1] : null;
|
||||
const isValid = commit !== null;
|
||||
export function detectEnvironment(prefix: string, configuredTabs: string[]): { environment: ApkEnvironment; rawEnvironment: string } {
|
||||
// Empty prefix → other
|
||||
if (!prefix) {
|
||||
return { environment: 'other', rawEnvironment: 'other' };
|
||||
}
|
||||
|
||||
// No underscore means product (e.g., "ft")
|
||||
if (!prefix.includes('_')) {
|
||||
return { environment: 'product', rawEnvironment: 'product' };
|
||||
}
|
||||
|
||||
// Extract environment from last segment after underscore
|
||||
const lastUnderscoreIndex = prefix.lastIndexOf('_');
|
||||
const rawEnv = prefix.substring(lastUnderscoreIndex + 1);
|
||||
|
||||
// Check if environment is configured
|
||||
if (configuredTabs.includes(rawEnv)) {
|
||||
return { environment: rawEnv as ApkEnvironment, rawEnvironment: rawEnv };
|
||||
}
|
||||
|
||||
return { environment: 'other', rawEnvironment: rawEnv };
|
||||
}
|
||||
|
||||
export function parseApkFilename(filename: string, version: string): ParsedApkMetadata {
|
||||
// Use version as delimiter (with leading underscore)
|
||||
const delimiter = `_${version}_`;
|
||||
const filenameWithoutExt = filename.replace(/\.apk$/, '');
|
||||
const parts = filenameWithoutExt.split(delimiter);
|
||||
const { environment, rawEnvironment } = detectEnvironment(parts[0], APK_TABS);
|
||||
const commit = parts[1].substring(0, 9);
|
||||
|
||||
return {
|
||||
environment,
|
||||
rawEnvironment,
|
||||
commit,
|
||||
isValid,
|
||||
isValid: commit !== null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user