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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
23 changes: 12 additions & 11 deletions nx2/public/utils/tree.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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}`);
Expand All @@ -75,7 +76,7 @@ async function getChildren(path) {
}
});

continuationToken = resp.headers.get('da-continuation-token');
continuationToken = nextToken;
} while (continuationToken);

return { files, folders };
Expand Down
154 changes: 154 additions & 0 deletions test/nx2/public/utils/tree.test.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading