合并最新主分支并清理新增策划耦合
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
合入 master 的聊天界面、回合控制及运行时更新 解决七处合并冲突,保持退役策划 V1/V2 链路删除 将新增界面判断连接到 Design Agent 状态并移除旧 GDD 测试依赖 保留现役文档建造指令、回合终止及推理档设置
This commit is contained in:
@@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { type ContextType, startTransition, Suspense } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearSignedAssetReadUrlCache } from '../../services/assetReadUrlService';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { CreationLandingView } from './CreationLandingView';
|
||||
|
||||
@@ -153,6 +154,7 @@ function SuspendAfterLandingRender({ active }: { active: boolean }) {
|
||||
|
||||
describe('CreationLandingView', () => {
|
||||
beforeEach(() => {
|
||||
clearSignedAssetReadUrlCache();
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
@@ -161,6 +163,7 @@ describe('CreationLandingView', () => {
|
||||
listPublicEditorProjectResourcesMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
toggleEditorShowcaseAssetLikeMock.mockReset();
|
||||
clearSignedAssetReadUrlCache();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -1351,7 +1354,7 @@ describe('CreationLandingView', () => {
|
||||
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it('loads more featured resources when the list reaches the end', async () => {
|
||||
const observers = installIntersectionObserverMock();
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView';
|
||||
import {
|
||||
ImageCanvasSelectedLayerToolbarView,
|
||||
type ImageCanvasSelectedToolbarAction,
|
||||
} from './ImageCanvasSelectedLayerToolbarView';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
@@ -67,6 +70,121 @@ function renderSelectedToolbar(
|
||||
}
|
||||
|
||||
describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => {
|
||||
const props = renderSelectedToolbar({
|
||||
supportedActions: new Set(['quick-edit', 'download']),
|
||||
downloadLabel: '导出',
|
||||
extraActions: <button>引用</button>,
|
||||
endActions: <button>删除素材</button>,
|
||||
});
|
||||
const toolbar = screen.getByRole('toolbar');
|
||||
expect(
|
||||
toolbar.querySelectorAll(
|
||||
'.image-canvas-editor__floating-toolbar-divider',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.textContent),
|
||||
).toEqual(['快速编辑', '引用', '导出', '删除素材']);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '导出' }).nextElementSibling,
|
||||
).toBe(screen.getByRole('button', { name: '删除素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
||||
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
|
||||
});
|
||||
|
||||
it('任意动作组合都不生成相邻、开头或结尾分隔线', () => {
|
||||
const actions: ImageCanvasSelectedToolbarAction[] = [
|
||||
'quick-edit',
|
||||
'crop-expand',
|
||||
'redraw',
|
||||
'download',
|
||||
];
|
||||
for (const mediaType of ['image', 'audio', 'video'] as const) {
|
||||
for (let mask = 0; mask < 1 << actions.length; mask += 1) {
|
||||
for (const withExtraActions of [false, true]) {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({ mediaType }),
|
||||
supportedActions: new Set(
|
||||
actions.filter((_, index) => (mask & (1 << index)) !== 0),
|
||||
),
|
||||
extraActions: withExtraActions ? <button>信息</button> : null,
|
||||
});
|
||||
const toolbar = screen.getByRole('toolbar');
|
||||
const divider = '.image-canvas-editor__floating-toolbar-divider';
|
||||
expect(toolbar.querySelector(`${divider} + ${divider}`)).toBeNull();
|
||||
expect(toolbar.firstElementChild?.matches(divider) ?? false).toBe(
|
||||
false,
|
||||
);
|
||||
expect(toolbar.lastElementChild?.matches(divider) ?? false).toBe(
|
||||
false,
|
||||
);
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('空的条件动作和嵌套 Fragment 不产生空分组', () => {
|
||||
renderSelectedToolbar({
|
||||
supportedActions: new Set(['quick-edit', 'download']),
|
||||
extraActions: (
|
||||
<>
|
||||
{null}
|
||||
<>{false}</>
|
||||
</>
|
||||
),
|
||||
endActions: <>{null}</>,
|
||||
});
|
||||
const toolbar = screen.getByRole('toolbar');
|
||||
expect(
|
||||
toolbar.querySelectorAll(
|
||||
'.image-canvas-editor__floating-toolbar-divider',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['快速编辑', '下载按钮']);
|
||||
expect(toolbar.lastElementChild?.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it.each(['image', 'audio', 'video', 'image-sequence', undefined] as const)(
|
||||
'%s 素材使用同一个带文字导出入口与回调',
|
||||
(mediaType) => {
|
||||
const props = renderSelectedToolbar({
|
||||
selectedLayer: createLayer({ mediaType }),
|
||||
supportedActions: new Set(['download']),
|
||||
downloadLabel: '导出',
|
||||
extraActions: <button>信息</button>,
|
||||
endActions: <button>删除素材</button>,
|
||||
});
|
||||
const toolbar = screen.getByRole('toolbar');
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.textContent),
|
||||
).toEqual(['信息', '导出', '删除素材']);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '导出' }).nextElementSibling,
|
||||
).toBe(screen.getByRole('button', { name: '删除素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
||||
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
|
||||
},
|
||||
);
|
||||
|
||||
it('宿主未放行下载时不展示导出入口', () => {
|
||||
renderSelectedToolbar({
|
||||
supportedActions: new Set(),
|
||||
downloadLabel: '导出',
|
||||
extraActions: <button>信息</button>,
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: '导出' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders common layer actions in the Lovart toolbar order and forwards callbacks', () => {
|
||||
const props = renderSelectedToolbar();
|
||||
const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
|
||||
|
||||
@@ -10,7 +10,13 @@ import {
|
||||
Sparkles,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import {
|
||||
Children,
|
||||
type CSSProperties,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
@@ -39,8 +45,12 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
* 让没有对应后端链路的画布宿主不必把「点了没反应」的按钮渲染出来。
|
||||
*/
|
||||
supportedActions?: ReadonlySet<ImageCanvasSelectedToolbarAction> | null;
|
||||
/** 宿主在工具条上追加的动作节点,渲染在下载按钮之前。 */
|
||||
/** 宿主在工具条上追加的常规动作,渲染在下载按钮之前。 */
|
||||
extraActions?: ReactNode;
|
||||
/** 工具栏末组动作,紧随下载按钮,与下载共用一个分组。 */
|
||||
endActions?: ReactNode;
|
||||
/** 提供文字时显示带文字的导出入口;缺省保持网页画布的图标按钮。 */
|
||||
downloadLabel?: string;
|
||||
selectedLayer: CanvasLayer | null;
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
|
||||
@@ -58,9 +68,20 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
onDownloadLayer: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
/** 条件动作通常放在 Fragment 中;空 Fragment 不能产生一个空分组。 */
|
||||
function hasToolbarActions(actions: ReactNode): boolean {
|
||||
return Children.toArray(actions).some((action) =>
|
||||
isValidElement<{ children?: ReactNode }>(action) && action.type === Fragment
|
||||
? hasToolbarActions(action.props.children)
|
||||
: action !== '',
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasSelectedLayerToolbarView({
|
||||
supportedActions = null,
|
||||
extraActions,
|
||||
endActions,
|
||||
downloadLabel,
|
||||
selectedLayer,
|
||||
selectedToolbarStyle,
|
||||
onOpenQuickEditPanel,
|
||||
@@ -88,12 +109,35 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
'redraw',
|
||||
canOpenRedrawPanel(selectedLayer),
|
||||
);
|
||||
const extraActionDivider = extraActions ? (
|
||||
const showDownload = isActionSupported('download', true);
|
||||
const downloadAction = showDownload ? (
|
||||
downloadLabel ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={downloadLabel}
|
||||
title={downloadLabel}
|
||||
icon={<Download className="h-4 w-4" />}
|
||||
onClick={() => onDownloadLayer(selectedLayer)}
|
||||
>
|
||||
<span>{downloadLabel}</span>
|
||||
</CanvasChromeButton>
|
||||
) : (
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
icon={Download}
|
||||
onClick={() => onDownloadLayer(selectedLayer)}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
const hasExtraActions = hasToolbarActions(extraActions);
|
||||
const hasEndActions = showDownload || hasToolbarActions(endActions);
|
||||
const divider = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
|
||||
if (selectedLayer.mediaType === 'audio') {
|
||||
return (
|
||||
@@ -115,16 +159,11 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
<span>改造</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{extraActionDivider}
|
||||
{canRedraw && hasExtraActions ? divider : null}
|
||||
{extraActions}
|
||||
{isActionSupported('download', true) ? (
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
icon={Download}
|
||||
onClick={() => onDownloadLayer(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{(canRedraw || hasExtraActions) && hasEndActions ? divider : null}
|
||||
{downloadAction}
|
||||
{endActions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +180,25 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
'character-animation',
|
||||
selectedLayer.assetKind === 'character',
|
||||
);
|
||||
const showCropExpand =
|
||||
canRasterEdit && isActionSupported('crop-expand', true);
|
||||
const showRemoveBackground =
|
||||
canRasterEdit && isActionSupported('remove-background', true);
|
||||
const showPerfectPixel =
|
||||
canRasterEdit && isActionSupported('perfect-pixel', true);
|
||||
const showSplitIconSpritesheet =
|
||||
selectedLayer.assetKind === 'icon-spritesheet' &&
|
||||
isActionSupported('split-icon-spritesheet', true);
|
||||
const showExtractUiDesign =
|
||||
selectedLayer.assetKind === 'ui-design' &&
|
||||
isActionSupported('extract-ui-design', true);
|
||||
const hasEditingActions =
|
||||
showCropExpand ||
|
||||
showRemoveBackground ||
|
||||
showPerfectPixel ||
|
||||
showSplitIconSpritesheet ||
|
||||
showExtractUiDesign ||
|
||||
showCharacterAnimation;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -151,23 +209,18 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{showQuickEdit ? (
|
||||
<>
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="快速编辑"
|
||||
title="快速编辑"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||
>
|
||||
<span>快速编辑</span>
|
||||
</CanvasChromeButton>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
</>
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="快速编辑"
|
||||
title="快速编辑"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||
>
|
||||
<span>快速编辑</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{canRasterEdit && isActionSupported('crop-expand', true) ? (
|
||||
{showQuickEdit && hasEditingActions ? divider : null}
|
||||
{showCropExpand ? (
|
||||
<EditorIconButton
|
||||
label="裁扩按钮"
|
||||
title="裁扩按钮"
|
||||
@@ -175,7 +228,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenCropExpandPanel(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{canRasterEdit && isActionSupported('remove-background', true) ? (
|
||||
{showRemoveBackground ? (
|
||||
<EditorIconButton
|
||||
label="去除背景按钮"
|
||||
title="去除背景按钮"
|
||||
@@ -183,7 +236,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{canRasterEdit && isActionSupported('perfect-pixel', true) ? (
|
||||
{showPerfectPixel ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
@@ -236,8 +289,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' &&
|
||||
isActionSupported('split-icon-spritesheet', true) ? (
|
||||
{showSplitIconSpritesheet ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={
|
||||
@@ -274,8 +326,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'ui-design' &&
|
||||
isActionSupported('extract-ui-design', true) ? (
|
||||
{showExtractUiDesign ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="提取素材"
|
||||
@@ -299,10 +350,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
) : null}
|
||||
{canRedraw ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
{showQuickEdit || hasEditingActions ? divider : null}
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
@@ -314,16 +362,16 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
) : null}
|
||||
{extraActionDivider}
|
||||
{(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions
|
||||
? divider
|
||||
: null}
|
||||
{extraActions}
|
||||
{isActionSupported('download', true) ? (
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
icon={Download}
|
||||
onClick={() => onDownloadLayer(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) &&
|
||||
hasEndActions
|
||||
? divider
|
||||
: null}
|
||||
{downloadAction}
|
||||
{endActions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5090,18 +5090,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
/* 紧挨着的两条分隔线只保留一条。
|
||||
共享工具条会在「快速编辑」之后输出一条(`ImageCanvasSelectedLayerToolbarView.tsx`
|
||||
的 showQuickEdit 分支),并为 `extraActions` 再自动生成一条前置分隔线
|
||||
(同文件 `extraActionDivider`)。AGC 资源卡恰好是"快速编辑可用 + 中间那些动作未接通
|
||||
不渲染"的组合,于是这两条直接相邻,用户看到两条竖线。两条相邻的分隔线在视觉上
|
||||
本来就没有意义,所以在样式层去重:不改任何 JSX 结构,对两端(美术画布与资源画布)
|
||||
都成立,也不会隐藏任何真正起分隔作用的那一条。 */
|
||||
.image-canvas-editor__floating-toolbar-divider
|
||||
+ .image-canvas-editor__floating-toolbar-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar-option-wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@@ -322,6 +322,7 @@ describe('apiClient', () => {
|
||||
it('emits auth change events when refresh fails on protected requests', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
@@ -330,7 +331,12 @@ describe('apiClient', () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
// 业务 401 + refresh 401 收敛重试 + refresh 仍 401:只有两次刷新都被明确拒绝,
|
||||
// 才判定登录态权威失效并广播一次全局鉴权变化。
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'),
|
||||
).toHaveLength(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
@@ -405,7 +411,9 @@ describe('apiClient', () => {
|
||||
|
||||
it('keeps local token when explicit refresh opts out of clearing on failure', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(
|
||||
refreshStoredAccessToken({ clearOnFailure: false }),
|
||||
@@ -456,7 +464,9 @@ describe('apiClient', () => {
|
||||
|
||||
it('clears local token when refresh confirms the session is unauthorized', async () => {
|
||||
setStoredAccessToken('expired-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
@@ -466,6 +476,32 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('retries refresh once with the current cookie after a rotation race', async () => {
|
||||
setStoredAccessToken('expired-local-token', { emit: false });
|
||||
fetchMock
|
||||
// 并发轮换竞争:这一次 refresh 拿到的是被另一个客户端轮换过的旧 cookie。
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
// 收敛重试使用浏览器当前 cookie,拿到轮换后的新 token。
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: { token: 'converged-token' },
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(refreshStoredAccessToken()).resolves.toBe('converged-token');
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'),
|
||||
).toHaveLength(2);
|
||||
expect(getStoredAccessToken()).toBe('converged-token');
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not clear auth when protected request refresh fails transiently', async () => {
|
||||
setStoredAccessToken('expired-token-during-restart', { emit: false });
|
||||
fetchMock
|
||||
|
||||
@@ -775,7 +775,7 @@ async function refreshAccessToken() {
|
||||
return refreshAccessTokenAttempt.promise;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const performRefresh = async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -802,6 +802,23 @@ async function refreshAccessToken() {
|
||||
|
||||
publishRefreshedAccessToken(nextToken, authStateSnapshot);
|
||||
return nextToken;
|
||||
};
|
||||
const promise = (async () => {
|
||||
try {
|
||||
return await performRefresh();
|
||||
} catch (error) {
|
||||
const authoritative =
|
||||
error instanceof ApiClientError &&
|
||||
(error.status === 401 || error.status === 403);
|
||||
// 登录态已经变化(换号 / 退出 / 另一个 refresh 已发布新 token)时不要重试:
|
||||
// 这次 refresh 的归属已经过期,重试只会把旧账号的结论带到新代次上。
|
||||
if (!authoritative || !isCurrentAuthState(authStateSnapshot)) {
|
||||
throw error;
|
||||
}
|
||||
// 并发轮换收敛:另一个标签页 / 客户端可能刚刚轮换过 refresh cookie,用当前
|
||||
// cookie 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算权威失效。
|
||||
return await performRefresh();
|
||||
}
|
||||
})();
|
||||
const attempt: RefreshAccessTokenAttempt = {
|
||||
...authStateSnapshot,
|
||||
@@ -870,7 +887,7 @@ export async function fetchWithApiAuth(
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
let hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -886,7 +903,7 @@ export async function fetchWithApiAuth(
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestSignal?.aborted) {
|
||||
|
||||
Reference in New Issue
Block a user