Files
superset2/superset-frontend/spec/helpers/sourceTreeScanner.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

217 lines
6.6 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.
*/
import { readdirSync, readFileSync, statSync } from 'fs';
import { join, relative, resolve, sep } from 'path';
/**
* Directories scanned by `scanSource` when `roots` is not supplied.
* Resolved relative to the `superset-frontend` workspace.
*/
const DEFAULT_ROOTS = ['src', 'packages/superset-ui-core/src'];
/**
* Path segments that are always excluded. We compare against path components
* so any directory named `node_modules` (etc.) is skipped wherever it appears
* in the tree.
*/
const ALWAYS_SKIP_SEGMENTS = new Set([
'node_modules',
'dist',
'build',
'coverage',
'__mocks__',
'cypress-base',
'playwright',
]);
/**
* Filename suffixes that legitimately mention otherwise-banned helpers (tests
* import them, stories embed them) and should not be scanned for invariants.
*/
const ALWAYS_SKIP_SUFFIXES = ['.test.ts', '.test.tsx', '.stories.ts', '.stories.tsx'];
/** Extensions considered source files. */
const SOURCE_EXTENSIONS = ['.ts', '.tsx'];
export interface ScanOptions {
/**
* Workspace-relative directory roots to scan. Defaults to the source tree.
* Each entry is walked recursively.
*/
roots?: string[];
/**
* Additional path segments to skip in addition to {@link ALWAYS_SKIP_SEGMENTS}.
*/
ignoreSegments?: string[];
/** Regex run against each line of each file. */
pattern: RegExp;
/**
* File paths (relative to `superset-frontend`, forward slashes) that are
* exempt from this scan. Use sparingly; every entry should justify itself
* in a comment.
*/
allowlist?: string[];
}
export interface ScanHit {
/** Path relative to `superset-frontend`, with forward slashes. */
file: string;
/** 1-based line number. */
line: number;
/** The text of the matching line, trimmed. */
text: string;
/** The substring captured by `pattern`. */
match: string;
}
/**
* Workspace root used as the base for relative paths returned by the scanner.
* `__dirname` resolves to `<workspace>/spec/helpers`, so the parent's parent
* is the workspace regardless of where Jest is invoked from.
*/
const WORKSPACE_ROOT = resolve(__dirname, '..', '..');
function isSourceFile(name: string): boolean {
return (
SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) &&
!ALWAYS_SKIP_SUFFIXES.some(suffix => name.endsWith(suffix))
);
}
function walk(directory: string, ignoreSegments: Set<string>): string[] {
const found: string[] = [];
let entries;
try {
entries = readdirSync(directory, { withFileTypes: true });
} catch {
return found;
}
for (const entry of entries) {
if (ignoreSegments.has(entry.name)) continue;
const absolute = join(directory, entry.name);
if (entry.isDirectory()) {
found.push(...walk(absolute, ignoreSegments));
} else if (entry.isFile() && isSourceFile(entry.name)) {
found.push(absolute);
}
}
return found;
}
function toForwardSlashes(path: string): string {
return sep === '/' ? path : path.split(sep).join('/');
}
/**
* Scan source files under `roots` for lines matching `pattern`.
*
* Each match is returned as a {@link ScanHit} with a workspace-relative path
* and 1-based line number. Files listed in `allowlist` are skipped entirely.
*
* Scanning is deliberately textual (line-by-line regex) rather than AST-based
* — these invariants flag forbidden *patterns*, not forbidden *expressions*.
* False positives on string literals or comments should be addressed by
* tightening the regex, not by parsing.
*/
export function scanSource(options: ScanOptions): ScanHit[] {
const {
roots = DEFAULT_ROOTS,
ignoreSegments = [],
pattern,
allowlist = [],
} = options;
const ignoreSet = new Set([...ALWAYS_SKIP_SEGMENTS, ...ignoreSegments]);
const allowSet = new Set(allowlist);
const hits: ScanHit[] = [];
const seen = new Set<string>();
for (const root of roots) {
const absoluteRoot = resolve(WORKSPACE_ROOT, root);
let stat;
try {
stat = statSync(absoluteRoot);
} catch {
continue;
}
if (!stat.isDirectory()) continue;
for (const absoluteFile of walk(absoluteRoot, ignoreSet)) {
if (seen.has(absoluteFile)) continue;
seen.add(absoluteFile);
const relativePath = toForwardSlashes(
relative(WORKSPACE_ROOT, absoluteFile),
);
if (allowSet.has(relativePath)) continue;
const contents = readFileSync(absoluteFile, 'utf8');
const lines = contents.split('\n');
for (let index = 0; index < lines.length; index += 1) {
const lineText = lines[index];
// Re-create the regex per line so the global flag's lastIndex doesn't
// bleed across iterations.
const lineRegex = new RegExp(pattern.source, pattern.flags);
const match = lineRegex.exec(lineText);
if (match) {
hits.push({
file: relativePath,
line: index + 1,
text: lineText.trim(),
match: match[0],
});
}
}
}
}
return hits;
}
/**
* Format a list of hits as a human-readable failure message. Used by
* invariant tests so the developer sees `file:line` for every violation.
*/
export function formatHits(hits: ScanHit[], header: string): string {
if (hits.length === 0) return header;
const lines = hits
.slice(0, 50)
.map(hit => ` ${hit.file}:${hit.line}${hit.text}`);
const overflow =
hits.length > 50 ? `\n ... and ${hits.length - 50} more` : '';
return `${header}\n${lines.join('\n')}${overflow}`;
}
/**
* Helper that fails a Jest test with a formatted message when `hits` is
* non-empty. Returns void so call sites read naturally:
*
* expectNoHits(scanSource({ pattern: /window\.open\(/ }), 'Found raw window.open');
*/
export function expectNoHits(hits: ScanHit[], header: string): void {
if (hits.length > 0) {
throw new Error(formatHits(hits, header));
}
}