diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js
index 257cd1711..14a4f6542 100644
--- a/blocks/canvas/canvas.js
+++ b/blocks/canvas/canvas.js
@@ -15,7 +15,6 @@ import {
removeSplitGutter,
} from './ew-editor-split/ew-editor-split.js';
import { resolveEditorDocSession } from './ew-editor-doc/utils/load-editor-doc.js';
-import { sourceUrlFromEditorCtx } from './ew-editor-doc/utils/ctx.js';
import { SEL_BLOCK, SEL_ITEM, SEL_TEXT } from './ew-editor-doc/utils/selection.js';
import { getChatPanelContent } from '../shared/chat-panel.js';
import { canvasBus } from './utils/canvas-bus.js';
@@ -108,7 +107,7 @@ async function syncCanvasEditorsToHash({ mountRoot, header, state }) {
return;
}
const ctx = editorCtxFromHashState(state, fullPath);
- const session = await resolveEditorDocSession(sourceUrlFromEditorCtx(ctx));
+ const session = await resolveEditorDocSession(ctx);
if (loadCount !== editorLoadCount) return;
if (!session.ok) {
removeCanvasEditors(mountRoot);
diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js
index 54da2e2ce..24f1b3e07 100644
--- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js
+++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js
@@ -5,7 +5,6 @@ import { updateDocument, updateCursors, getInstrumentedHTML, getEditor } from '.
import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js';
import {
editorDocCanLoad,
- sourceUrlFromEditorCtx,
controllerPathnameFromEditorCtx,
editorDocRenderPhase,
} from './utils/ctx.js';
@@ -24,6 +23,7 @@ import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount
import { teardownEditorDocResources } from './utils/teardown.js';
import { hideSelectionToolbar, setSelectionToolbarCtx } from '../editor-utils/selection-toolbar.js';
import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js';
+import mediaBusImage from './prose-plugins/mediaBusImage.js';
import { MESSAGE_TYPES } from '../utils/quick-edit-messages.js';
import { canvasBus } from '../utils/canvas-bus.js';
@@ -265,13 +265,12 @@ export class EwEditorDoc extends LitElement {
return;
}
- const sourceUrl = sourceUrlFromEditorCtx(this.ctx);
-
- const session = this.session ?? await resolveEditorDocSession(sourceUrl);
+ const session = this.session ?? await resolveEditorDocSession(this.ctx);
if (!session.ok) {
this._error = session.error;
return;
}
+ const { sourceUrl } = session;
try {
const { token, permissions } = session;
@@ -282,6 +281,7 @@ export class EwEditorDoc extends LitElement {
setEditable: (editable) => this._setEditable(editable),
getToken: () => token,
extraPlugins: [
+ mediaBusImage(this.ctx),
createExtensionsBridgePlugin(),
createTrackingPlugin(
() => {
diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js
index c21820ed8..7d84c8758 100644
--- a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js
+++ b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js
@@ -1,9 +1,7 @@
import { Plugin } from 'da-y-wrapper';
-import { getNx } from '../../../../scripts/utils.js';
-import { daFetch } from '../../../shared/utils.js';
+import { getNx2Api } from '../../../../scripts/utils.js';
import { getSourceUploadContext } from './sourceUploadContext.js';
-
-const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`);
+import { dataUrlByteLength, refuseOversizedImage } from '../../utils/image-upload.js';
const FPO_IMG_URL = '/blocks/edit/img/fpo.svg';
@@ -14,6 +12,42 @@ function makeHash(string) {
), 0));
}
+// the media bus is content addressed, so the src is only known from the response
+function removeFpo(view, fpoSrc) {
+ view.state.doc.descendants((node, pos) => {
+ if (node.type.name === 'image' && node.attrs.src === fpoSrc) {
+ view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize));
+ return false;
+ }
+ return true;
+ });
+}
+
+export async function uploadBase64Image(view, { src, path, fpoSrc, parent }) {
+ if (await refuseOversizedImage(dataUrlByteLength(src), parent)) {
+ removeFpo(view, fpoSrc);
+ return;
+ }
+ const resp = await fetch(src);
+ const blob = await resp.blob();
+ const { source } = await getNx2Api();
+ const uploadResp = await source.uploadMedia(path, { body: blob });
+ if (!uploadResp.ok) {
+ // eslint-disable-next-line no-console
+ console.error(`Failed to upload pasted image: ${uploadResp.status} ${uploadResp.statusText}`);
+ return;
+ }
+ const { source: { contentUrl } } = await uploadResp.json();
+
+ view.state.doc.descendants((node, pos) => {
+ if (node.type.name === 'image' && node.attrs.src === fpoSrc) {
+ view.dispatch(view.state.tr.setNodeMarkup(pos, null, { ...node.attrs, src: contentUrl }));
+ return false;
+ }
+ return true;
+ });
+}
+
/**
* @param {{
* getSourceUrl: () => string | null,
@@ -35,40 +69,16 @@ export default function base64Uploader({ getSourceUrl, getEditorView }) {
const details = getSourceUploadContext(getSourceUrl() ?? '');
if (!details) return html;
- const imagePaths = [];
- const uploadPromises = [];
-
dataImgs.forEach((img) => {
const src = img.getAttribute('src');
let ext = src.replace('data:image/', '').split(';base64')[0];
if (ext === 'jpeg') ext = 'jpg';
const path = `${details.parent}/.${details.name}/wp${makeHash(src)}.${ext}`;
- const fpoSrc = `${FPO_IMG_URL}#${DA_CONTENT}${path}`;
+ const fpoSrc = `${FPO_IMG_URL}#${makeHash(src)}`;
img.setAttribute('src', fpoSrc);
- imagePaths.push(fpoSrc);
-
- uploadPromises.push((async () => {
- const resp = await fetch(src);
- const blob = await resp.blob();
- const body = new FormData();
- body.append('data', blob);
- await daFetch(`${DA_ADMIN}/source${path}`, { body, method: 'POST' });
- })());
- });
- Promise.all(uploadPromises).then(() => {
const view = getEditorView();
- if (!view) return;
- const { tr } = view.state;
-
- view.state.doc.descendants((node, pos) => {
- if (node.type.name === 'image' && imagePaths.includes(node.attrs.src)) {
- const newAttrs = { src: node.attrs.src.split('#')[1] };
- tr.setNodeMarkup(pos, null, { ...node.attrs, ...newAttrs });
- }
- });
-
- view.dispatch(tr);
+ if (view) uploadBase64Image(view, { src, path, fpoSrc, parent: details.parent });
});
const serializer = new XMLSerializer();
diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js
index 49602a954..d63a81734 100644
--- a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js
+++ b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js
@@ -1,34 +1,44 @@
import { Plugin, TextSelection } from 'da-y-wrapper';
-import { daFetch } from '../../../shared/utils.js';
+import { getNx2Api } from '../../../../scripts/utils.js';
import { getSourceUploadContext } from './sourceUploadContext.js';
+import { refuseOversizedImage } from '../../utils/image-upload.js';
const FPO_IMG_URL = '/blocks/edit/img/fpo.svg';
export const SUPPORTED_IMAGE_FILES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/gif'];
export async function uploadImageFile(view, file, details) {
if (!SUPPORTED_IMAGE_FILES.some((type) => type === file.type)) return;
+ if (await refuseOversizedImage(file.size, details.parent)) return;
const { schema } = view.state;
const fpo = schema.nodes.image.create({ src: FPO_IMG_URL, style: 'width: 180px' });
view.dispatch(view.state.tr.replaceSelectionWith(fpo).scrollIntoView());
const { $from } = view.state.selection;
- const url = `${details.origin}/source${details.parent}/.${details.name}/${file.name}`;
+ const path = `${details.parent}/.${details.name}/${file.name}`;
- const formData = new FormData();
- formData.append('data', file);
- const resp = await daFetch(url, { method: 'PUT', body: formData });
+ // the media bus is content addressed, so the src is only known from the response
+ const { source } = await getNx2Api();
+ const resp = await source.uploadMedia(path, { body: file });
if (!resp.ok) return;
- const json = await resp.json();
+ const { source: { contentUrl } } = await resp.json();
- const docImg = document.createElement('img');
- docImg.addEventListener('load', () => {
+ const replaceFpo = () => {
const fpoSelection = TextSelection.create(view.state.doc, $from.pos - 1, $from.pos);
const ts = view.state.tr.setSelection(fpoSelection);
- const img = schema.nodes.image.create({ src: json.source.contentUrl });
+ const img = schema.nodes.image.create({ src: contentUrl });
view.dispatch(ts.replaceSelectionWith(img).scrollIntoView());
- });
- docImg.src = json.source.contentUrl;
+ };
+
+ // a media bus src is relative to the published page and cannot load from here
+ if (contentUrl.startsWith('./media_')) {
+ replaceFpo();
+ return;
+ }
+
+ const docImg = document.createElement('img');
+ docImg.addEventListener('load', replaceFpo);
+ docImg.src = contentUrl;
}
/**
diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js b/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js
new file mode 100644
index 000000000..f83c32092
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js
@@ -0,0 +1,36 @@
+// eslint-disable-next-line import/no-unresolved
+import { Plugin, PluginKey } from 'da-y-wrapper';
+import { getPreviewOrigin } from '../../editor-utils/editor-utils.js';
+
+const mediaBusImageKey = new PluginKey('canvasMediaBusImage');
+
+// a media bus src ("./media_123.png") only resolves where the page is served, so it is rewritten
+// to the doc's preview origin for display; node attrs and the saved document keep the relative
+// path. getPreviewOrigin matches the origin fetchWysiwygCookie logs into, unlike getLivePreviewUrl.
+export function getRenderableSrc(src, ctx) {
+ if (!src || !src.startsWith('./media_')) return null;
+ const { org, repo } = ctx ?? {};
+ if (!org || !repo) return null;
+ return `${getPreviewOrigin(org, repo)}/${src.slice(2)}`;
+}
+
+function updateImageSrcs(view, ctx) {
+ view.dom.querySelectorAll('img[src^="./media_"]').forEach((img) => {
+ const renderableSrc = getRenderableSrc(img.getAttribute('src'), ctx);
+ if (renderableSrc) img.src = renderableSrc;
+ });
+}
+
+export default function mediaBusImage(ctx) {
+ return new Plugin({
+ key: mediaBusImageKey,
+ view(view) {
+ updateImageSrcs(view, ctx);
+ return {
+ update(updatedView, prevState) {
+ if (updatedView.state.doc !== prevState.doc) updateImageSrcs(updatedView, ctx);
+ },
+ };
+ },
+ });
+}
diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js
index 085a1dc23..e6e0564b1 100644
--- a/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js
+++ b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js
@@ -1,30 +1,31 @@
/*
* Copyright 2026 Adobe. All rights reserved.
- * Derives upload parent/name from a DA source document URL (same shape as da.live getPathDetails).
+ * Derives the upload parent and name from a source document URL, in either store's shape.
*/
-import { getNx } from '../../../../scripts/utils.js';
-const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`);
+// da-admin: https://admin.da.live/source/{org}/{site}/dir/doc.html
+// source bus: https://api.aem.live/{org}/sites/{site}/source/dir/doc.html
+function orgSiteAndRest(pathname) {
+ const segments = pathname.split('/').filter(Boolean);
+ if (segments[0] === 'source') return segments.slice(1);
+ if (segments[1] === 'sites' && segments[3] === 'source') {
+ return [segments[0], segments[2], ...segments.slice(4)];
+ }
+ return null;
+}
/**
- * @param {string} sourceUrl - e.g. https://admin.da.live/source/org/repo/path/doc.html
- * @returns {{ origin: string, parent: string, name: string } | null}
+ * @param {string} sourceUrl a document url on either store
+ * @returns {{ parent: string, name: string } | null} the parent in the `/org/site/dir` form the
+ * source api takes, and the document name without its extension
*/
export function getSourceUploadContext(sourceUrl) {
if (!sourceUrl || typeof sourceUrl !== 'string') return null;
try {
- const u = new URL(sourceUrl);
- const mark = '/source/';
- const idx = u.pathname.indexOf(mark);
- if (idx === -1) return null;
- const rest = u.pathname.slice(idx + mark.length);
- const segments = rest.split('/').filter(Boolean);
- if (segments.length === 0) return null;
- const lastSeg = segments[segments.length - 1];
- const name = lastSeg.replace(/\.html?$/i, '');
- const parentSegments = segments.slice(0, -1);
- const parent = parentSegments.length ? `/${parentSegments.join('/')}` : '/';
- return { origin: DA_ADMIN, parent, name };
+ const segments = orgSiteAndRest(new URL(sourceUrl).pathname);
+ if (!segments || segments.length < 3) return null;
+ const name = segments[segments.length - 1].replace(/\.html?$/i, '');
+ return { parent: `/${segments.slice(0, -1).join('/')}`, name };
} catch {
return null;
}
diff --git a/blocks/canvas/ew-editor-doc/prose.js b/blocks/canvas/ew-editor-doc/prose.js
index b99e01d07..4a3cdfc63 100644
--- a/blocks/canvas/ew-editor-doc/prose.js
+++ b/blocks/canvas/ew-editor-doc/prose.js
@@ -43,7 +43,7 @@ import { generateColor, getCollabIdentity } from './utils/collab.js';
import { checkBlockLibraryConfigured } from '../editor-utils/block-slash.js';
import { canvasBus } from '../utils/canvas-bus.js';
-const { DA_ADMIN, DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`);
+const { DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`);
function registerErrorHandler(ydoc) {
ydoc.on('update', () => {
@@ -94,7 +94,8 @@ export default async function initProse({
const ydoc = new Y.Doc();
const server = DA_COLLAB;
- const roomName = `${DA_ADMIN}${new URL(path).pathname}`;
+ // da-collab reads the store off the room name, and `path` is already the store's source url.
+ const roomName = path;
const wsOpts = { protocols: ['yjs'] };
let lastSentToken = null;
diff --git a/blocks/canvas/ew-editor-doc/utils/ctx.js b/blocks/canvas/ew-editor-doc/utils/ctx.js
index 975da88e4..0fb7d773c 100644
--- a/blocks/canvas/ew-editor-doc/utils/ctx.js
+++ b/blocks/canvas/ew-editor-doc/utils/ctx.js
@@ -1,16 +1,13 @@
-import { buildSourceUrl } from './source.js';
-
-export function sourceUrlFromEditorCtx(ctx) {
- return buildSourceUrl(ctx?.path);
-}
+import { normalizeSourcePath } from './source.js';
export function editorCtxHasOrgRepoPath(ctx) {
const { org, repo, path } = ctx ?? {};
return Boolean(org && repo && path);
}
+// Stays synchronous: the render phase needs an answer without waiting on the store lookup.
export function editorDocCanLoad(ctx) {
- return editorCtxHasOrgRepoPath(ctx) && Boolean(sourceUrlFromEditorCtx(ctx));
+ return editorCtxHasOrgRepoPath(ctx) && Boolean(normalizeSourcePath(ctx.path));
}
export function controllerPathnameFromEditorCtx(ctx) {
diff --git a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js
index 19ade38ca..4226f15a8 100644
--- a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js
+++ b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js
@@ -1,19 +1,37 @@
-import { checkDoc } from './source.js';
+import { buildSourceUrl, checkDoc } from './source.js';
import { initIms } from '../../../shared/utils.js';
-export async function resolveEditorDocSession(sourceUrl) {
+export function sessionErrorFromResponse(resp) {
+ const status = resp?.status;
+ if (typeof status !== 'number') return { ok: false, error: 'Could not reach the content store' };
+ if (resp.ok || status === 404) return null;
+ if (status === 401) return { ok: false, error: 'Sign in required' };
+ if (status === 403) return { ok: false, error: 'Not permitted' };
+ const detail = resp.headers?.get?.('x-error');
+ const reason = detail ? `: ${detail}` : '';
+ return { ok: false, error: `Could not load the document (${status})${reason}` };
+}
+
+// takes the ctx rather than a url, so the sign-in check runs before the store lookup needs a token
+export async function resolveEditorDocSession(ctx) {
const ims = await initIms();
const token = ims?.accessToken?.token ?? null;
if (ims?.anonymous || !token) {
return { ok: false, error: 'Sign in required' };
}
- const resp = await checkDoc(sourceUrl);
- if (!resp.ok && resp.status !== 404) {
- const error = resp.status === 401 ? 'Sign in required' : 'Not permitted';
- return { ok: false, error };
+ let sourceUrl;
+ try {
+ sourceUrl = await buildSourceUrl(ctx?.path);
+ } catch {
+ return { ok: false, error: 'Could not reach the content store' };
}
+ if (!sourceUrl) return { ok: false, error: 'Could not reach the content store' };
+
+ const resp = await checkDoc(sourceUrl);
+ const failure = sessionErrorFromResponse(resp);
+ if (failure) return failure;
const permissions = resp.permissions || ['read'];
- return { ok: true, token, permissions };
+ return { ok: true, token, permissions, sourceUrl };
}
diff --git a/blocks/canvas/ew-editor-doc/utils/source.js b/blocks/canvas/ew-editor-doc/utils/source.js
index b22ccec4b..b22d12634 100644
--- a/blocks/canvas/ew-editor-doc/utils/source.js
+++ b/blocks/canvas/ew-editor-doc/utils/source.js
@@ -1,23 +1,29 @@
-import { getNx } from '../../../../scripts/utils.js';
+import { getNx, getNx2Api } from '../../../../scripts/utils.js';
import { daFetch } from '../../../shared/utils.js';
const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`);
-export function buildSourceUrl(path) {
+export function normalizeSourcePath(path) {
if (!path || typeof path !== 'string') return null;
const trimmed = path.replace(/^\//, '').trim();
- if (!trimmed) return null;
- return `${DA_ADMIN}/source/${trimmed}.html`;
+ return trimmed || null;
}
-export function parsePermissions(resp) {
- const hint = resp.headers.get('x-da-child-actions') ?? resp.headers.get('x-da-actions');
- if (hint) resp.permissions = hint.split('=').pop().split(',');
- else resp.permissions = ['read', 'write'];
- return resp;
+export async function buildSourceUrl(path) {
+ const trimmed = normalizeSourcePath(path);
+ if (!trimmed) return null;
+ const [org, site, ...parts] = trimmed.split('/');
+ const { AEM_API, isHlx6 } = await getNx2Api();
+ if (org && site && parts.length && await isHlx6(org, site)) {
+ return `${AEM_API}/${org}/sites/${site}/source/${parts.join('/')}.html`;
+ }
+ return `${DA_ADMIN}/source/${trimmed}.html`;
}
+// da-admin keeps da-live's own fetcher, which reads the token live and retries once on a 401.
+// nx2's is the one that allowlists api.aem.live for the bearer.
export async function checkDoc(sourceUrl) {
- const resp = await daFetch(sourceUrl, { method: 'HEAD' });
- return parsePermissions(resp);
+ if (sourceUrl.startsWith(DA_ADMIN)) return daFetch(sourceUrl, { method: 'HEAD' });
+ const { daFetch: nx2Fetch } = await getNx2Api();
+ return nx2Fetch({ url: sourceUrl, opts: { method: 'HEAD' } });
}
diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js
index 770a2408c..2e07649c5 100644
--- a/blocks/canvas/ew-editor-wysiwyg/utils/image.js
+++ b/blocks/canvas/ew-editor-wysiwyg/utils/image.js
@@ -1,7 +1,6 @@
-import { getNx } from '../../../../scripts/utils.js';
+import { getNx2Api } from '../../../../scripts/utils.js';
import { MESSAGE_TYPES } from '../../utils/quick-edit-messages.js';
-
-const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`);
+import { dataUrlByteLength, refuseOversizedImage } from '../../utils/image-upload.js';
function updateImageInDocument(view, originalSrc, newSrc) {
if (!view) return false;
@@ -62,33 +61,25 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c
ctx.suppressRerender = true;
try {
- // eslint-disable-next-line no-console
- console.log('handleImageReplace', fileName, originalSrc);
+ const sitePath = `/${ctx.owner}/${ctx.repo}`;
+ if (await refuseOversizedImage(dataUrlByteLength(imageData), sitePath)) {
+ ctx.port.postMessage({
+ type: MESSAGE_TYPES.IMAGE_REPLACE,
+ payload: { error: 'Image is too large', originalSrc },
+ });
+ return;
+ }
const blob = dataUrlToBlob(imageData);
const pageName = getPageName(ctx.path);
const parentPath = ctx.path === '/' ? '' : ctx.path.replace(/\/[^/]+$/, '');
- // Same upload path and URL as da-nx quick-edit-portal/src/images.js
- const uploadPath = `${parentPath}/.${pageName}/${fileName}`;
- const uploadUrl = `${DA_ADMIN}/source/${ctx.owner}/${ctx.repo}${uploadPath}`;
-
- const tokenPromise = typeof ctx.getToken === 'function' ? ctx.getToken() : null;
- const token = tokenPromise != null && typeof tokenPromise?.then === 'function'
- ? await tokenPromise
- : tokenPromise;
- const headers = {};
- if (token) headers.Authorization = `Bearer ${token}`;
+ // Same upload path as da-nx quick-edit-portal/src/images.js
+ const uploadPath = `/${ctx.owner}/${ctx.repo}${parentPath}/.${pageName}/${fileName}`;
- const formData = new FormData();
- formData.append('data', blob, fileName);
-
- const resp = await fetch(uploadUrl, {
- method: 'PUT',
- body: formData,
- headers,
- });
+ const { source } = await getNx2Api();
+ const resp = await source.uploadMedia(uploadPath, { body: blob });
if (!resp.ok) {
const error = `Upload failed with status ${resp.status}`;
@@ -99,8 +90,8 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c
return;
}
- // Same as da-nx: AEM delivery URL for the uploaded image
- const newSrc = `${DA_CONTENT}/${ctx.owner}/${ctx.repo}${uploadPath}`;
+ // the media bus is content addressed, so the src is only known from the response
+ const { source: { contentUrl: newSrc } } = await resp.json();
updateImageInDocument(ctx.view, originalSrc, newSrc);
diff --git a/blocks/canvas/ew-panel-extensions/helpers.js b/blocks/canvas/ew-panel-extensions/helpers.js
index b013af80f..8b009b8cf 100644
--- a/blocks/canvas/ew-panel-extensions/helpers.js
+++ b/blocks/canvas/ew-panel-extensions/helpers.js
@@ -1,7 +1,7 @@
/* eslint-disable import/no-unresolved -- importmap */
import { DOMParser as PMDOMParser, DOMSerializer, Slice, TextSelection } from 'da-y-wrapper';
-import { getNx } from '../../../scripts/utils.js';
-import { aemAdmin, daFetch } from '../../shared/utils.js';
+import { getNx, getNx2Api } from '../../../scripts/utils.js';
+import { daFetch } from '../../shared/utils.js';
import { htmlToProse } from '../../edit/utils/helpers.js';
import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js';
@@ -394,8 +394,10 @@ export async function insertTemplate(view, url) {
export async function getPreviewStatus({ org, site, pathname }) {
const path = `/${org}/${site}${pathname}`;
try {
- const json = await aemAdmin(path, 'status', 'GET');
- if (!json) return null;
+ const { status } = await getNx2Api();
+ const resp = await status.get(path);
+ if (!resp.ok) return null;
+ const json = await resp.json();
return json.preview?.status === 200;
} catch {
return null;
diff --git a/blocks/canvas/utils/image-upload.js b/blocks/canvas/utils/image-upload.js
new file mode 100644
index 000000000..2786957cc
--- /dev/null
+++ b/blocks/canvas/utils/image-upload.js
@@ -0,0 +1,39 @@
+import { getNx2, getNx2Api } from '../../../scripts/utils.js';
+
+// The api service runs on Lambda, which caps a request at 6 MiB once base64 has
+// inflated the body. Measured 2026-08-18, the AWS edge answers 413 from 4,717,360
+// bytes up, and that 413 carries no CORS header, so the browser reads it as a
+// network failure with no status. The size is checked before the request instead.
+// da-admin took a 120 MB body in the same probe, so the cap is the source bus's.
+export const MAX_IMAGE_BYTES = 4500000;
+const MAX_IMAGE_LABEL = '4.5 MB';
+
+export function isImageTooLarge(bytes) {
+ return bytes > MAX_IMAGE_BYTES;
+}
+
+export function dataUrlByteLength(dataUrl) {
+ const base64 = dataUrl?.split(';base64,')[1];
+ if (!base64) return 0;
+ const padding = (base64.endsWith('==') && 2) || (base64.endsWith('=') && 1) || 0;
+ return Math.floor(base64.length / 4) * 3 - padding;
+}
+
+export async function showImageTooLarge() {
+ const { showToast, VARIANT_ERROR } = await import(`${getNx2()}/blocks/shared/toast/toast.js`);
+ showToast({
+ text: `Image upload failed. Image size must be ${MAX_IMAGE_LABEL} or under`,
+ variant: VARIANT_ERROR,
+ });
+}
+
+// `parentPath` is the document's folder, `/org/site/dir`.
+export async function refuseOversizedImage(bytes, parentPath) {
+ if (!isImageTooLarge(bytes)) return false;
+ const [, org, site] = (parentPath ?? '').split('/');
+ if (!org || !site) return false;
+ const { isHlx6 } = await getNx2Api();
+ if (!await isHlx6(org, site)) return false;
+ await showImageTooLarge();
+ return true;
+}
diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js
index fc6606c52..c183d7538 100644
--- a/blocks/shared/utils.js
+++ b/blocks/shared/utils.js
@@ -342,8 +342,12 @@ export async function checkLockdownImages(owner) {
export const fetchDaConfigs = (() => {
const configCache = {};
- const fetchConfig = async (pathname) => {
- const resp = await daFetch(`${DA_ORIGIN}/config${pathname}/`);
+ // getNx2Api's config.get is isHlx6-aware: it reads from api.aem.live for a
+ // migrated org/site, and falls back to admin.da.live otherwise.
+ const fetchConfig = async (org, site) => {
+ const { config: configApi } = await getNx2Api();
+ const resp = await configApi.get({ org, site });
+ const pathname = site ? `/${org}/${site}` : `/${org}`;
if (!resp.ok) return { error: `Error loading ${pathname}`, status: resp.status };
return resp.json();
};
@@ -352,11 +356,11 @@ export const fetchDaConfigs = (() => {
if (!org) return [Promise.resolve(null)];
// Set the org config promise if it does not exist
- configCache[`/${org}`] ??= fetchConfig(`/${org}`);
+ configCache[`/${org}`] ??= fetchConfig(org);
if (site) {
// Set the site config promise if it does not exist
- configCache[`/${org}/${site}`] ??= fetchConfig(`/${org}/${site}`);
+ configCache[`/${org}/${site}`] ??= fetchConfig(org, site);
}
// return array of cached configs (org = 0, site = 1)
diff --git a/test/fixtures/nx/utils/utils.js b/test/fixtures/nx/utils/utils.js
index b771487b9..b084967ed 100644
--- a/test/fixtures/nx/utils/utils.js
+++ b/test/fixtures/nx/utils/utils.js
@@ -8,6 +8,8 @@ export const loadScript = async () => {};
export const DA_ADMIN = 'https://admin.da.live';
+export const DA_COLLAB = 'wss://collab.da.live';
+
let _hashState = {};
const _hashSubscribers = new Set();
export const hashChange = {
diff --git a/test/fixtures/nx2/blocks/shared/toast/toast.js b/test/fixtures/nx2/blocks/shared/toast/toast.js
new file mode 100644
index 000000000..f24a97ad6
--- /dev/null
+++ b/test/fixtures/nx2/blocks/shared/toast/toast.js
@@ -0,0 +1,14 @@
+// Test fixture mirroring nx2/blocks/shared/toast/toast.js.
+// The array is parked on window because the import-maps plugin can load this
+// module twice, once per specifier shape, and both copies have to record here.
+window.daTestToasts ??= [];
+
+export const VARIANT_SUCCESS = 'success';
+export const VARIANT_ERROR = 'error';
+export const VARIANT_WARNING = 'warning';
+
+export const toasts = window.daTestToasts;
+
+export function showToast(opts) {
+ window.daTestToasts.push(opts);
+}
diff --git a/test/unit/blocks/browse/da-browse/da-browse.test.js b/test/unit/blocks/browse/da-browse/da-browse.test.js
index 9d01eb4f9..b1076cd49 100644
--- a/test/unit/blocks/browse/da-browse/da-browse.test.js
+++ b/test/unit/blocks/browse/da-browse/da-browse.test.js
@@ -347,10 +347,12 @@ describe('DaBrowse Component', () => {
describe('getEditor', () => {
function mockConfig(rows) {
- window.fetch = async () => ({
- ok: true,
- json: async () => ({ data: rows }),
- });
+ // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that
+ // with a real Response (so its headers.get() call is safe) and defer everything else.
+ window.fetch = async (url) => {
+ if (String(url).includes('/ping/')) return new Response('', { status: 200 });
+ return { ok: true, json: async () => ({ data: rows }) };
+ };
}
let origFetch;
diff --git a/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js b/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js
new file mode 100644
index 000000000..000ac9235
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js
@@ -0,0 +1,67 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../scripts/utils.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let sessionErrorFromResponse;
+
+before(async () => {
+ ({ sessionErrorFromResponse } = await import('../../../../../blocks/canvas/ew-editor-doc/utils/load-editor-doc.js'));
+});
+
+const respond = (status, { headers = {}, ok } = {}) => ({
+ status,
+ ok: ok ?? (status >= 200 && status < 300),
+ headers: new Headers(headers),
+});
+
+describe('sessionErrorFromResponse', () => {
+ it('lets a document that loaded through', () => {
+ expect(sessionErrorFromResponse(respond(200))).to.equal(null);
+ });
+
+ it('lets a missing document through, so it can be created', () => {
+ expect(sessionErrorFromResponse(respond(404))).to.equal(null);
+ });
+
+ it('asks for a sign-in on 401', () => {
+ expect(sessionErrorFromResponse(respond(401))).to.deep.equal({
+ ok: false,
+ error: 'Sign in required',
+ });
+ });
+
+ it('reports a refusal on 403', () => {
+ expect(sessionErrorFromResponse(respond(403))).to.deep.equal({
+ ok: false,
+ error: 'Not permitted',
+ });
+ });
+
+ it('names the status instead of blaming permissions on 503', () => {
+ expect(sessionErrorFromResponse(respond(503))).to.deep.equal({
+ ok: false,
+ error: 'Could not load the document (503)',
+ });
+ });
+
+ it('adds the reason the store gave', () => {
+ const resp = respond(500, { headers: { 'x-error': 'source lookup failed' } });
+ expect(sessionErrorFromResponse(resp)).to.deep.equal({
+ ok: false,
+ error: 'Could not load the document (500): source lookup failed',
+ });
+ });
+
+ it('says so when the store was never reached', () => {
+ // nx2 daFetch answers {} when it has no token to send, which carries no status.
+ expect(sessionErrorFromResponse({})).to.deep.equal({
+ ok: false,
+ error: 'Could not reach the content store',
+ });
+ expect(sessionErrorFromResponse(null)).to.deep.equal({
+ ok: false,
+ error: 'Could not reach the content store',
+ });
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js
new file mode 100644
index 000000000..802570feb
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js
@@ -0,0 +1,144 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../../scripts/utils.js';
+import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let base64Uploader;
+let uploadBase64Image;
+let MAX_IMAGE_BYTES;
+let toasts;
+
+const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); });
+
+// the paste handler is synchronous and leaves the upload running behind it
+async function until(done, tries = 50) {
+ for (let i = 0; i < tries; i += 1) {
+ if (done()) return;
+ // eslint-disable-next-line no-await-in-loop
+ await nextFrame();
+ }
+}
+
+before(async () => {
+ ({ default: base64Uploader, uploadBase64Image } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js'));
+ ({ MAX_IMAGE_BYTES } = await import('../../../../../../blocks/canvas/utils/image-upload.js'));
+ ({ toasts } = await import('../../../../../fixtures/nx2/blocks/shared/toast/toast.js'));
+});
+
+// isHlx6 memoizes its answer per site, so each case needs its own org/site
+function stubStore({ upgraded }) {
+ const saved = window.fetch;
+ const calls = [];
+ window.fetch = async (url, opts) => {
+ const href = typeof url === 'string' ? url : url.url;
+ calls.push({ url: href, opts });
+ if (href.includes('/ping/')) {
+ return new Response('', {
+ status: 200,
+ headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {},
+ });
+ }
+ if (href.startsWith('data:')) return new Response(new Blob([new Uint8Array([1])]));
+ return new Response(JSON.stringify({ source: { contentUrl: './media_abc.png' } }), {
+ status: 201,
+ headers: { 'content-type': 'application/json' },
+ });
+ };
+ return { calls, restore: () => { window.fetch = saved; } };
+}
+
+afterEach(() => {
+ window.localStorage.removeItem('hlx6-upgrade');
+});
+
+describe('base64Uploader', () => {
+ let editor;
+
+ const oversized = () => `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`;
+
+ const insertFpo = (fpoSrc) => {
+ const { schema } = editor.view.state;
+ const fpo = schema.nodes.image.create({ src: fpoSrc });
+ editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(fpo));
+ };
+
+ const imageSrcs = () => {
+ const srcs = [];
+ editor.view.state.doc.descendants((node) => {
+ if (node.type.name === 'image') srcs.push(node.attrs.src);
+ });
+ return srcs;
+ };
+
+ beforeEach(async () => {
+ editor = await createTestEditor();
+ await nextFrame();
+ toasts.length = 0;
+ });
+
+ afterEach(() => {
+ destroyEditor(editor);
+ });
+
+ it('swaps a pasted data url for the fpo and uploads it', async () => {
+ const { calls, restore } = stubStore({ upgraded: true });
+ try {
+ const plugin = base64Uploader({
+ getSourceUrl: () => 'https://api.aem.live/pasteok/sites/pasteok/source/doc.html',
+ getEditorView: () => editor.view,
+ });
+ const html = plugin.props.transformPastedHTML('

');
+ await until(() => calls.some((c) => c.opts?.method === 'POST'));
+
+ expect(html).to.contain('/blocks/edit/img/fpo.svg');
+ expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1);
+ expect(toasts).to.have.length(0);
+ } finally {
+ restore();
+ }
+ });
+
+ it('drops the fpo when the pasted image is over the upload limit', async () => {
+ const { calls, restore } = stubStore({ upgraded: true });
+ const fpoSrc = '/blocks/edit/img/fpo.svg#1';
+ try {
+ insertFpo(fpoSrc);
+ expect(imageSrcs()).to.deep.equal([fpoSrc]);
+
+ await uploadBase64Image(editor.view, {
+ src: oversized(),
+ path: '/pastebig/pastebig/.doc/wp1.png',
+ fpoSrc,
+ parent: '/pastebig/pastebig',
+ });
+
+ expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0);
+ expect(imageSrcs(), 'the fpo was left in the document').to.deep.equal([]);
+ expect(toasts).to.have.length(1);
+ expect(toasts[0].text).to.contain('Image upload failed');
+ expect(toasts[0].text).to.contain('4.5 MB or under');
+ } finally {
+ restore();
+ }
+ });
+
+ it('takes an oversized image on a legacy site, where the limit does not apply', async () => {
+ const { calls, restore } = stubStore({ upgraded: false });
+ const fpoSrc = '/blocks/edit/img/fpo.svg#2';
+ try {
+ insertFpo(fpoSrc);
+ await uploadBase64Image(editor.view, {
+ src: oversized(),
+ path: '/pasteleg/pasteleg/.doc/wp2.png',
+ fpoSrc,
+ parent: '/pasteleg/pasteleg',
+ });
+
+ expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1);
+ expect(toasts).to.have.length(0);
+ } finally {
+ restore();
+ }
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js
new file mode 100644
index 000000000..2c28438bf
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js
@@ -0,0 +1,73 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../../scripts/utils.js';
+import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let getRenderableSrc;
+let mediaBusImage;
+
+const ctx = { org: 'org', repo: 'repo', path: '/org/repo/page' };
+const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); });
+
+before(async () => {
+ ({ default: mediaBusImage, getRenderableSrc } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js'));
+});
+
+describe('canvas getRenderableSrc', () => {
+ // the same origin the canvas fetches its preview cookie from, which is stage off da.live
+ it('rewrites a relative media src to the origin the canvas logged into', () => {
+ expect(getRenderableSrc('./media_123.png', ctx)).to.equal(
+ 'https://main--repo--org.stage-preview.da.live/media_123.png',
+ );
+ });
+
+ it('answers null for a src that is not on the media bus', () => {
+ expect(getRenderableSrc('https://example.com/foo.png', ctx)).to.equal(null);
+ expect(getRenderableSrc('/media_123.png', ctx)).to.equal(null);
+ expect(getRenderableSrc(null, ctx)).to.equal(null);
+ });
+
+ it('answers null when the ctx names no site', () => {
+ expect(getRenderableSrc('./media_123.png', { org: 'org' })).to.equal(null);
+ expect(getRenderableSrc('./media_123.png', null)).to.equal(null);
+ });
+});
+
+describe('canvas mediaBusImage plugin', () => {
+ let editor;
+
+ beforeEach(async () => {
+ editor = await createTestEditor({ additionalPlugins: [mediaBusImage(ctx)] });
+ await nextFrame();
+ });
+
+ afterEach(() => {
+ destroyEditor(editor);
+ });
+
+ it('renders a media bus image from the preview origin, and stores the relative src', async () => {
+ const { schema } = editor.view.state;
+ const image = schema.nodes.image.create({ src: './media_123.png' });
+ editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image));
+ await nextFrame();
+
+ const img = editor.view.dom.querySelector('img');
+ expect(img.src).to.equal('https://main--repo--org.stage-preview.da.live/media_123.png');
+
+ let storedSrc;
+ editor.view.state.doc.descendants((node) => {
+ if (node.type.name === 'image') storedSrc = node.attrs.src;
+ });
+ expect(storedSrc).to.equal('./media_123.png');
+ });
+
+ it('leaves an absolute image src alone', async () => {
+ const { schema } = editor.view.state;
+ const image = schema.nodes.image.create({ src: 'https://example.com/pic.png' });
+ editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image));
+ await nextFrame();
+
+ expect(editor.view.dom.querySelector('img').src).to.equal('https://example.com/pic.png');
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js
new file mode 100644
index 000000000..c1e84c07f
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js
@@ -0,0 +1,188 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../../scripts/utils.js';
+import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let getSourceUploadContext;
+let uploadImageFile;
+let MAX_IMAGE_BYTES;
+let toasts;
+
+const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); });
+
+before(async () => {
+ ({ getSourceUploadContext } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js'));
+ ({ uploadImageFile } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js'));
+ ({ MAX_IMAGE_BYTES } = await import('../../../../../../blocks/canvas/utils/image-upload.js'));
+ ({ toasts } = await import('../../../../../fixtures/nx2/blocks/shared/toast/toast.js'));
+});
+
+// isHlx6 memoizes its answer per site, so each case needs its own org/site
+function stubStore({ upgraded, contentUrl = 'https://content.da.live/org/site/.doc/pic.png' }) {
+ const saved = window.fetch;
+ const calls = [];
+ window.fetch = async (url, opts) => {
+ const href = typeof url === 'string' ? url : url.url;
+ calls.push({ url: href, opts });
+ if (href.includes('/ping/')) {
+ return new Response('', {
+ status: 200,
+ headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {},
+ });
+ }
+ return new Response(JSON.stringify({ source: { contentUrl } }), {
+ status: 201,
+ headers: { 'content-type': 'application/json' },
+ });
+ };
+ return { calls, restore: () => { window.fetch = saved; } };
+}
+
+afterEach(() => {
+ window.localStorage.removeItem('hlx6-upgrade');
+});
+
+describe('getSourceUploadContext', () => {
+ it('reads a da-admin source url', () => {
+ const ctx = getSourceUploadContext('https://admin.da.live/source/org/site/dir/doc.html');
+ expect(ctx).to.deep.equal({ parent: '/org/site/dir', name: 'doc' });
+ });
+
+ it('reads a source-bus source url', () => {
+ const ctx = getSourceUploadContext('https://api.aem.live/org/sites/site/source/dir/doc.html');
+ expect(ctx).to.deep.equal({ parent: '/org/site/dir', name: 'doc' });
+ });
+
+ it('reads a document at the site root', () => {
+ expect(getSourceUploadContext('https://admin.da.live/source/org/site/index.html'))
+ .to.deep.equal({ parent: '/org/site', name: 'index' });
+ expect(getSourceUploadContext('https://api.aem.live/org/sites/site/source/index.html'))
+ .to.deep.equal({ parent: '/org/site', name: 'index' });
+ });
+
+ it('answers null for a url it cannot read', () => {
+ expect(getSourceUploadContext('')).to.equal(null);
+ expect(getSourceUploadContext('https://admin.da.live/list/org/site')).to.equal(null);
+ expect(getSourceUploadContext(null)).to.equal(null);
+ });
+});
+
+describe('uploadImageFile', () => {
+ let editor;
+
+ beforeEach(async () => {
+ editor = await createTestEditor();
+ await nextFrame();
+ });
+
+ afterEach(() => {
+ destroyEditor(editor);
+ });
+
+ const png = () => new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' });
+
+ it('uploads to the source bus for a migrated site', async () => {
+ const { calls, restore } = stubStore({ upgraded: true, contentUrl: './media_abc.png' });
+ try {
+ await uploadImageFile(editor.view, png(), { parent: '/upsorg/upssite/dir', name: 'doc' });
+
+ // nx2 owns the route past the site, so this pins only the store and the path
+ const upload = calls.find((c) => c.opts?.method === 'POST');
+ expect(upload, 'nothing was uploaded').to.exist;
+ expect(new URL(upload.url).origin).to.equal('https://api.aem.live');
+ expect(upload.url).to.contain('/upsorg/sites/upssite/');
+ expect(upload.url.endsWith('/dir/.doc/pic.png'), upload.url).to.equal(true);
+ } finally {
+ restore();
+ }
+ });
+
+ it('uploads to da-admin for a legacy site', async () => {
+ const { calls, restore } = stubStore({ upgraded: false });
+ try {
+ await uploadImageFile(editor.view, png(), { parent: '/legorg/legsite/dir', name: 'doc' });
+
+ const upload = calls.find((c) => c.opts?.method === 'POST');
+ expect(upload, 'nothing was uploaded').to.exist;
+ expect(new URL(upload.url).origin).to.equal('https://admin.da.live');
+ expect(upload.url.endsWith('/legorg/legsite/dir/.doc/pic.png'), upload.url).to.equal(true);
+ } finally {
+ restore();
+ }
+ });
+
+ it('shows a media bus image without waiting for it to load', async () => {
+ // a relative src cannot load from the canvas origin
+ const { restore } = stubStore({ upgraded: true, contentUrl: './media_abc.png' });
+ try {
+ await uploadImageFile(editor.view, png(), { parent: '/relorg/relsite', name: 'doc' });
+ await nextFrame();
+
+ let stored;
+ editor.view.state.doc.descendants((node) => {
+ if (node.type.name === 'image') stored = node.attrs.src;
+ });
+ expect(stored).to.equal('./media_abc.png');
+ } finally {
+ restore();
+ }
+ });
+
+ it('refuses a file type the store does not take', async () => {
+ const { calls, restore } = stubStore({ upgraded: false });
+ try {
+ const pdf = new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' });
+ await uploadImageFile(editor.view, pdf, { parent: '/pdforg/pdfsite', name: 'doc' });
+
+ expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0);
+ } finally {
+ restore();
+ }
+ });
+
+ it('refuses an image over the upload limit', async () => {
+ const { calls, restore } = stubStore({ upgraded: true });
+ toasts.length = 0;
+ try {
+ const big = new File(
+ [new Uint8Array(MAX_IMAGE_BYTES + 1)],
+ 'big.png',
+ { type: 'image/png' },
+ );
+ await uploadImageFile(editor.view, big, { parent: '/bigorg/bigsite', name: 'doc' });
+ await nextFrame();
+
+ expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0);
+ let images = 0;
+ editor.view.state.doc.descendants((node) => {
+ if (node.type.name === 'image') images += 1;
+ });
+ expect(images, 'the fpo was left in the document').to.equal(0);
+ expect(toasts).to.have.length(1);
+ expect(toasts[0].text).to.contain('Image upload failed');
+ expect(toasts[0].text).to.contain('4.5 MB or under');
+ } finally {
+ restore();
+ }
+ });
+
+ it('takes an oversized image on a legacy site, where the limit does not apply', async () => {
+ const { calls, restore } = stubStore({ upgraded: false });
+ toasts.length = 0;
+ try {
+ const big = new File(
+ [new Uint8Array(MAX_IMAGE_BYTES + 1)],
+ 'big.png',
+ { type: 'image/png' },
+ );
+ await uploadImageFile(editor.view, big, { parent: '/bigleg/bigleg', name: 'doc' });
+ await nextFrame();
+
+ expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1);
+ expect(toasts).to.have.length(0);
+ } finally {
+ restore();
+ }
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js
new file mode 100644
index 000000000..4a5d90636
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js
@@ -0,0 +1,48 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../scripts/utils.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let initProse;
+
+before(async () => {
+ ({ default: initProse } = await import('../../../../../blocks/canvas/ew-editor-doc/prose.js'));
+});
+
+async function connect(sourceUrl) {
+ const result = await initProse({
+ path: sourceUrl,
+ permissions: ['read', 'write'],
+ setEditable: () => {},
+ getToken: () => 'test-token',
+ });
+ return result;
+}
+
+function teardown({ wsProvider, ydoc, view }) {
+ wsProvider.disconnect({ data: 'Client navigation' });
+ wsProvider.destroy?.();
+ view?.destroy?.();
+ ydoc.destroy();
+}
+
+describe('canvas collab room', () => {
+ it('keeps a legacy document in its da-admin room', async () => {
+ const result = await connect('https://admin.da.live/source/roomorg/roomsite/page.html');
+ try {
+ expect(result.wsProvider.roomname).to.equal('https://admin.da.live/source/roomorg/roomsite/page.html');
+ } finally {
+ teardown(result);
+ }
+ });
+
+ it('puts a source-bus document in its api.aem.live room', async () => {
+ // da-collab reads the store off the room name, so the room must name the document's real store
+ const result = await connect('https://api.aem.live/roomorg/sites/roomsite/source/page.html');
+ try {
+ expect(result.wsProvider.roomname).to.equal('https://api.aem.live/roomorg/sites/roomsite/source/page.html');
+ } finally {
+ teardown(result);
+ }
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js b/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js
new file mode 100644
index 000000000..b6a9dae9d
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js
@@ -0,0 +1,56 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../scripts/utils.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let resolveEditorDocSession;
+
+before(async () => {
+ ({ resolveEditorDocSession } = await import('../../../../../blocks/canvas/ew-editor-doc/utils/load-editor-doc.js'));
+});
+
+function stubFetch(respond) {
+ const saved = window.fetch;
+ const calls = [];
+ window.fetch = async (url, opts) => {
+ const href = typeof url === 'string' ? url : url.url;
+ calls.push({ url: href, opts });
+ return respond(href, opts);
+ };
+ return { calls, restore: () => { window.fetch = saved; } };
+}
+
+const ok = () => new Response('', { status: 200 });
+
+afterEach(() => {
+ window.localStorage.removeItem('hlx6-upgrade');
+ window.localStorage.removeItem('nx-ims');
+ delete window.adobeIMS;
+});
+
+describe('resolveEditorDocSession', () => {
+ // the nx fixture's loadIms answers nothing, so every session in this file is anonymous
+ it('refuses an anonymous session before it looks the store up', async () => {
+ const { calls, restore } = stubFetch(ok);
+ try {
+ const session = await resolveEditorDocSession({ org: 'anonorg', repo: 'anonsite', path: '/anonorg/anonsite/page' });
+
+ expect(session).to.deep.equal({ ok: false, error: 'Sign in required' });
+ expect(calls, 'the store was asked before the sign-in check').to.have.length(0);
+ } finally {
+ restore();
+ }
+ });
+
+ it('answers the same for a ctx it cannot use', async () => {
+ const { calls, restore } = stubFetch(ok);
+ try {
+ const session = await resolveEditorDocSession({ org: 'anonorg', repo: 'anonsite', path: '' });
+
+ expect(session.ok).to.equal(false);
+ expect(calls).to.have.length(0);
+ } finally {
+ restore();
+ }
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js
new file mode 100644
index 000000000..d2af95ffe
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js
@@ -0,0 +1,28 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../../scripts/utils.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let editorDocCanLoad;
+
+before(async () => {
+ ({ editorDocCanLoad } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/ctx.js'));
+});
+
+describe('editorDocCanLoad', () => {
+ it('is decided without a network call', () => {
+ // a promise is truthy, so this has to stay synchronous or an empty path reads as loadable
+ expect(editorDocCanLoad({ org: 'o', repo: 's', path: '/o/s/page' })).to.equal(true);
+ });
+
+ it('refuses a ctx with an empty path', () => {
+ expect(editorDocCanLoad({ org: 'o', repo: 's', path: '' })).to.equal(false);
+ expect(editorDocCanLoad({ org: 'o', repo: 's', path: ' ' })).to.equal(false);
+ });
+
+ it('refuses a ctx missing org or site', () => {
+ expect(editorDocCanLoad({ repo: 's', path: '/o/s/page' })).to.equal(false);
+ expect(editorDocCanLoad({ org: 'o', path: '/o/s/page' })).to.equal(false);
+ expect(editorDocCanLoad(null)).to.equal(false);
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js
new file mode 100644
index 000000000..817d55fe9
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js
@@ -0,0 +1,131 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../../scripts/utils.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let buildSourceUrl;
+let checkDoc;
+
+before(async () => {
+ ({ buildSourceUrl, checkDoc } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/source.js'));
+});
+
+// isHlx6 memoizes its answer per site, so each case needs its own org/site
+function stubPing({ upgraded }) {
+ const saved = window.fetch;
+ const calls = [];
+ window.fetch = async (url, opts) => {
+ calls.push({ url: typeof url === 'string' ? url : url.url, opts });
+ const headers = upgraded ? { 'x-api-upgrade-available': 'true' } : {};
+ return new Response('', { status: 200, headers });
+ };
+ return { calls, restore: () => { window.fetch = saved; } };
+}
+
+afterEach(() => {
+ window.localStorage.removeItem('hlx6-upgrade');
+});
+
+describe('canvas buildSourceUrl', () => {
+ it('keeps a legacy site on da-admin', async () => {
+ const { restore } = stubPing({ upgraded: false });
+ try {
+ const url = await buildSourceUrl('/legacyorg/legacysite/dir/page');
+ expect(url).to.equal('https://admin.da.live/source/legacyorg/legacysite/dir/page.html');
+ } finally {
+ restore();
+ }
+ });
+
+ it('sends a source-bus site to api.aem.live', async () => {
+ const { restore } = stubPing({ upgraded: true });
+ try {
+ const url = await buildSourceUrl('/hlxorg/hlxsite/dir/page');
+ expect(url).to.equal('https://api.aem.live/hlxorg/sites/hlxsite/source/dir/page.html');
+ } finally {
+ restore();
+ }
+ });
+
+ it('routes a document at the site root', async () => {
+ const { restore } = stubPing({ upgraded: true });
+ try {
+ const url = await buildSourceUrl('/rootorg/rootsite/index');
+ expect(url).to.equal('https://api.aem.live/rootorg/sites/rootsite/source/index.html');
+ } finally {
+ restore();
+ }
+ });
+
+ it('answers null for a path it cannot use', async () => {
+ expect(await buildSourceUrl('')).to.equal(null);
+ expect(await buildSourceUrl(' ')).to.equal(null);
+ expect(await buildSourceUrl(null)).to.equal(null);
+ expect(await buildSourceUrl(42)).to.equal(null);
+ });
+});
+
+describe('canvas checkDoc', () => {
+ // the source bus is not on da-live's token allowlist, so that read goes through nx2
+ it('asks api.aem.live for a source-bus document, with a bearer', async () => {
+ const { calls, restore } = stubPing({ upgraded: true });
+ try {
+ const url = await buildSourceUrl('/checkorg/checksite/page');
+ await checkDoc(url);
+ const head = calls.find((c) => c.opts?.method === 'HEAD');
+ expect(head, 'no HEAD was issued').to.exist;
+ expect(head.url).to.equal('https://api.aem.live/checkorg/sites/checksite/source/page.html');
+ expect(head.opts.headers.Authorization).to.equal('Bearer test-token');
+ } finally {
+ restore();
+ }
+ });
+
+ // da-admin keeps da-live's own fetcher, which reads the token live and retries once on a 401.
+ // nx2's takes the snapshot loadIms captured at page load and does neither.
+ it('asks da-admin for a legacy document, through the fetcher that can refresh a token', async () => {
+ window.localStorage.setItem('nx-ims', 'true');
+ window.adobeIMS = { getAccessToken: () => ({ token: 'live-token' }) };
+ const { calls, restore } = stubPing({ upgraded: false });
+ try {
+ const url = await buildSourceUrl('/checkleg/checkleg/page');
+ await checkDoc(url);
+ const head = calls.find((c) => c.opts?.method === 'HEAD');
+ expect(head, 'no HEAD was issued').to.exist;
+ expect(head.url).to.equal('https://admin.da.live/source/checkleg/checkleg/page.html');
+ expect(new Headers(head.opts.headers).get('Authorization')).to.equal('Bearer live-token');
+ } finally {
+ restore();
+ window.localStorage.removeItem('nx-ims');
+ delete window.adobeIMS;
+ }
+ });
+
+ it('reads the action list da-admin sends', async () => {
+ const saved = window.fetch;
+ window.fetch = async (url) => {
+ if (String(url).includes('/ping/')) return new Response('', { status: 200 });
+ return new Response('', {
+ status: 200,
+ headers: { 'x-da-actions': '/site/page.html=read' },
+ });
+ };
+ try {
+ const resp = await checkDoc('https://admin.da.live/source/permorg/permsite/page.html');
+ expect(resp.permissions).to.deep.equal(['read']);
+ } finally {
+ window.fetch = saved;
+ }
+ });
+
+ it('assumes read and write when the store sends no action list', async () => {
+ const { restore } = stubPing({ upgraded: true });
+ try {
+ const url = await buildSourceUrl('/faketorg/fakesite/page');
+ const resp = await checkDoc(url);
+ expect(resp.permissions).to.deep.equal(['read', 'write']);
+ } finally {
+ restore();
+ }
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js
new file mode 100644
index 000000000..447aa9edc
--- /dev/null
+++ b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js
@@ -0,0 +1,149 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../scripts/utils.js';
+import { createTestEditor, destroyEditor } from '../../edit/prose/test-helpers.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let handleImageReplace;
+let MAX_IMAGE_BYTES;
+let toasts;
+
+const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); });
+
+before(async () => {
+ ({ handleImageReplace } = await import('../../../../../blocks/canvas/ew-editor-wysiwyg/utils/image.js'));
+ ({ MAX_IMAGE_BYTES } = await import('../../../../../blocks/canvas/utils/image-upload.js'));
+ ({ toasts } = await import('../../../../fixtures/nx2/blocks/shared/toast/toast.js'));
+});
+
+function stubStore({ upgraded, contentUrl = './media_abc.png' }) {
+ const saved = window.fetch;
+ const calls = [];
+ window.fetch = async (url, opts) => {
+ const href = typeof url === 'string' ? url : url.url;
+ calls.push({ url: href, opts });
+ if (href.includes('/ping/')) {
+ return new Response('', {
+ status: 200,
+ headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {},
+ });
+ }
+ return new Response(JSON.stringify({ source: { contentUrl } }), {
+ status: 201,
+ headers: { 'content-type': 'application/json' },
+ });
+ };
+ return { calls, restore: () => { window.fetch = saved; } };
+}
+
+afterEach(() => {
+ window.localStorage.removeItem('hlx6-upgrade');
+});
+
+describe('handleImageReplace', () => {
+ let editor;
+
+ const ctxFor = (owner, repo) => {
+ const posted = [];
+ return {
+ posted,
+ ctx: {
+ owner,
+ repo,
+ path: '/page',
+ view: editor.view,
+ port: { postMessage: (message) => posted.push(message) },
+ getToken: () => 'test-token',
+ },
+ };
+ };
+
+ const imageData = 'data:image/png;base64,iVBORw0KGgo=';
+
+ beforeEach(async () => {
+ editor = await createTestEditor();
+ await nextFrame();
+ });
+
+ afterEach(() => {
+ destroyEditor(editor);
+ });
+
+ it('uploads to the source bus for a migrated site', async () => {
+ const { calls, restore } = stubStore({ upgraded: true });
+ const { ctx } = ctxFor('wysorg', 'wyssite');
+ try {
+ await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx);
+
+ // the route past the site is nx2's, so what is pinned is the store and the path
+ const upload = calls.find((c) => c.opts?.method === 'POST');
+ expect(upload, 'nothing was uploaded').to.exist;
+ expect(new URL(upload.url).origin).to.equal('https://api.aem.live');
+ expect(upload.url).to.contain('/wysorg/sites/wyssite/');
+ expect(upload.url.endsWith('/.page/pic.png'), upload.url).to.equal(true);
+ } finally {
+ restore();
+ }
+ });
+
+ it('uploads to da-admin for a legacy site', async () => {
+ const { calls, restore } = stubStore({ upgraded: false, contentUrl: 'https://content.da.live/wyslegacy/wyslegacy/.page/pic.png' });
+ const { ctx } = ctxFor('wyslegacy', 'wyslegacy');
+ try {
+ await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx);
+
+ const upload = calls.find((c) => c.opts?.method === 'POST');
+ expect(upload, 'nothing was uploaded').to.exist;
+ expect(new URL(upload.url).origin).to.equal('https://admin.da.live');
+ expect(upload.url.endsWith('/wyslegacy/wyslegacy/.page/pic.png'), upload.url).to.equal(true);
+ } finally {
+ restore();
+ }
+ });
+
+ it('reports the src the store gave back, not one it composed', async () => {
+ const { restore } = stubStore({ upgraded: true, contentUrl: './media_xyz.png' });
+ const { ctx, posted } = ctxFor('wysrep', 'wysrep');
+ try {
+ await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx);
+
+ expect(posted.at(-1).payload.newSrc).to.equal('./media_xyz.png');
+ } finally {
+ restore();
+ }
+ });
+
+ it('reports a refused upload', async () => {
+ const saved = window.fetch;
+ window.fetch = async (url) => {
+ if (String(url).includes('/ping/')) return new Response('', { status: 200 });
+ return new Response('', { status: 403 });
+ };
+ const { ctx, posted } = ctxFor('wysref', 'wysref');
+ try {
+ await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx);
+
+ expect(posted.at(-1).payload.error).to.contain('403');
+ } finally {
+ window.fetch = saved;
+ }
+ });
+
+ it('refuses an image over the upload limit', async () => {
+ const { calls, restore } = stubStore({ upgraded: true });
+ const { ctx, posted } = ctxFor('wysbig', 'wysbig');
+ // base64 inflates by 4/3, so this decodes to one byte over the limit
+ const big = `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`;
+ toasts.length = 0;
+ try {
+ await handleImageReplace({ imageData: big, fileName: 'big.png', originalSrc: '/old.png' }, ctx);
+
+ expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0);
+ expect(posted.at(-1).payload.error).to.contain('too large');
+ expect(toasts).to.have.length(1);
+ expect(toasts[0].text).to.contain('4.5 MB or under');
+ } finally {
+ restore();
+ }
+ });
+});
diff --git a/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js b/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js
index 8ddcfc822..e7a4886df 100644
--- a/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js
+++ b/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js
@@ -25,12 +25,16 @@ function makeSheet(entries) {
function makeFetch(responses) {
return async (url) => {
+ // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); check that
+ // before the pattern match below, since a ping url can otherwise collide with an
+ // org-level config pattern (e.g. '/ping/{org}/{site}' contains '/{org}/').
+ if (url.includes('/ping/')) return new Response('', { status: 200 });
for (const [pattern, response] of Object.entries(responses).sort(
([a], [b]) => b.length - a.length,
)) {
if (url.includes(pattern)) return response;
}
- return { ok: false };
+ return new Response('', { status: 404 });
};
}
diff --git a/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js b/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js
index 355971db9..3cb5a7bde 100644
--- a/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js
+++ b/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js
@@ -5,11 +5,13 @@ setNx('/test/fixtures/nx', { hostname: 'example.com' });
let getBlockVariants;
let extensionToPanelView;
+let getPreviewStatus;
before(async () => {
const mod = await import('../../../../../blocks/canvas/ew-panel-extensions/helpers.js');
getBlockVariants = mod.getBlockVariants;
extensionToPanelView = mod.extensionToPanelView;
+ getPreviewStatus = mod.getPreviewStatus;
});
describe('EW panel helpers transformBlock', () => {
@@ -208,3 +210,40 @@ describe('extensionToPanelView', () => {
expect(view.openModal).to.be.undefined;
});
});
+
+// getPreviewStatus now goes through getNx2Api's isHlx6-aware status.get, not the legacy
+// admin.hlx.page-only aemAdmin() helper. A real Response keeps isHlx6's ping (which fires
+// before the actual status call) safe regardless of route.
+describe('getPreviewStatus', () => {
+ let savedFetch;
+
+ beforeEach(() => { savedFetch = window.fetch; });
+ afterEach(() => {
+ window.fetch = savedFetch;
+ window.localStorage.removeItem('hlx6-upgrade');
+ });
+
+ it('returns true when preview status is 200', async () => {
+ window.fetch = () => Promise.resolve(new Response(
+ JSON.stringify({ preview: { status: 200 } }),
+ { status: 200 },
+ ));
+ const result = await getPreviewStatus({ org: 'pstatusorg', site: 'pstatussite', pathname: '/p' });
+ expect(result).to.be.true;
+ });
+
+ it('returns false when preview status is not 200', async () => {
+ window.fetch = () => Promise.resolve(new Response(
+ JSON.stringify({ preview: { status: 404 } }),
+ { status: 200 },
+ ));
+ const result = await getPreviewStatus({ org: 'pstatusorg2', site: 'pstatussite2', pathname: '/p' });
+ expect(result).to.be.false;
+ });
+
+ it('returns null when the status call fails', async () => {
+ window.fetch = () => Promise.resolve(new Response('{}', { status: 500 }));
+ const result = await getPreviewStatus({ org: 'pstatusorg3', site: 'pstatussite3', pathname: '/p' });
+ expect(result).to.equal(null);
+ });
+});
diff --git a/test/unit/blocks/canvas/utils/image-upload.test.js b/test/unit/blocks/canvas/utils/image-upload.test.js
new file mode 100644
index 000000000..13949b974
--- /dev/null
+++ b/test/unit/blocks/canvas/utils/image-upload.test.js
@@ -0,0 +1,112 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../../scripts/utils.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+let MAX_IMAGE_BYTES;
+let isImageTooLarge;
+let dataUrlByteLength;
+let showImageTooLarge;
+let refuseOversizedImage;
+let toasts;
+
+before(async () => {
+ ({
+ MAX_IMAGE_BYTES,
+ isImageTooLarge,
+ dataUrlByteLength,
+ showImageTooLarge,
+ refuseOversizedImage,
+ } = await import('../../../../../blocks/canvas/utils/image-upload.js'));
+ ({ toasts } = await import('../../../../fixtures/nx2/blocks/shared/toast/toast.js'));
+});
+
+// isHlx6 memoizes its answer per site, so each case needs its own org/site
+function stubPing(upgraded) {
+ const saved = window.fetch;
+ window.fetch = async () => new Response('', {
+ status: 200,
+ headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {},
+ });
+ return () => { window.fetch = saved; };
+}
+
+beforeEach(() => {
+ toasts.length = 0;
+});
+
+afterEach(() => {
+ window.localStorage.removeItem('hlx6-upgrade');
+});
+
+describe('image upload limit', () => {
+ it('caps an upload below the api service request limit', () => {
+ // the AWS edge answers 413 above 4,717,360 bytes, measured 2026-08-18
+ expect(MAX_IMAGE_BYTES).to.be.below(4717360);
+ });
+
+ it('takes a file at the limit and refuses the byte above it', () => {
+ expect(isImageTooLarge(MAX_IMAGE_BYTES)).to.equal(false);
+ expect(isImageTooLarge(MAX_IMAGE_BYTES + 1)).to.equal(true);
+ expect(isImageTooLarge(0)).to.equal(false);
+ });
+
+ it('reads the decoded length of a data url', () => {
+ expect(dataUrlByteLength('data:image/png;base64,AAAA')).to.equal(3);
+ expect(dataUrlByteLength('data:image/png;base64,AAA=')).to.equal(2);
+ expect(dataUrlByteLength('data:image/png;base64,AA==')).to.equal(1);
+ expect(dataUrlByteLength('not a data url')).to.equal(0);
+ });
+});
+
+describe('showImageTooLarge', () => {
+ it('names the failure and the limit', async () => {
+ await showImageTooLarge();
+ expect(toasts).to.have.length(1);
+ expect(toasts[0].variant).to.equal('error');
+ expect(toasts[0].text).to.equal('Image upload failed. Image size must be 4.5 MB or under');
+ });
+});
+
+describe('refuseOversizedImage', () => {
+ it('refuses an oversized image on a source bus site', async () => {
+ const restore = stubPing(true);
+ try {
+ expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/refsb/refsb/dir')).to.equal(true);
+ expect(toasts).to.have.length(1);
+ } finally {
+ restore();
+ }
+ });
+
+ it('takes an oversized image on a legacy site, where the limit does not apply', async () => {
+ const restore = stubPing(false);
+ try {
+ expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/reflg/reflg/dir')).to.equal(false);
+ expect(toasts).to.have.length(0);
+ } finally {
+ restore();
+ }
+ });
+
+ it('takes an image inside the limit without probing the store', async () => {
+ let probed = false;
+ const saved = window.fetch;
+ window.fetch = async () => {
+ probed = true;
+ return new Response('', { status: 200 });
+ };
+ try {
+ expect(await refuseOversizedImage(MAX_IMAGE_BYTES, '/refok/refok')).to.equal(false);
+ expect(probed, 'the store was probed for a file inside the limit').to.equal(false);
+ } finally {
+ window.fetch = saved;
+ }
+ });
+
+ it('takes an image it cannot place in a site', async () => {
+ expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '')).to.equal(false);
+ expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/orgonly')).to.equal(false);
+ expect(toasts).to.have.length(0);
+ });
+});
diff --git a/test/unit/blocks/edit/da-assets/config.test.js b/test/unit/blocks/edit/da-assets/config.test.js
index 53338a6af..a557510ae 100644
--- a/test/unit/blocks/edit/da-assets/config.test.js
+++ b/test/unit/blocks/edit/da-assets/config.test.js
@@ -15,10 +15,14 @@ function makeSheet(entries) {
function makeFetch(responses) {
return async (url) => {
+ // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); check that
+ // before the pattern match below, since a ping url can otherwise collide with an
+ // org-level config pattern (e.g. '/ping/{org}/{site}' contains '/{org}/').
+ if (url.includes('/ping/')) return new Response('', { status: 200 });
for (const [pattern, response] of Object.entries(responses)) {
if (url.includes(pattern)) return response;
}
- return { ok: false };
+ return new Response('', { status: 404 });
};
}
@@ -418,6 +422,15 @@ function makeMultiSheetWithResponsive(crops) {
};
}
+// getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that with a
+// real Response (so its headers.get() call is safe) and defer everything else to `respond`.
+function pingSafe(respond) {
+ return async (url, opts) => {
+ if (String(url).includes('/ping/')) return new Response('', { status: 200 });
+ return respond(url, opts);
+ };
+}
+
describe('getResponsiveImageConfig', () => {
it('returns null when owner and repo are both absent', async () => {
const result = await getResponsiveImageConfig(null, null);
@@ -426,7 +439,7 @@ describe('getResponsiveImageConfig', () => {
it('returns false when config has no responsive-images sheet', async () => {
const orgFetch = window.fetch;
- window.fetch = async () => ({ ok: true, json: async () => ({ data: [] }) });
+ window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [] }) }));
try {
const result = await getResponsiveImageConfig('ri1', 'none');
expect(result).to.be.false;
@@ -437,9 +450,9 @@ describe('getResponsiveImageConfig', () => {
it('parses crops string into array from responsive-images sheet', async () => {
const orgFetch = window.fetch;
- window.fetch = async () => makeMultiSheetWithResponsive([
+ window.fetch = pingSafe(async () => makeMultiSheetWithResponsive([
{ name: 'Full Width', position: 'everywhere', crops: 'desktop, mobile' },
- ]);
+ ]));
try {
const result = await getResponsiveImageConfig('ri2', 'crops');
expect(result).to.be.an('array');
@@ -452,9 +465,9 @@ describe('getResponsiveImageConfig', () => {
it('handles crops with no spaces around comma', async () => {
const orgFetch = window.fetch;
- window.fetch = async () => makeMultiSheetWithResponsive([
+ window.fetch = pingSafe(async () => makeMultiSheetWithResponsive([
{ name: 'Tight', position: 'hero', crops: 'small,medium,large' },
- ]);
+ ]));
try {
const result = await getResponsiveImageConfig('ri3', 'tight');
expect(result[0].crops).to.deep.equal(['small', 'medium', 'large']);
@@ -465,12 +478,12 @@ describe('getResponsiveImageConfig', () => {
it('falls back to org-level config when repo config has no responsive-images', async () => {
const orgFetch = window.fetch;
- window.fetch = async (url) => {
+ window.fetch = pingSafe((url) => {
if (url.includes('/ri4/fallback/')) return { ok: true, json: async () => ({ data: [] }) };
return makeMultiSheetWithResponsive([
{ name: 'Org Wide', position: 'outside-blocks', crops: 'wide' },
]);
- };
+ });
try {
const result = await getResponsiveImageConfig('ri4', 'fallback');
expect(result[0].name).to.equal('Org Wide');
diff --git a/test/unit/blocks/edit/da-content/helpers/index.test.js b/test/unit/blocks/edit/da-content/helpers/index.test.js
index dad5c9cf5..15d01c51e 100644
--- a/test/unit/blocks/edit/da-content/helpers/index.test.js
+++ b/test/unit/blocks/edit/da-content/helpers/index.test.js
@@ -17,9 +17,18 @@ const MULTI_SHEET = { data: SINGLE_SHEET };
const { default: ueUrlHelper } = await import('../../../../../../blocks/edit/da-content/helpers/index.js');
+// getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that with a
+// real Response (so its headers.get() call is safe) and defer everything else to `respond`.
+function pingSafe(respond) {
+ return async (url, opts) => {
+ if (String(url).includes('/ping/')) return new Response('', { status: 200 });
+ return respond(url, opts);
+ };
+}
+
describe('UE URLs', () => {
it('Supports single sheet configs', async () => {
- const mockFetch = async () => ({ ok: true, json: async () => (SINGLE_SHEET) });
+ const mockFetch = pingSafe(async () => ({ ok: true, json: async () => (SINGLE_SHEET) }));
const orgFetch = window.fetch;
try {
@@ -32,7 +41,7 @@ describe('UE URLs', () => {
});
it('Supports multisheet configs', async () => {
- const mockFetch = async () => ({ ok: true, json: async () => (MULTI_SHEET) });
+ const mockFetch = pingSafe(async () => ({ ok: true, json: async () => (MULTI_SHEET) }));
const orgFetch = window.fetch;
try {
@@ -45,7 +54,7 @@ describe('UE URLs', () => {
});
it('Successfully dies gracefully', async () => {
- const mockFetch = async () => ({ ok: false });
+ const mockFetch = pingSafe(async () => ({ ok: false }));
const orgFetch = window.fetch;
try {
@@ -60,7 +69,7 @@ describe('UE URLs', () => {
it('Returns null when no editor.path or quick-edit config exists', async () => {
const orgFetch = window.fetch;
try {
- window.fetch = async () => ({ ok: true, json: async () => ({ data: [{ key: 'other', value: 'x' }] }) });
+ window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [{ key: 'other', value: 'x' }] }) }));
const url = await ueUrlHelper('org', 'repo', 'https://main--repo--org.aem.page/page');
expect(url).to.equal(null);
} finally {
@@ -71,10 +80,10 @@ describe('UE URLs', () => {
it('Builds a quick-edit URL when quick-edit config matches the repo', async () => {
const orgFetch = window.fetch;
try {
- window.fetch = async () => ({
+ window.fetch = pingSafe(async () => ({
ok: true,
json: async () => ({ data: [{ key: 'quick-edit', value: 'repo' }] }),
- });
+ }));
const url = await ueUrlHelper('org-qe', 'repo', 'https://main--repo--org.aem.live/page');
expect(url).to.equal('https://main--repo--org.aem.page/page?quick-edit=on');
} finally {
@@ -85,10 +94,10 @@ describe('UE URLs', () => {
it('Strips trailing /index when building the quick-edit URL', async () => {
const orgFetch = window.fetch;
try {
- window.fetch = async () => ({
+ window.fetch = pingSafe(async () => ({
ok: true,
json: async () => ({ data: [{ key: 'quick-edit', value: 'repo' }] }),
- });
+ }));
const url = await ueUrlHelper('org-qe-strip', 'repo', 'https://main--repo--org.aem.live/folder/index');
expect(url).to.equal('https://main--repo--org.aem.page/folder/?quick-edit=on');
} finally {
diff --git a/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js b/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js
index a72d5af4d..725e74765 100644
--- a/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js
+++ b/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js
@@ -54,8 +54,9 @@ describe('imageFocalPoint Plugin', () => {
}),
};
}
- // Fallback for any other requests
- return { ok: false };
+ // Fallback for any other requests (includes getNx2Api's isHlx6 ping preflight,
+ // which needs a real Response for its headers.get() call)
+ return new Response('', { status: 404 });
};
const mod = await import('../../../../../../blocks/edit/prose/plugins/imageFocalPoint.js');
diff --git a/test/unit/blocks/shared/utils.test.js b/test/unit/blocks/shared/utils.test.js
index 20365b4b5..f80aa95ab 100644
--- a/test/unit/blocks/shared/utils.test.js
+++ b/test/unit/blocks/shared/utils.test.js
@@ -684,6 +684,35 @@ describe('fetchDaConfigs', () => {
expect(resolved).to.equal(null);
expect(fetchCalled).to.be.false;
});
+
+ // fetchDaConfigs routes through getNx2Api's config.get, which pings isHlx6
+ // (HLX_ADMIN/ping/{org}/{site}) before resolving the config url.
+ it('goes through the isHlx6-aware config route, not a hardcoded da-admin fetch', async () => {
+ const calls = [];
+ window.fetch = async (url) => {
+ calls.push(String(url));
+ if (String(url).includes('/ping/')) return new Response('', { status: 200 });
+ return new Response(JSON.stringify({ data: [{ key: 'k', value: 'v' }] }), { status: 200 });
+ };
+
+ const [orgConfig, siteConfig] = await Promise.all(
+ fetchDaConfigs({ org: 'cfgorg', site: 'cfgsite' }),
+ );
+
+ expect(calls.some((u) => u.includes('/ping/cfgorg/cfgsite'))).to.equal(true, 'isHlx6 was not consulted');
+ expect(orgConfig).to.deep.equal({ data: [{ key: 'k', value: 'v' }] });
+ expect(siteConfig).to.deep.equal({ data: [{ key: 'k', value: 'v' }] });
+ });
+
+ it('reports the store status without throwing when the config fetch fails', async () => {
+ window.fetch = async (url) => {
+ if (String(url).includes('/ping/')) return new Response('', { status: 200 });
+ return new Response('', { status: 500 });
+ };
+
+ const [orgConfig] = await Promise.all(fetchDaConfigs({ org: 'cfgfail' }));
+ expect(orgConfig).to.deep.equal({ error: 'Error loading /cfgfail', status: 500 });
+ });
});
describe('saveDaVersion', () => {