mirror of
https://github.com/apache/superset.git
synced 2026-05-21 15:55:10 +00:00
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>
217 lines
6.6 KiB
TypeScript
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));
|
|
}
|
|
}
|