test: add full playwright ui regression coverage
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s

This commit is contained in:
2026-04-02 00:55:34 +08:00
parent 7de4ddc3ee
commit ee0bec4a78
32 changed files with 5100 additions and 336 deletions

View File

@@ -58,6 +58,24 @@ const defaultMetadataForm: MediaMetadataFormState = {
notes: '',
}
function normalizeMediaTags(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
}
function normalizeMediaItem(item: AdminMediaObjectResponse): AdminMediaObjectResponse {
return {
...item,
tags: normalizeMediaTags(item.tags),
}
}
function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState {
if (!item) {
return defaultMetadataForm
@@ -67,7 +85,7 @@ function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFor
title: item.title ?? '',
altText: item.alt_text ?? '',
caption: item.caption ?? '',
tags: item.tags.join(', '),
tags: normalizeMediaTags(item.tags).join(', '),
notes: item.notes ?? '',
}
}
@@ -111,8 +129,9 @@ export function MediaPage() {
}
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
const normalizedItems = result.items.map(normalizeMediaItem)
startTransition(() => {
setItems(result.items)
setItems(normalizedItems)
setProvider(result.provider)
setBucket(result.bucket)
})
@@ -219,6 +238,7 @@ export function MediaPage() {
<Button
variant="danger"
disabled={!selectedKeys.length || batchDeleting}
data-testid="media-batch-delete"
onClick={async () => {
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
return
@@ -267,6 +287,7 @@ export function MediaPage() {
<option value="uploads/"></option>
</Select>
<Input
data-testid="media-search"
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
@@ -275,6 +296,7 @@ export function MediaPage() {
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
<Input
data-testid="media-upload-input"
type="file"
multiple
accept="image/*"
@@ -300,6 +322,7 @@ export function MediaPage() {
/>
<Button
disabled={!uploadFiles.length || uploading}
data-testid="media-upload"
onClick={async () => {
try {
setUploading(true)
@@ -399,6 +422,7 @@ export function MediaPage() {
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={metadataSaving}
data-testid="media-save-metadata"
onClick={async () => {
if (!activeItem) {
return
@@ -423,7 +447,7 @@ export function MediaPage() {
title: result.title,
alt_text: result.alt_text,
caption: result.caption,
tags: result.tags,
tags: normalizeMediaTags(result.tags),
notes: result.notes,
}
: item,
@@ -473,10 +497,12 @@ export function MediaPage() {
{filteredItems.map((item, index) => {
const selected = selectedKeys.includes(item.key)
const replaceInputId = `replace-media-${index}`
const itemTags = normalizeMediaTags(item.tags)
return (
<Card
key={item.key}
data-testid={`media-item-${index}`}
className={`overflow-hidden ${activeKey === item.key ? 'ring-1 ring-primary/40' : ''}`}
>
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
@@ -504,9 +530,9 @@ export function MediaPage() {
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
{item.title ? <p className="text-sm text-foreground">{item.title}</p> : null}
{item.tags.length ? (
{itemTags.length ? (
<div className="flex flex-wrap gap-2">
{item.tags.slice(0, 4).map((tag) => (
{itemTags.slice(0, 4).map((tag) => (
<Badge key={`${item.key}-${tag}`} variant="outline">
{tag}
</Badge>
@@ -515,7 +541,12 @@ export function MediaPage() {
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setActiveKey(item.key)}>
<Button
size="sm"
variant="outline"
onClick={() => setActiveKey(item.key)}
data-testid={`media-edit-${index}`}
>
</Button>
<Button
@@ -541,6 +572,7 @@ export function MediaPage() {
</Button>
<input
id={replaceInputId}
data-testid={`media-replace-input-${index}`}
className="hidden"
type="file"
accept="image/*"
@@ -583,6 +615,7 @@ export function MediaPage() {
size="sm"
variant="danger"
disabled={deletingKey === item.key || replacingKey === item.key}
data-testid={`media-delete-${index}`}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return