mirror of
https://github.com/apache/superset.git
synced 2026-05-14 12:25:19 +00:00
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>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalises backend-supplied URL fields so the frontend speaks one shape
|
||||
* (router-relative paths) regardless of whether Superset is deployed at the
|
||||
* web root or under a subdirectory.
|
||||
*
|
||||
* The backend renders absolute paths that include the application root, e.g.
|
||||
* `/superset/explore/?slice_id=1`. Channel-3 helpers (window.open, redirect,
|
||||
* AppLink) and channel-2 (`SupersetClient`) re-apply the root themselves;
|
||||
* leaving the prefix on a backend value would double it. So we strip the
|
||||
* configured root on the way in and let the consumers re-add it.
|
||||
*
|
||||
* # Why this is conservative by design
|
||||
*
|
||||
* The normaliser **only touches fields whose name appears in
|
||||
* `NORMALIZED_URL_FIELDS`**. It does not heuristically detect URLs by content
|
||||
* — a `description` field containing `/looks/like/a/path` is left alone.
|
||||
* Adding a new URL field to the backend therefore requires an explicit
|
||||
* one-line change here. Drift requires intentional opt-in.
|
||||
*
|
||||
* Exact-segment prefix matching prevents false positives where a value
|
||||
* happens to share a prefix with the application root (e.g.
|
||||
* `/superset-public/...` is not stripped when the root is `/superset`).
|
||||
*
|
||||
* Absolute URLs (`https://...`, `mailto:`, protocol-relative `//cdn`) and
|
||||
* already-router-relative paths are passed through unchanged.
|
||||
*/
|
||||
|
||||
const NOT_IMPLEMENTED =
|
||||
'normalizeBackendUrls is not implemented yet — landing in the green commit of the subdirectory-helpers PR.';
|
||||
|
||||
/**
|
||||
* Field names whose values are router-relative URLs to this Superset
|
||||
* deployment and therefore safe to normalise.
|
||||
*
|
||||
* Curated, not heuristic. Add a field here only after confirming:
|
||||
*
|
||||
* 1. The backend always sets it to a path within this Superset instance
|
||||
* (never an external URL or a path with a different prefix).
|
||||
* 2. Every consumer expects to feed the value to a channel-3 helper or
|
||||
* `SupersetClient`, both of which re-apply the application root.
|
||||
*
|
||||
* Fields that have been *deliberately excluded* are listed in
|
||||
* `NORMALIZER_EXCLUSIONS` below with the reason — keep that list in sync.
|
||||
*/
|
||||
export const NORMALIZED_URL_FIELDS = new Set<string>([
|
||||
// Concrete entries are added in the green commit after the per-endpoint
|
||||
// audit. The skeleton commit only ships the constant so static-invariant
|
||||
// tests have a stable import target.
|
||||
]);
|
||||
|
||||
/**
|
||||
* URL-shaped field names that we have deliberately *not* added to
|
||||
* `NORMALIZED_URL_FIELDS`, with the reason. The negative tests in
|
||||
* `normalizeBackendUrls.test.ts` assert that values for these names are
|
||||
* passed through unchanged even when the value happens to begin with the
|
||||
* configured application root.
|
||||
*
|
||||
* This list is informational — code does not read it. Its purpose is to
|
||||
* preserve institutional knowledge so a future contributor doesn't add an
|
||||
* exclusion to the allow-list by mistake.
|
||||
*/
|
||||
export const NORMALIZER_EXCLUSIONS: ReadonlyArray<{
|
||||
field: string;
|
||||
reason: string;
|
||||
}> = [
|
||||
{ field: 'bug_report_url', reason: 'External (GitHub)' },
|
||||
{ field: 'documentation_url', reason: 'External (docs site)' },
|
||||
{ field: 'external_url', reason: 'External by name' },
|
||||
{ field: 'bundle_url', reason: 'CDN / static asset host, not a Superset route' },
|
||||
{ field: 'tracking_url', reason: 'External (analytics)' },
|
||||
{ field: 'user_login_url', reason: 'OAuth / SSO endpoints, may be external' },
|
||||
{ field: 'user_logout_url', reason: 'OAuth / SSO endpoints, may be external' },
|
||||
{ field: 'user_info_url', reason: 'OAuth / SSO endpoints, may be external' },
|
||||
{
|
||||
field: 'thumbnail_url',
|
||||
reason:
|
||||
'Storage host varies (S3 / local) — needs per-endpoint audit before normalising',
|
||||
},
|
||||
{
|
||||
field: 'creator_url',
|
||||
reason: 'User-profile destination varies by deployment',
|
||||
},
|
||||
];
|
||||
|
||||
export interface NormalizeOptions {
|
||||
/**
|
||||
* Application root to strip. Pass an empty string to disable normalisation.
|
||||
* Trailing slash is tolerated; the strip logic compares whole path segments.
|
||||
*/
|
||||
applicationRoot: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively normalise URL fields in a JSON-shaped value.
|
||||
*
|
||||
* Returns a new value when normalisation changed anything; otherwise returns
|
||||
* the input by reference so consumers can compare with `===`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
|
||||
export function normalizeBackendUrls<T>(value: T, options: NormalizeOptions): T {
|
||||
throw new Error(NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a single URL string. Exposed for use cases that read a URL
|
||||
* directly (e.g. bootstrap data) without going through the recursive walker.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
|
||||
export function normalizeBackendUrlString(
|
||||
value: string,
|
||||
options: NormalizeOptions,
|
||||
): string {
|
||||
throw new Error(NOT_IMPLEMENTED);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 { SupersetClientClass } from '@superset-ui/core';
|
||||
|
||||
// =============================================================================
|
||||
// Layer 4 example: SupersetClient × applicationRoot contract
|
||||
// =============================================================================
|
||||
//
|
||||
// Layer 4 pins down the contract between the channel-2 client and the
|
||||
// application root. The channel rule is "callers pass router-relative paths;
|
||||
// the client adds the prefix exactly once." This file proves that property in
|
||||
// isolation so the rest of the codebase can rely on it.
|
||||
//
|
||||
// The full PR adds parallel tests for the React Router channel
|
||||
// (`<MemoryRouter basename>` × `<Link to>`) and a composition test that
|
||||
// drives `redirect()` and `<Link>` together. This file ships the
|
||||
// SupersetClient half as the template.
|
||||
// =============================================================================
|
||||
|
||||
describe('SupersetClient applies the application root exactly once', () => {
|
||||
test('endpoint without leading slash is concatenated correctly under a non-empty appRoot', () => {
|
||||
const client = new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
appRoot: '/superset',
|
||||
});
|
||||
expect(client.getUrl({ endpoint: 'api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
test('endpoint with leading slash is normalised to a single root segment', () => {
|
||||
const client = new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
appRoot: '/superset',
|
||||
});
|
||||
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not double-apply the application root when caller pre-prefixes', () => {
|
||||
// This documents the bug class the helpers protect against. Pre-prefixing
|
||||
// is forbidden by the channel rule, but we record the current behaviour
|
||||
// here so anyone debugging a double-prefix issue lands on this assertion
|
||||
// and reads the comment.
|
||||
const client = new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
appRoot: '/superset',
|
||||
});
|
||||
expect(client.getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/superset/api/v1/chart',
|
||||
);
|
||||
// ^ The duplicated `/superset` is exactly the symptom developers see when
|
||||
// they wrap a SupersetClient endpoint in `ensureAppRoot`. The static
|
||||
// invariant test in `navigationUtils.invariants.test.ts` catches that
|
||||
// pattern before it reaches runtime.
|
||||
});
|
||||
|
||||
test('empty application root produces no prefix segment', () => {
|
||||
const client = new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
});
|
||||
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
|
||||
'https://config_host/api/v1/chart',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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 {
|
||||
normalizeBackendUrls,
|
||||
normalizeBackendUrlString,
|
||||
NORMALIZED_URL_FIELDS,
|
||||
} from '../../src/connection/normalizeBackendUrls';
|
||||
|
||||
// =============================================================================
|
||||
// Layer 3 example: backend URL normaliser
|
||||
// =============================================================================
|
||||
//
|
||||
// Layer 3 has two halves: positive tests (the normaliser strips the
|
||||
// configured root from values in `NORMALIZED_URL_FIELDS`) and negative tests
|
||||
// (it leaves everything else alone). The negative half carries most of the
|
||||
// safety value — it's how we prove the normaliser doesn't over-reach.
|
||||
//
|
||||
// The full PR adds:
|
||||
// • Per-field positive tests for every entry in NORMALIZED_URL_FIELDS
|
||||
// • Per-field negative tests for every entry in NORMALIZER_EXCLUSIONS
|
||||
// • Recursion through arrays and nested objects
|
||||
// • Idempotence: `normalize(normalize(x))` equals `normalize(x)`
|
||||
// • Per-call opt-out hook from SupersetClient
|
||||
//
|
||||
// This file ships one positive + one negative test as a template.
|
||||
// =============================================================================
|
||||
|
||||
const PREFIX = '/superset';
|
||||
|
||||
describe('normalizeBackendUrls (Layer 3 — positive)', () => {
|
||||
test('strips configured application root from a recognised URL field', () => {
|
||||
// `explore_url` will be added to NORMALIZED_URL_FIELDS in the green commit.
|
||||
// Until then this assertion exists to drive that decision.
|
||||
const input = { id: 1, explore_url: '/superset/explore/?slice_id=1' };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
expect(output).toEqual({ id: 1, explore_url: '/explore/?slice_id=1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBackendUrls (Layer 3 — negative passthrough)', () => {
|
||||
test('leaves random non-allow-listed fields alone even when value looks path-shaped', () => {
|
||||
// `description` is not — and must never be — a URL field. A user could
|
||||
// legitimately type `/looks/like/a/path` into a description; stripping
|
||||
// the prefix would silently mutate user content.
|
||||
const input = { description: '/superset/just-text-from-a-user' };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
test('leaves absolute URLs alone in recognised fields', () => {
|
||||
const input = { explore_url: 'https://other.example.com/superset/foo' };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
test('leaves protocol-relative URLs alone', () => {
|
||||
const input = { explore_url: '//cdn.example.com/superset/foo' };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
test('does not strip a similar-but-different prefix segment', () => {
|
||||
// `/superset-public/...` shares the `/superset` text but is a different
|
||||
// path segment. Conservative match: only `/superset` followed by `/` or
|
||||
// end-of-string is treated as the application root.
|
||||
const input = { explore_url: '/superset-public/explore/?slice_id=1' };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
test('is a no-op when application root is empty', () => {
|
||||
const input = { explore_url: '/explore/?slice_id=1' };
|
||||
const output = normalizeBackendUrls(input, { applicationRoot: '' });
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBackendUrlString (Layer 3 — string-level entry point)', () => {
|
||||
test('strips application root from a router-relative path', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset/sqllab', { applicationRoot: PREFIX }),
|
||||
).toBe('/sqllab');
|
||||
});
|
||||
|
||||
test('passes absolute URLs through unchanged', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('https://external.example.com/foo', {
|
||||
applicationRoot: PREFIX,
|
||||
}),
|
||||
).toBe('https://external.example.com/foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NORMALIZED_URL_FIELDS (allow-list shape)', () => {
|
||||
test('is a Set so callers can rely on O(1) membership checks', () => {
|
||||
expect(NORMALIZED_URL_FIELDS).toBeInstanceOf(Set);
|
||||
});
|
||||
});
|
||||
216
superset-frontend/spec/helpers/sourceTreeScanner.ts
Normal file
216
superset-frontend/spec/helpers/sourceTreeScanner.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
91
superset-frontend/spec/helpers/withApplicationRoot.ts
Normal file
91
superset-frontend/spec/helpers/withApplicationRoot.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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 { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
|
||||
|
||||
// =============================================================================
|
||||
// Layer 5 example: per-site regression test for SliceHeaderControls
|
||||
// =============================================================================
|
||||
//
|
||||
// Subdirectory-specific behaviour for SliceHeaderControls. The full PR adds
|
||||
// parallel files for RedirectWarning, ResultSet, DatasourceEditor,
|
||||
// SaveDatasetModal, ViewQuery, plus reinstates the regression tests from
|
||||
// commits 86fe4fc8b2 (chart export) and 36a32e7b49 (SavedQueryList,
|
||||
// dashboard fullscreen) which haven't merged to master yet.
|
||||
//
|
||||
// Why a separate file: the existing SliceHeaderControls.test.tsx is 676 lines
|
||||
// of shared setup that does not mock `getBootstrapData`. Mocking it at the
|
||||
// top of that file would force every existing test to consider application
|
||||
// root behaviour. Putting subdirectory regressions in their own file keeps
|
||||
// the mock surface explicit and discoverable by name.
|
||||
//
|
||||
// This test is RED today: SliceHeaderControls/index.tsx:266 calls
|
||||
// `window.open(props.exploreUrl, '_blank')` without prefixing the root, so
|
||||
// the assertion below fails. The migration commit replaces that call with
|
||||
// `openInNewTab(props.exploreUrl)` (which prefixes internally) and the test
|
||||
// goes green.
|
||||
// =============================================================================
|
||||
|
||||
const APPLICATION_ROOT_MOCK = jest.fn<string, []>(() => '');
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
applicationRoot: () => APPLICATION_ROOT_MOCK(),
|
||||
}));
|
||||
|
||||
const SLICE_ID = 371;
|
||||
|
||||
const buildProps = (): SliceHeaderControlsProps =>
|
||||
({
|
||||
addDangerToast: jest.fn(),
|
||||
addSuccessToast: jest.fn(),
|
||||
exploreChart: jest.fn(),
|
||||
exportCSV: jest.fn(),
|
||||
exportFullCSV: jest.fn(),
|
||||
exportXLSX: jest.fn(),
|
||||
exportFullXLSX: jest.fn(),
|
||||
exportPivotExcel: jest.fn(),
|
||||
forceRefresh: jest.fn(),
|
||||
handleToggleFullSize: jest.fn(),
|
||||
toggleExpandSlice: jest.fn(),
|
||||
logEvent: jest.fn(),
|
||||
logExploreChart: jest.fn(),
|
||||
slice: {
|
||||
slice_id: SLICE_ID,
|
||||
slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20371%7D',
|
||||
slice_name: 'Subdirectory regression chart',
|
||||
slice_description: '',
|
||||
form_data: {
|
||||
slice_id: SLICE_ID,
|
||||
datasource: '58__table',
|
||||
viz_type: VizType.Sunburst,
|
||||
},
|
||||
viz_type: VizType.Sunburst,
|
||||
datasource: '58__table',
|
||||
description: '',
|
||||
description_markeddown: '',
|
||||
owners: [],
|
||||
modified: '',
|
||||
changed_on: 0,
|
||||
},
|
||||
isCached: [false],
|
||||
isExpanded: false,
|
||||
cachedDttm: [''],
|
||||
updatedDttm: 0,
|
||||
supersetCanExplore: true,
|
||||
supersetCanCSV: true,
|
||||
componentId: 'CHART-subdir',
|
||||
dashboardId: 26,
|
||||
isFullSize: false,
|
||||
chartStatus: 'rendered',
|
||||
showControls: true,
|
||||
supersetCanShare: true,
|
||||
formData: {
|
||||
slice_id: SLICE_ID,
|
||||
datasource: '58__table',
|
||||
viz_type: VizType.Sunburst,
|
||||
},
|
||||
exploreUrl: '/explore/?dashboard_page_id=abc&slice_id=371',
|
||||
defaultOpen: true,
|
||||
}) as unknown as SliceHeaderControlsProps;
|
||||
|
||||
const renderControls = (): void => {
|
||||
render(<SliceHeaderControls {...buildProps()} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
...mockState,
|
||||
user: {
|
||||
...mockState.user,
|
||||
roles: { Admin: [['can_samples', 'Datasource']] },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('SliceHeaderControls — Cmd-click "Edit chart" under subdirectory deployment', () => {
|
||||
let openSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
APPLICATION_ROOT_MOCK.mockReturnValue('');
|
||||
openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('opens the unprefixed exploreUrl when application root is empty', async () => {
|
||||
APPLICATION_ROOT_MOCK.mockReturnValue('');
|
||||
renderControls();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'More Options' }));
|
||||
const editChart = await screen.findByText('Edit chart');
|
||||
userEvent.click(editChart, { metaKey: true });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/explore/?dashboard_page_id=abc&slice_id=371',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
test('opens the prefixed exploreUrl when deployed under a subdirectory', async () => {
|
||||
APPLICATION_ROOT_MOCK.mockReturnValue('/superset');
|
||||
renderControls();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'More Options' }));
|
||||
const editChart = await screen.findByText('Edit chart');
|
||||
userEvent.click(editChart, { metaKey: true });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/superset/explore/?dashboard_page_id=abc&slice_id=371',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 { expectNoHits, scanSource } from 'spec/helpers/sourceTreeScanner';
|
||||
|
||||
// =============================================================================
|
||||
// Layer 2 example: structural invariant
|
||||
// =============================================================================
|
||||
//
|
||||
// Layer 2 contains tests that read the source tree and assert structural
|
||||
// properties — "no file outside `navigationUtils.ts` imports `ensureAppRoot`"
|
||||
// is the canonical example. The full PR adds parallel scans for raw
|
||||
// `window.open` / `window.location.href`, double-prefix patterns through
|
||||
// `SupersetClient` and `history.push`, and `<Link to={makeUrl(...)}>`.
|
||||
//
|
||||
// Each test seeds an allow-list of current violations so the suite is GREEN
|
||||
// on day one. Migration commits then delete entries from the allow-list,
|
||||
// driving the count to zero. New violations introduced after migration fail
|
||||
// the suite immediately and surface a `file:line` location in the message.
|
||||
//
|
||||
// The list below is the INITIAL seed — every entry will be removed by a
|
||||
// subsequent migration commit. Do not extend it without an inline comment
|
||||
// explaining why the file is exempt.
|
||||
// =============================================================================
|
||||
|
||||
const PATH_UTILS_IMPORT_ALLOWLIST: string[] = [
|
||||
// Migrated by future commits. Each line listed here is a call site that
|
||||
// currently imports `ensureAppRoot` or `makeUrl` directly; the migration
|
||||
// PRs replace those imports with calls to focused helpers in
|
||||
// `src/utils/navigationUtils.ts` and remove the file from this list.
|
||||
'src/SqlLab/components/QueryTable/index.tsx',
|
||||
'src/SqlLab/components/ResultSet/index.tsx',
|
||||
'src/components/Chart/DrillDetail/DrillDetailPane.tsx',
|
||||
'src/components/Chart/chartAction.ts',
|
||||
'src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx',
|
||||
'src/components/FacePile/index.tsx',
|
||||
'src/components/StreamingExportModal/useStreamingExport.ts',
|
||||
'src/explore/components/controls/ViewQuery.tsx',
|
||||
'src/explore/exploreUtils/index.ts',
|
||||
'src/features/databases/DatabaseModal/index.tsx',
|
||||
'src/features/datasets/AddDataset/LeftPanel/index.tsx',
|
||||
'src/features/home/EmptyState.tsx',
|
||||
'src/features/home/Menu.tsx',
|
||||
'src/features/home/RightMenu.tsx',
|
||||
'src/features/home/SavedQueries.tsx',
|
||||
'src/middleware/loggerMiddleware.ts',
|
||||
'src/pages/SavedQueryList/index.tsx',
|
||||
'src/preamble.ts',
|
||||
'src/views/CRUD/hooks.ts',
|
||||
];
|
||||
|
||||
test('no file outside navigationUtils.ts imports ensureAppRoot or makeUrl from pathUtils', () => {
|
||||
const hits = scanSource({
|
||||
pattern: /\b(?:ensureAppRoot|makeUrl)\b/,
|
||||
allowlist: [
|
||||
// The two modules that are *allowed* to know about path prefixing.
|
||||
// `pathUtils.ts` defines the helpers; `navigationUtils.ts` is the only
|
||||
// re-export sanctioned for the rest of the codebase to consume.
|
||||
'src/utils/pathUtils.ts',
|
||||
'src/utils/navigationUtils.ts',
|
||||
// SupersetClient has its own `appRoot` configuration path — it does not
|
||||
// import from `pathUtils`. Excluded so a future occurrence of the word
|
||||
// `appRoot` in connection internals doesn't trip this scan.
|
||||
'packages/superset-ui-core/src/connection/SupersetClientClass.ts',
|
||||
'packages/superset-ui-core/src/connection/normalizeBackendUrls.ts',
|
||||
...PATH_UTILS_IMPORT_ALLOWLIST,
|
||||
],
|
||||
});
|
||||
|
||||
expectNoHits(
|
||||
hits,
|
||||
'Found imports of ensureAppRoot / makeUrl outside navigationUtils.ts. ' +
|
||||
'Use the focused helpers (openInNewTab, redirect, getShareableUrl, AppLink) ' +
|
||||
'instead, or add the file to PATH_UTILS_IMPORT_ALLOWLIST with justification.',
|
||||
);
|
||||
});
|
||||
115
superset-frontend/src/utils/navigationUtils.test.ts
Normal file
115
superset-frontend/src/utils/navigationUtils.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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 { withApplicationRoot } from 'spec/helpers/withApplicationRoot';
|
||||
|
||||
// =============================================================================
|
||||
// Layer 1 example: openInNewTab
|
||||
// =============================================================================
|
||||
//
|
||||
// Layer 1 covers per-helper unit behaviour. The full PR adds parallel suites
|
||||
// for `redirect`, `redirectReplace`, `getShareableUrl`, and `<AppLink>`. This
|
||||
// file ships a single helper as a template for the structure those follow:
|
||||
//
|
||||
// 1. Each helper is exercised under empty appRoot AND a non-empty appRoot.
|
||||
// 2. Absolute URLs (https://, mailto:, etc.) pass through unchanged.
|
||||
// 3. Already-prefixed input is idempotent (does not double-prefix).
|
||||
// =============================================================================
|
||||
|
||||
describe('openInNewTab', () => {
|
||||
let openSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('passes router-relative path through unchanged when application root is empty', async () => {
|
||||
await withApplicationRoot('', async () => {
|
||||
const { openInNewTab } = await import('src/utils/navigationUtils');
|
||||
openInNewTab('/sqllab?new=true');
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/sqllab?new=true',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('prefixes router-relative path with application root under subdirectory deployment', async () => {
|
||||
await withApplicationRoot('/superset/', async () => {
|
||||
const { openInNewTab } = await import('src/utils/navigationUtils');
|
||||
openInNewTab('/sqllab?new=true');
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/superset/sqllab?new=true',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('prefixes correctly for nested subdirectory roots', async () => {
|
||||
await withApplicationRoot('/a/b/c/', async () => {
|
||||
const { openInNewTab } = await import('src/utils/navigationUtils');
|
||||
openInNewTab('/dashboard/list');
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/a/b/c/dashboard/list',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('passes absolute URLs through unchanged regardless of application root', async () => {
|
||||
await withApplicationRoot('/superset/', async () => {
|
||||
const { openInNewTab } = await import('src/utils/navigationUtils');
|
||||
openInNewTab('https://external.example.com/docs');
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://external.example.com/docs',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('passes mailto: URLs through unchanged', async () => {
|
||||
await withApplicationRoot('/superset/', async () => {
|
||||
const { openInNewTab } = await import('src/utils/navigationUtils');
|
||||
openInNewTab('mailto:owner@example.com');
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'mailto:owner@example.com',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('uses noopener noreferrer for security on every call', async () => {
|
||||
await withApplicationRoot('/superset/', async () => {
|
||||
const { openInNewTab } = await import('src/utils/navigationUtils');
|
||||
openInNewTab('/sqllab');
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
const features = openSpy.mock.calls[0][2] as string;
|
||||
expect(features).toContain('noopener');
|
||||
expect(features).toContain('noreferrer');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,93 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { AnchorHTMLAttributes, ReactElement } from 'react';
|
||||
import { ensureAppRoot } from './pathUtils';
|
||||
|
||||
// =============================================================================
|
||||
// Channel-3 helpers (browser-direct sinks)
|
||||
// =============================================================================
|
||||
//
|
||||
// Every helper in this section takes a *router-relative* path (the same shape
|
||||
// you'd pass to `<Link to>` or `history.push`) and applies the application
|
||||
// root internally before handing the URL to the browser. This keeps the rest
|
||||
// of the codebase decision-free: callers always write `/sqllab`, never
|
||||
// `${applicationRoot()}/sqllab`.
|
||||
//
|
||||
// Once migration is complete, `ensureAppRoot` and `makeUrl` are imported only
|
||||
// from this module. A static-invariant test (see
|
||||
// `navigationUtils.invariants.test.ts`) enforces that boundary.
|
||||
// =============================================================================
|
||||
|
||||
const NOT_IMPLEMENTED =
|
||||
'navigationUtils helper not implemented yet — landing in the green commit of the subdirectory-helpers PR.';
|
||||
|
||||
/**
|
||||
* Open a router-relative path in a new browser tab.
|
||||
*
|
||||
* The path is automatically prefixed with the application root so the new tab
|
||||
* lands inside Superset on subdirectory deployments.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
|
||||
export function openInNewTab(path: string): void {
|
||||
throw new Error(NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate the current window to a router-relative path via `window.location`.
|
||||
*
|
||||
* Unlike `history.push`, this triggers a full page load. Use it only when the
|
||||
* destination is outside the React Router tree (e.g. a backend-rendered page)
|
||||
* or when a hard reload is required.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
|
||||
export function redirect(path: string): void {
|
||||
throw new Error(NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the current entry in `window.history` with a router-relative path.
|
||||
* No new history entry is pushed. Use sparingly — most navigation should go
|
||||
* through React Router's `history.replace`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
|
||||
export function redirectReplace(path: string): void {
|
||||
throw new Error(NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully-qualified URL (`<scheme>://<host><appRoot><path>`) from a
|
||||
* router-relative path. Use for clipboard / share / email targets that need
|
||||
* to round-trip through external systems back to this Superset deployment.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
|
||||
export function getShareableUrl(path: string): string {
|
||||
throw new Error(NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor element that prefixes its `href` with the application root.
|
||||
*
|
||||
* Use this instead of `<a href={varExpr}>` whenever the href is computed at
|
||||
* runtime. Static `<a href="https://...">` literals are fine — the static-
|
||||
* invariant test only flags non-literal hrefs.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
|
||||
export function AppLink(
|
||||
props: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string },
|
||||
): ReactElement {
|
||||
throw new Error(NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Legacy multi-mode helpers
|
||||
// =============================================================================
|
||||
// These predate the focused helpers above. They behave correctly but are
|
||||
// scheduled for replacement so the channel-3 surface is entirely composed of
|
||||
// single-purpose functions. Migration commits will rewrite call sites to use
|
||||
// the focused helpers, then delete these.
|
||||
// =============================================================================
|
||||
|
||||
export const navigateTo = (
|
||||
url: string,
|
||||
options?: { newWindow?: boolean; assign?: boolean },
|
||||
|
||||
Reference in New Issue
Block a user