From f43530eb31db7487e09f6f0099c2175188ccde16 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Thu, 25 Jun 2026 11:20:54 -0400 Subject: [PATCH 1/2] fix: make nx2 crawl backend-aware via source.list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crawler in nx2/public/utils/tree.js listed folder contents by hitting ${DA_ORIGIN}/list directly, bypassing the backend-aware source.list. On a Helix 6 site that endpoint has nothing, so da-live's delete confirmation — which crawls a folder to count its files — reported "0 items" and the delete flow broke (adobe/da-live#1034). Route getChildren through source.list, which detects the backend per-site via isHlx6 and returns DA-normalized items. Behavior is unchanged for the legacy DA backend (DA is the backend, and source.list falls back to the same /list endpoint); only Helix 6 sites — previously broken — change. Co-Authored-By: Claude Opus 4.8 --- nx2/public/utils/tree.js | 23 ++--- test/nx2/public/utils/tree.test.js | 154 +++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 test/nx2/public/utils/tree.test.js diff --git a/nx2/public/utils/tree.js b/nx2/public/utils/tree.js index 75cb2bb75..4077f592d 100644 --- a/nx2/public/utils/tree.js +++ b/nx2/public/utils/tree.js @@ -1,5 +1,4 @@ -import { daFetch } from '../../utils/api.js'; -import { DA_ORIGIN } from './constants.js'; +import { source } from '../../utils/api.js'; export class Queue { constructor(callback, maxConcurrent = 500, onError = null, throttle = null) { @@ -55,14 +54,16 @@ async function getChildren(path) { let continuationToken = null; do { - const opts = continuationToken - ? { headers: { 'da-continuation-token': continuationToken } } - : {}; - const resp = await daFetch({ url: `${DA_ORIGIN}/list${path}`, opts }); - if (!resp.ok) break; - - const json = await resp.json(); - json.forEach((child) => { + // Use the backend-aware source.list rather than hitting the DA list endpoint + // directly. source.list routes per-site via isHlx6, so the crawl works for + // both the legacy DA backend and Helix 6 (and returns DA-normalized items). + const { ok, items, continuationToken: nextToken } = await source.list( + path, + { continuationToken }, + ); + if (!ok) break; + + items.forEach((child) => { if (!child.name) { // eslint-disable-next-line no-console console.log(`This folder has a child with an empty name: ${child.path}`); @@ -75,7 +76,7 @@ async function getChildren(path) { } }); - continuationToken = resp.headers.get('da-continuation-token'); + continuationToken = nextToken; } while (continuationToken); return { files, folders }; diff --git a/test/nx2/public/utils/tree.test.js b/test/nx2/public/utils/tree.test.js new file mode 100644 index 000000000..de98cf6f0 --- /dev/null +++ b/test/nx2/public/utils/tree.test.js @@ -0,0 +1,154 @@ +import { expect } from '@esm-bundle/chai'; +import { HLX_ADMIN, AEM_API, DA_ADMIN } from '../../../../nx2/utils/utils.js'; +import { crawl } from '../../../../nx2/public/utils/tree.js'; + +// Dynamic-expression import (not a literal string) so @web/dev-server-import-maps +// does not rewrite this to ...?wds-import-map=0. The same mock URL is reached at +// runtime via the inline importmap when api.js's dynamic IIFE imports ims.js, so +// both this test and api.js receive the *same* mock module instance. +const imsPath = '../../../../nx2/utils/ims.js'; +const { resetMockIms } = await import(imsPath); + +let counter = 0; +const uniq = (label) => { + counter += 1; + return `${label}-${counter}-${Math.floor(Math.random() * 1e6)}`; +}; + +// Unique org/site per call so isHlx6's in-memory cache never collides between +// tests. hlx6-detection is driven via the ping response header (see installFetch) +// rather than seeded localStorage, which is shared across concurrently-running +// test files and therefore raced. +const makeOrgSite = () => ({ org: uniq('org'), site: uniq('site') }); + +let origFetch; + +// Route fetches by first matching URL substring; each route value is the JSON +// list body to return. Most specific keys must be listed first. `hlx6` controls +// whether the upgrade-status ping advertises Helix 6. +const installFetch = (routes, { hlx6 = false } = {}) => { + origFetch = window.fetch; + window.fetch = async (url) => { + const u = url.toString(); + if (u.includes(`${HLX_ADMIN}/ping/`)) { + const headers = hlx6 ? { 'x-api-upgrade-available': 'true' } : {}; + return new Response('', { status: 200, headers }); + } + const key = Object.keys(routes).find((k) => u.includes(k)); + return new Response(JSON.stringify(key ? routes[key] : []), { status: 200 }); + }; +}; + +const restoreFetch = () => { + if (origFetch) window.fetch = origFetch; + origFetch = null; +}; + +describe('nx2 crawl (backend-aware)', () => { + beforeEach(() => { + resetMockIms(); + }); + + afterEach(() => { + restoreFetch(); + }); + + it('crawls a Helix 6 site via source.list (regression for delete count)', async () => { + const { org: o, site: s } = makeOrgSite(); + installFetch({ + [`${AEM_API}/${o}/sites/${s}/source/folder/sub/`]: [ + { name: 'deep.json', 'content-type': 'application/json' }, + ], + [`${AEM_API}/${o}/sites/${s}/source/folder/`]: [ + { name: 'doc.html', 'content-type': 'text/html' }, + { name: 'sub/', 'content-type': 'application/folder' }, + ], + }, { hlx6: true }); + + const { results } = crawl({ + path: `/${o}/${s}/folder`, + callback: null, + concurrent: 10, + throttle: 10, + }); + + const files = await results; + expect(files).to.have.length(2); + expect(files.some((f) => f.name === 'doc' && f.ext === 'html')).to.equal(true); + expect(files.some((f) => f.name === 'deep' && f.ext === 'json')).to.equal(true); + }); + + it('crawls the legacy DA backend via source.list fallback', async () => { + const { org: o, site: s } = makeOrgSite(); + installFetch({ + [`${DA_ADMIN}/list/${o}/${s}/folder`]: [ + { path: `/${o}/${s}/folder/page.html`, name: 'page', ext: 'html', lastModified: 1 }, + { path: `/${o}/${s}/folder/data.json`, name: 'data', ext: 'json', lastModified: 2 }, + ], + }); + + const { results } = crawl({ + path: `/${o}/${s}/folder`, + callback: null, + concurrent: 10, + throttle: 10, + }); + + const files = await results; + expect(files).to.have.length(2); + expect(files.map((f) => f.name).sort()).to.deep.equal(['data', 'page']); + }); + + it('follows the continuation token across pages', async () => { + const { org: o, site: s } = makeOrgSite(); + let page = 0; + origFetch = window.fetch; + window.fetch = async (url, opts = {}) => { + const u = url.toString(); + if (u.includes(`${HLX_ADMIN}/ping/`)) return new Response('', { status: 200 }); + const hasToken = opts.headers?.['da-continuation-token']; + if (!hasToken) { + page += 1; + return new Response( + JSON.stringify([{ path: `/${o}/${s}/big/a.html`, name: 'a', ext: 'html' }]), + { status: 200, headers: { 'da-continuation-token': 'next' } }, + ); + } + return new Response( + JSON.stringify([{ path: `/${o}/${s}/big/b.html`, name: 'b', ext: 'html' }]), + { status: 200 }, + ); + }; + + const { results } = crawl({ + path: `/${o}/${s}/big`, + callback: null, + concurrent: 10, + throttle: 10, + }); + + const files = await results; + expect(page).to.equal(1); + expect(files.map((f) => f.name).sort()).to.deep.equal(['a', 'b']); + }); + + it('stops a folder listing on a non-ok response', async () => { + const { org: o, site: s } = makeOrgSite(); + origFetch = window.fetch; + window.fetch = async (url) => { + const u = url.toString(); + if (u.includes(`${HLX_ADMIN}/ping/`)) return new Response('', { status: 200 }); + return new Response('', { status: 403 }); + }; + + const { results } = crawl({ + path: `/${o}/${s}/folder`, + callback: null, + concurrent: 10, + throttle: 10, + }); + + const files = await results; + expect(files).to.deep.equal([]); + }); +}); From e611f516524c07ad52b37c9547756dfaddd02907 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 11:22:32 -0400 Subject: [PATCH 2/2] Update worklog --- WORKLOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/WORKLOG.md b/WORKLOG.md index b400f35d2..88fde41de 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,15 @@ # Worklog +## 2026-06-25 + +### nx2/public/utils/tree.js — backend-aware crawl (crawlhlx6 branch) + +`getChildren` listed folder contents by fetching `${DA_ORIGIN}/list${path}` directly, bypassing the backend-aware `source.list`. On a Helix 6 site that endpoint has nothing, so da-live's delete confirmation — which crawls a folder to count its files before showing the dialog — reported "0 items" and the delete flow broke (adobe/da-live#1034). + +Now routes through `source.list` (from `../../utils/api.js`), which detects the backend per-site via `isHlx6` and returns DA-normalized items. Legacy DA behavior is unchanged: DA *is* the backend, and `source.list` falls back to the same `/list` endpoint. Only Helix 6 sites — previously broken — change. Removed the now-unused `daFetch`/`DA_ORIGIN` imports. + +Only consumer is da-live (no internal nx2 callers). nx1's `tree.js` is intentionally left DA-only since nx1 is the legacy DA-only world. Added `test/nx2/public/utils/tree.test.js` covering the hlx6 crawl, legacy fallback, continuation paging, and non-ok handling; hlx6 detection in the tests is driven via the ping response header rather than seeded localStorage, which is shared across concurrently-running test files and was racing api.test.js's `removeItem`. + ## 2026-06-23 ### nx2/blocks/shared/dialog — configurable panel sizing (dialog-css-vars branch)