Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ jobs:

# ==================== GitHub-hosted 标准链路 ====================
ci-standard:
if: vars.USE_SELF_HOSTED != 'true'
if: always() && (vars.USE_SELF_HOSTED != 'true' || needs.ci-fast.result != 'success')
needs: [ci-fast]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
Expand Down Expand Up @@ -131,7 +132,7 @@ jobs:
fi

checks-standard:
if: vars.USE_SELF_HOSTED != 'true'
if: always() && needs.ci-standard.result == 'success'
needs: ci-standard
runs-on: ubuntu-latest
steps:
Expand Down
5 changes: 3 additions & 2 deletions miniapps/biobridge/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs'

const E2E_SCREENSHOTS_DIR = resolve(__dirname, '../../e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts')
const MANIFEST_PATH = resolve(__dirname, 'manifest.json')
const ENABLE_HTTPS_DEV = !process.env.CI

function getShortId(): string {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'))
Expand Down Expand Up @@ -51,7 +52,7 @@ function miniappPlugin(): Plugin {

export default defineConfig({
plugins: [
mkcert(),
...(ENABLE_HTTPS_DEV ? [mkcert()] : []),
react(),
tsconfigPaths(),
tailwindcss(),
Expand All @@ -63,7 +64,7 @@ export default defineConfig({
emptyOutDir: true,
},
server: {
https: true,
https: ENABLE_HTTPS_DEV,
port: 5184,
fs: {
allow: [resolve(__dirname, '../..')],
Expand Down
5 changes: 3 additions & 2 deletions miniapps/teleport/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs'

const E2E_SCREENSHOTS_DIR = resolve(__dirname, '../../e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts')
const MANIFEST_PATH = resolve(__dirname, 'manifest.json')
const ENABLE_HTTPS_DEV = !process.env.CI

function getShortId(): string {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'))
Expand Down Expand Up @@ -51,7 +52,7 @@ function miniappPlugin(): Plugin {

export default defineConfig({
plugins: [
mkcert(),
...(ENABLE_HTTPS_DEV ? [mkcert()] : []),
react(),
tsconfigPaths(),
tailwindcss(),
Expand All @@ -63,7 +64,7 @@ export default defineConfig({
emptyOutDir: true,
},
server: {
https: true,
https: ENABLE_HTTPS_DEV,
port: 5185,
fs: {
allow: [resolve(__dirname, '../..')],
Expand Down
28 changes: 26 additions & 2 deletions src/components/security/pattern-lock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface PatternLockProps {
success?: boolean;
/** 错误提示文案(可选,覆盖默认错误文案) */
errorText?: string;
/** 状态提示文案(可选,覆盖默认提示文案) */
hintText?: string;
/** 状态提示语气(仅在 hintText 生效时使用) */
hintTone?: 'default' | 'destructive' | 'primary';
/** 底部详情文案(可选) */
footerText?: string;
/** 额外的 className */
className?: string;
/** 网格大小 (默认 3x3) */
Expand Down Expand Up @@ -51,6 +57,9 @@ export function PatternLock({
error = false,
success = false,
errorText,
hintText,
hintTone = 'default',
footerText,
className,
size = 3,
'data-testid': testId,
Expand Down Expand Up @@ -504,6 +513,19 @@ export function PatternLock({
<p className="text-destructive text-sm">
{errorText ?? t('patternLock.error')}
</p>
) : hintText ? (
<p
className={cn(
'text-sm',
hintTone === 'destructive'
? 'text-destructive'
: hintTone === 'primary'
? 'text-primary'
: 'text-muted-foreground',
)}
>
{hintText}
</p>
) : selectedNodes.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('patternLock.hint', { min: minPoints })}
Expand All @@ -525,7 +547,9 @@ export function PatternLock({

{/* 清除按钮 - 固定高度避免布局抖动 */}
<div className="h-5">
{selectedNodes.length > 0 && !disabled && !isErrorAnimating && (
{footerText ? (
<p className="text-destructive/80 truncate text-left text-xs">{footerText}</p>
) : selectedNodes.length > 0 && !disabled && !isErrorAnimating ? (
<button
type="button"
onClick={handleClear}
Expand All @@ -534,7 +558,7 @@ export function PatternLock({
>
{t('patternLock.clear')}
</button>
)}
) : null}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,26 @@ vi.mock('@/components/wallet/chain-address-display', () => ({
}));

vi.mock('@/components/security/pattern-lock', () => ({
PatternLock: ({ onComplete }: { onComplete?: (nodes: number[]) => void }) => (
<button
type="button"
data-testid="pattern-lock"
onClick={() => onComplete?.([1, 2, 3, 4])}
>
pattern
</button>
PatternLock: ({
onComplete,
hintText,
footerText,
}: {
onComplete?: (nodes: number[]) => void;
hintText?: string;
footerText?: string;
}) => (
<div>
<button
type="button"
data-testid="pattern-lock"
onClick={() => onComplete?.([1, 2, 3, 4])}
>
pattern
</button>
<span data-testid="pattern-lock-hint">{hintText ?? ''}</span>
<span data-testid="pattern-lock-footer">{footerText ?? ''}</span>
</div>
),
patternToString: (nodes: number[]) => nodes.join(''),
}));
Expand Down Expand Up @@ -227,6 +239,39 @@ describe('miniapp confirm jobs regressions', () => {
expect(screen.getByTestId('miniapp-sheet-header')).toBeInTheDocument();
});

it('shows sign service feedback in pattern hint and detail footer', async () => {
vi.mocked(signUnsignedTransaction).mockRejectedValueOnce(new Error('sign service timeout'));

render(
<MiniappSignTransactionJob
params={{
appName: 'Org App',
appIcon: '',
from: 'b_sender_1',
chain: 'BFMetaV2',
unsignedTx: superjson.stringify({
chainId: 'bfmetav2',
intentType: 'transfer',
data: { tx: 'unsigned' },
}),
}}
/>,
);

const signButton = screen.getByTestId('miniapp-sign-review-confirm');
await waitFor(() => {
expect(signButton).not.toBeDisabled();
});

fireEvent.click(signButton);
fireEvent.click(screen.getByTestId('pattern-lock'));

await waitFor(() => {
expect(screen.getByTestId('pattern-lock-hint').textContent?.length).toBeGreaterThan(0);
});
expect(screen.getByTestId('pattern-lock-footer').textContent).toContain('sign service timeout');
});

it('does not pass raw amount directly to display layer', () => {
render(
<MiniappTransferConfirmJob
Expand Down Expand Up @@ -1047,7 +1092,10 @@ describe('miniapp confirm jobs regressions', () => {
fireEvent.click(confirmButton);
fireEvent.click(screen.getByTestId('pattern-lock'));

expect(await screen.findByTestId('miniapp-transfer-error')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('pattern-lock-hint').textContent?.length).toBeGreaterThan(0);
});
expect(screen.getByTestId('pattern-lock-footer').textContent).toContain('Request timeout');
await waitFor(() => {
expect(screen.queryByTestId('miniapp-transfer-broadcasting-status')).not.toBeInTheDocument();
});
Expand Down
63 changes: 55 additions & 8 deletions src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,32 @@ type MiniappSignTransactionJobParams = {

type SignStep = MiniappSignFlowStep;

function collectErrorMessages(error: unknown): string[] {
const messages: string[] = [];
const visited = new Set<unknown>();
let current: unknown = error;

while (current instanceof Error && !visited.has(current)) {
visited.add(current);
if (current.message) {
messages.push(current.message);
}
current = (current as Error & { cause?: unknown }).cause;
}

return messages;
}

function extractSignErrorDetail(error: unknown): string | null {
for (const message of collectErrorMessages(error)) {
const normalized = message.trim();
if (normalized.length > 0) {
return normalized;
}
}
return null;
}

function MiniappSignTransactionJobContent() {
const { t } = useTranslation('common');
const { pop } = useFlow();
Expand All @@ -56,6 +82,7 @@ function MiniappSignTransactionJobContent() {
const [patternError, setPatternError] = useState(false);
const [twoStepSecretError, setTwoStepSecretError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [errorDetail, setErrorDetail] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [requiresTwoStepSecret, setRequiresTwoStepSecret] = useState(false);
const [isResolvingTwoStepSecret, setIsResolvingTwoStepSecret] = useState(true);
Expand Down Expand Up @@ -85,6 +112,7 @@ function MiniappSignTransactionJobContent() {
setTwoStepSecret('');
setTwoStepSecretError(false);
setErrorMessage(null);
setErrorDetail(null);
}, []);

useEffect(() => {
Expand Down Expand Up @@ -120,25 +148,27 @@ function MiniappSignTransactionJobContent() {

const handlePatternChange = useCallback(
(nextPattern: number[]) => {
if (patternError || errorMessage || twoStepSecretError) {
if (patternError || errorMessage || twoStepSecretError || errorDetail) {
setPatternError(false);
setTwoStepSecretError(false);
setErrorMessage(null);
setErrorDetail(null);
}
setPattern(nextPattern);
},
[patternError, errorMessage, twoStepSecretError],
[patternError, errorMessage, twoStepSecretError, errorDetail],
);

const handleTwoStepSecretChange = useCallback(
(value: string) => {
if (twoStepSecretError || errorMessage) {
if (twoStepSecretError || errorMessage || errorDetail) {
setTwoStepSecretError(false);
setErrorMessage(null);
setErrorDetail(null);
}
setTwoStepSecret(value);
},
[twoStepSecretError, errorMessage],
[twoStepSecretError, errorMessage, errorDetail],
);

const performSign = useCallback(
Expand Down Expand Up @@ -180,6 +210,7 @@ function MiniappSignTransactionJobContent() {
setPatternError(false);
setTwoStepSecretError(false);
setErrorMessage(null);
setErrorDetail(null);

try {
setStep('signing');
Expand All @@ -189,15 +220,20 @@ function MiniappSignTransactionJobContent() {
if (isMiniappWalletLockError(error)) {
setPatternError(true);
setErrorMessage(t('walletLock.error'));
setErrorDetail(null);
setStep('wallet_lock');
} else if (isMiniappTwoStepSecretError(error)) {
setPatternError(false);
setTwoStepSecretError(true);
setErrorMessage(t('transaction:sendPage.twoStepSecretError'));
setErrorDetail(null);
setStep('two_step_secret');
} else {
setPatternError(false);
setTwoStepSecretError(false);
setErrorMessage(t('signingFailed'));
setErrorDetail(extractSignErrorDetail(error));
setStep('wallet_lock');
}
setPattern([]);
} finally {
Expand All @@ -216,6 +252,7 @@ function MiniappSignTransactionJobContent() {
setIsSubmitting(true);
setTwoStepSecretError(false);
setErrorMessage(null);
setErrorDetail(null);

try {
setStep('signing');
Expand All @@ -225,14 +262,17 @@ function MiniappSignTransactionJobContent() {
if (isMiniappTwoStepSecretError(error)) {
setTwoStepSecretError(true);
setErrorMessage(t('transaction:sendPage.twoStepSecretError'));
setErrorDetail(null);
setStep('two_step_secret');
} else if (isMiniappWalletLockError(error)) {
setPatternError(true);
setErrorMessage(t('walletLock.error'));
setErrorDetail(null);
setStep('wallet_lock');
} else {
setTwoStepSecretError(false);
setErrorMessage(t('signingFailed'));
setErrorDetail(extractSignErrorDetail(error));
setStep('two_step_secret');
}
} finally {
Expand Down Expand Up @@ -261,6 +301,9 @@ function MiniappSignTransactionJobContent() {
}
}, [unsignedTx]);

const walletLockServiceMessage = !patternError && errorMessage ? errorMessage : null;
const walletLockErrorDetail = !patternError ? errorDetail : null;

return (
<BottomSheet onCancel={handleCancel}>
<div className="bg-background rounded-t-2xl">
Expand Down Expand Up @@ -343,6 +386,7 @@ function MiniappSignTransactionJobContent() {
{t('cancel')}
</button>
<button
data-testid="miniapp-sign-review-confirm"
onClick={handleEnterWalletLockStep}
disabled={isSubmitting || !unsignedTx || !walletId || isResolvingTwoStepSecret}
className={cn(
Expand Down Expand Up @@ -372,12 +416,15 @@ function MiniappSignTransactionJobContent() {
disabled={isSubmitting || !walletId}
error={patternError}
errorText={patternError ? t('walletLock.error') : undefined}
hintText={walletLockServiceMessage ?? undefined}
hintTone={walletLockServiceMessage ? 'destructive' : 'default'}
footerText={
walletLockErrorDetail
? `${t('common:service.technicalDetails')}: ${walletLockErrorDetail}`
: undefined
}
/>

{errorMessage && !patternError && (
<div className="bg-destructive/10 text-destructive rounded-xl p-3 text-sm">{errorMessage}</div>
)}

{isSubmitting && (
<div className="bg-muted text-muted-foreground flex items-center justify-center gap-2 rounded-xl p-3 text-sm">
<IconLoader2 className="size-4 animate-spin" />
Expand Down
Loading
Loading