Files
superset2/superset-frontend/spec/helpers/withApplicationRoot.ts
Joe Li be43d00d5d test(subdirectory): scaffold red/green tests for application root URL helpers
Skeleton commit for the subdirectory deployment refactor. Adds the test
framework and one example test per layer; the helpers themselves are
stubbed so the suite is meaningfully red until the green commit lands.

Frameworks
- spec/helpers/withApplicationRoot.ts: fixture that rewrites #app data
  and resets the module cache so getBootstrapData() returns the requested
  application root inside the callback. Replaces the inline ritual that
  pathUtils.test.ts currently repeats per test.
- spec/helpers/sourceTreeScanner.ts: line-by-line regex scanner over the
  source tree with allow-list support. Backs the static-invariant tests
  in Layer 2 with workspace-relative file:line locations on failure.

Stubs
- src/utils/navigationUtils.ts: openInNewTab, redirect, redirectReplace,
  getShareableUrl, AppLink. Each throws a "not implemented" error with a
  doc comment describing the channel rule it enforces. Existing
  navigateTo / navigateWithState are kept untouched and called out as
  legacy multi-mode helpers scheduled for replacement.
- packages/superset-ui-core/src/connection/normalizeBackendUrls.ts:
  conservative URL field normaliser. Ships the curated NORMALIZED_URL_FIELDS
  set (initially empty pending per-endpoint audit) and a documented
  NORMALIZER_EXCLUSIONS list explaining why bug_report_url, thumbnail_url,
  user_login_url, etc. are deliberately not normalised.

Layered tests (one example each; full suite expands per layer in
subsequent commits on this PR)
- Layer 1 unit: navigationUtils.test.ts exercises openInNewTab under
  empty / single / nested application roots, plus absolute-URL and
  mailto passthrough. Red until the helper is implemented.
- Layer 2 invariant: navigationUtils.invariants.test.ts asserts that
  ensureAppRoot / makeUrl are not imported outside navigationUtils.ts.
  Allow-list seeded with the 19 current call sites so the test is GREEN
  on day one; migration commits delete entries from the list.
- Layer 3 normaliser: normalizeBackendUrls.test.ts pairs a positive
  strip case with negative passthrough cases (non-allow-listed field,
  absolute URL, similar-but-different prefix segment, empty root).
  Red until the normaliser is implemented.
- Layer 4 contract: SupersetClientAppRootContract.test.ts pins the
  channel-2 invariant (root applied exactly once, never doubled).
  Documents the double-prefix symptom in a regression assertion.
- Layer 5 regression: SliceHeaderControls.subdirectory.test.tsx
  asserts Cmd-click "Edit chart" opens a prefixed URL when the app
  is deployed under a subdirectory. Red until index.tsx:266 is
  migrated to openInNewTab.

Strategy: each subsequent commit on this PR fans out one layer to its
full coverage and migrates the corresponding call sites, shrinking the
Layer 2 allow-list in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:51:15 -07:00

92 lines
3.5 KiB
TypeScript

/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Test fixture for subdirectory-deployment scenarios.
*
* Bootstrap data in Superset is read once per module load via
* `getBootstrapData()` and cached. Tests that exercise URL generation under a
* non-empty `application_root` therefore need to rewrite the `#app` element
* and force the relevant modules to re-import so the cache is rebuilt.
*
* `withApplicationRoot` centralises that ritual. Usage:
*
* import { withApplicationRoot } from 'spec/helpers/withApplicationRoot';
*
* test('redirects to prefixed root under subdirectory deployment', async () => {
* await withApplicationRoot('/superset/', async () => {
* const { redirect } = await import('src/utils/navigationUtils');
* redirect('/');
* expect(window.location.href).toBe('/superset/');
* });
* });
*
* The callback receives a freshly-reset module registry, so any imports inside
* it observe the configured root. After the callback finishes (or throws), the
* fixture restores the previous `<div id="app">` markup and resets modules
* again, leaving the global state clean for the next test.
*
* Pass `''` (the default) to simulate a deployment with no application root.
*/
export async function withApplicationRoot<T>(
applicationRoot: string,
callback: () => Promise<T> | T,
): Promise<T> {
const previousBody = document.body.innerHTML;
try {
const bootstrapData = {
common: { application_root: applicationRoot },
};
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(bootstrapData)}'></div>`;
jest.resetModules();
// Touch getBootstrapData first so the cached value reflects the new DOM.
await import('src/utils/getBootstrapData');
return await callback();
} finally {
document.body.innerHTML = previousBody;
jest.resetModules();
}
}
/**
* Convenience wrapper that runs the same assertion under multiple application
* roots. Use when the assertion text doesn't depend on the prefix.
*
* applicationRootScenarios([
* { root: '', expected: '/sqllab' },
* { root: '/superset/', expected: '/superset/sqllab' },
* { root: '/a/b/c/', expected: '/a/b/c/sqllab' },
* ], async ({ expected }) => {
* const { ensureAppRoot } = await import('src/utils/pathUtils');
* expect(ensureAppRoot('/sqllab')).toBe(expected);
* });
*/
export async function applicationRootScenarios<S extends { root: string }>(
scenarios: S[],
body: (scenario: S) => Promise<void> | void,
): Promise<void> {
for (const scenario of scenarios) {
// eslint-disable-next-line no-await-in-loop -- intentional: scenarios share document state.
await withApplicationRoot(scenario.root, () => body(scenario));
}
}