-
Notifications
You must be signed in to change notification settings - Fork 61
fix(core): let BS hosts inject a complete Maestro scope root #2357
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,7 @@ import { normalize } from '@percy/config/utils'; | |
| import { ServerError } from './server.js'; | ||
| import { encodeURLSearchParams } from './utils.js'; | ||
| import { handleSyncJob } from './snapshot.js'; | ||
| import { locateScreenshot, appAutomateTmpDir } from './maestro-screenshot-file.js'; | ||
| import { locateScreenshot, appAutomateTmpDir, bsScopeRootOverride } from './maestro-screenshot-file.js'; | ||
| import { validateRegionInputs, resolveRegions } from './maestro-regions.js'; | ||
| import { deriveDeviceInsets } from './maestro-hierarchy.js'; | ||
|
|
||
|
|
@@ -91,13 +91,16 @@ export async function handleMaestroScreenshot(req, res, percy) { | |
|
|
||
| // Resolve the file-find scope root. On BrowserStack (sessionId present), the | ||
| // root is the BS host's {appAutomateTmpDir()}/{sessionId}{_test_suite} | ||
| // convention (PERCY_APP_AUTOMATE_TMP_DIR, defaulting to /tmp). Self-hosted | ||
| // convention (PERCY_APP_AUTOMATE_TMP_DIR, defaulting to /tmp), unless the host | ||
| // injected a complete root via PERCY_MAESTRO_BS_SCOPE_ROOT. Self-hosted | ||
| // (sessionId absent) requires PERCY_MAESTRO_SCREENSHOT_DIR (read from | ||
| // process.env, never the request body) to be an absolute, existing directory | ||
| // — typically the customer's `maestro test --test-output-dir <DIR>` path. The | ||
| // realpath + prefix check inside locateScreenshot enforces the security | ||
| // invariant at whichever root applies; the boundary is relocated, not removed. | ||
| let scopeRoot; | ||
| // Search the whole root — self-hosted, or a BS explicit root. | ||
| let recursiveScope = false; | ||
| if (selfHosted) { | ||
| // Reject filePath outright in self-hosted mode. The SDK never emits it (it | ||
| // sends a relative SCREENSHOT_NAME); honoring an absolute filePath against | ||
|
|
@@ -124,10 +127,20 @@ export async function handleMaestroScreenshot(req, res, percy) { | |
| throw new ServerError(400, `PERCY_MAESTRO_SCREENSHOT_DIR is not an existing directory: ${dir}`); | ||
| } | ||
| scopeRoot = dir; | ||
| recursiveScope = true; | ||
| } else { | ||
| scopeRoot = platform === 'ios' | ||
| ? `${appAutomateTmpDir()}/${sessionId}` | ||
| : `${appAutomateTmpDir()}/${sessionId}_test_suite`; | ||
| // No existence pre-check (unlike self-hosted): host config, not customer | ||
| // config, so a stale root should 404, not 400 at the customer. | ||
| let overrideRoot = bsScopeRootOverride(); | ||
| if (overrideRoot) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Override applies to Android too — worth documenting on the host side Design note, not a defect. The override is applied regardless of Suggestion: State in the BS-host runbook that this is set per-invocation for iOS sessions, not host-wide, unless Android's layout has also changed. Reviewer: stack-code-reviewer |
||
| scopeRoot = overrideRoot; | ||
| recursiveScope = true; | ||
| percy.log.debug(`maestro screenshot scope root overridden: ${scopeRoot}`); | ||
| } else { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] A rejected override falls through silently When Suggestion: Emit Reviewer: stack-code-reviewer |
||
| scopeRoot = platform === 'ios' | ||
| ? `${appAutomateTmpDir()}/${sessionId}` | ||
| : `${appAutomateTmpDir()}/${sessionId}_test_suite`; | ||
| } | ||
| } | ||
|
|
||
| // Validate regions input shape early (before file I/O and ADB work) so | ||
|
|
@@ -137,7 +150,7 @@ export async function handleMaestroScreenshot(req, res, percy) { | |
| // Locate the screenshot on disk (supplied filePath, BS session glob, or | ||
| // self-hosted PERCY_MAESTRO_SCREENSHOT_DIR recursive glob) and confirm it | ||
| // resolves under scopeRoot. Throws ServerError(404) when missing/out-of-root. | ||
| let realPath = await locateScreenshot({ platform, sessionId, name, filePath: suppliedFilePath, scopeRoot, selfHosted }); | ||
| let realPath = await locateScreenshot({ platform, sessionId, name, filePath: suppliedFilePath, scopeRoot, selfHosted, recursiveScope }); | ||
|
|
||
| // Read and base64-encode the screenshot | ||
| let fileContent = await fs.promises.readFile(realPath); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ import { logger, setupTest, fs } from './helpers/index.js'; | |
| import Percy from '@percy/core'; | ||
| import WebdriverUtils from '@percy/webdriver-utils'; | ||
| import { getPercyDomPath, _applyHttpReadOnlyStripping } from '../src/api.js'; | ||
| import { appAutomateTmpDir } from '../src/maestro-screenshot-file.js'; | ||
| import { appAutomateTmpDir, bsScopeRootOverride } from '../src/maestro-screenshot-file.js'; | ||
|
|
||
| describe('API Server', () => { | ||
| let percy; | ||
|
|
@@ -25,7 +25,7 @@ describe('API Server', () => { | |
| // suite (works in isolation; returns [] mid-suite), so route the | ||
| // self-hosted root to the REAL filesystem — this also tests the true | ||
| // production glob path. Only paths under this unique root are affected. | ||
| await setupTest({ filesystem: { $bypass: [p => typeof p === 'string' && (p.includes('percy-self-hosted-real') || p.includes('percy-bs-tmp-real'))] } }); | ||
| await setupTest({ filesystem: { $bypass: [p => typeof p === 'string' && (p.includes('percy-self-hosted-real') || p.includes('percy-bs-tmp-real') || p.includes('percy-bs-scope-'))] } }); | ||
|
|
||
| percy = new Percy({ | ||
| token: 'PERCY_TOKEN', | ||
|
|
@@ -1915,6 +1915,135 @@ describe('API Server', () => { | |
| }); | ||
| }); | ||
|
|
||
| // Mirrors the realmobile AAP-18965 shape: no sessionId segment and no | ||
| // <device>_ prefix, so no tmp-root value composes it. Real-fs root for the | ||
| // same fast-glob binding-staleness reason as above. | ||
| describe('PERCY_MAESTRO_BS_SCOPE_ROOT override', () => { | ||
| const SCOPE_ROOT = path.join(os.tmpdir(), 'percy-bs-scope-real-root'); | ||
| const REALMOBILE_DIR = path.join(SCOPE_ROOT, 'maestro_debug_LoginFlow_LoginFlow_0'); | ||
| let priorScope, priorTmp; | ||
|
|
||
| beforeEach(() => { | ||
| priorScope = process.env.PERCY_MAESTRO_BS_SCOPE_ROOT; | ||
| priorTmp = process.env.PERCY_APP_AUTOMATE_TMP_DIR; | ||
| process.env.PERCY_MAESTRO_BS_SCOPE_ROOT = SCOPE_ROOT; | ||
| fs.rmSync(SCOPE_ROOT, { recursive: true, force: true }); | ||
| fs.mkdirSync(REALMOBILE_DIR, { recursive: true }); | ||
| fs.writeFileSync(path.join(REALMOBILE_DIR, `${SS_NAME}.png`), 'PNGBYTES-SCOPE-ROOT'); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| if (priorScope === undefined) delete process.env.PERCY_MAESTRO_BS_SCOPE_ROOT; | ||
| else process.env.PERCY_MAESTRO_BS_SCOPE_ROOT = priorScope; | ||
| if (priorTmp === undefined) delete process.env.PERCY_APP_AUTOMATE_TMP_DIR; | ||
| else process.env.PERCY_APP_AUTOMATE_TMP_DIR = priorTmp; | ||
| fs.rmSync(SCOPE_ROOT, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('finds a screenshot the platform convention cannot reach', async () => { | ||
| spyOn(percy, 'upload').and.resolveTo(); | ||
| await percy.start(); | ||
|
|
||
| await expectAsync(postMaestro({ name: SS_NAME, sessionId: SID, platform: 'ios' })) | ||
| .toBeResolvedTo(jasmine.objectContaining({ success: true })); | ||
|
|
||
| let [payload] = percy.upload.calls.mostRecent().args; | ||
| expect(payload.tiles[0].content).toBe(Buffer.from('PNGBYTES-SCOPE-ROOT').toString('base64')); | ||
| }); | ||
|
|
||
| it('applies to android too — the root is the whole convention', async () => { | ||
| spyOn(percy, 'upload').and.resolveTo(); | ||
| await percy.start(); | ||
|
|
||
| await expectAsync(postMaestro({ name: SS_NAME, sessionId: SID, platform: 'android' })) | ||
| .toBeResolvedTo(jasmine.objectContaining({ success: true })); | ||
|
|
||
| let [payload] = percy.upload.calls.mostRecent().args; | ||
| expect(payload.tiles[0].content).toBe(Buffer.from('PNGBYTES-SCOPE-ROOT').toString('base64')); | ||
| }); | ||
|
|
||
| it('wins over PERCY_APP_AUTOMATE_TMP_DIR when both are set', async () => { | ||
| process.env.PERCY_APP_AUTOMATE_TMP_DIR = '/tmp'; | ||
| spyOn(percy, 'upload').and.resolveTo(); | ||
| await percy.start(); | ||
|
|
||
| await expectAsync(postMaestro({ name: SS_NAME, sessionId: SID, platform: 'ios' })) | ||
| .toBeResolvedTo(jasmine.objectContaining({ success: true })); | ||
|
|
||
| let [payload] = percy.upload.calls.mostRecent().args; | ||
| // Not the /tmp IOS_DIR fixture the composed convention would have hit | ||
| expect(payload.tiles[0].content).toBe(Buffer.from('PNGBYTES-SCOPE-ROOT').toString('base64')); | ||
| }); | ||
|
|
||
| it('tolerates a trailing slash on the override', async () => { | ||
| process.env.PERCY_MAESTRO_BS_SCOPE_ROOT = `${SCOPE_ROOT}/`; | ||
| spyOn(percy, 'upload').and.resolveTo(); | ||
| await percy.start(); | ||
|
|
||
| await expectAsync(postMaestro({ name: SS_NAME, sessionId: SID, platform: 'ios' })) | ||
| .toBeResolvedTo(jasmine.objectContaining({ success: true })); | ||
|
|
||
| let [payload] = percy.upload.calls.mostRecent().args; | ||
| expect(payload.tiles[0].content).toBe(Buffer.from('PNGBYTES-SCOPE-ROOT').toString('base64')); | ||
| }); | ||
|
|
||
| it('ignores a non-absolute override and keeps the composed convention', async () => { | ||
| process.env.PERCY_MAESTRO_BS_SCOPE_ROOT = 'relative/path'; | ||
| spyOn(percy, 'upload').and.resolveTo(); | ||
| await percy.start(); | ||
|
|
||
| await expectAsync(postMaestro({ name: SS_NAME, sessionId: SID, platform: 'android' })) | ||
| .toBeResolvedTo(jasmine.objectContaining({ success: true })); | ||
|
|
||
| let [payload] = percy.upload.calls.mostRecent().args; | ||
| // Fell through to the default /tmp glob, not a cwd-relative root | ||
| expect(payload.tiles[0].content).toBe(Buffer.from('PNGBYTES-ANDROID').toString('base64')); | ||
| }); | ||
|
|
||
| it('re-anchors filePath containment on the overridden root', async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] No symlink-escape test for the overridden scope root The self-hosted arm already has Suggestion: Add a spec here that places a symlink inside Reviewer: stack-code-reviewer |
||
| // In-root filePath resolves, out-of-root does not: boundary moved, not removed. | ||
| fs.writeFileSync(path.join(REALMOBILE_DIR, `${FILEPATH_NAME}.png`), 'PNGBYTES-SCOPE-FILEPATH'); | ||
| spyOn(percy, 'upload').and.resolveTo(); | ||
| await percy.start(); | ||
|
|
||
| await expectAsync(postMaestro({ | ||
| name: FILEPATH_NAME, | ||
| sessionId: SID, | ||
| platform: 'ios', | ||
| filePath: path.join(REALMOBILE_DIR, `${FILEPATH_NAME}.png`) | ||
| })).toBeResolvedTo(jasmine.objectContaining({ success: true })); | ||
|
|
||
| let [payload] = percy.upload.calls.mostRecent().args; | ||
| expect(payload.tiles[0].content).toBe(Buffer.from('PNGBYTES-SCOPE-FILEPATH').toString('base64')); | ||
|
|
||
| await expectAsync(postMaestro({ | ||
| name: FILEPATH_NAME, | ||
| sessionId: SID, | ||
| platform: 'ios', | ||
| filePath: `${IOS_FILEPATH_DIR}/${FILEPATH_NAME}.png` | ||
| })).toBeRejectedWithError(/Screenshot not found/); | ||
| }); | ||
|
|
||
| it('404s when the overridden root does not exist', async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] No multi-match mtime tie-break test for the override glob
Suggestion: Add a spec with two Reviewer: stack-code-reviewer |
||
| process.env.PERCY_MAESTRO_BS_SCOPE_ROOT = path.join(os.tmpdir(), 'percy-bs-scope-missing'); | ||
| await percy.start(); | ||
|
|
||
| await expectAsync(postMaestro({ name: SS_NAME, sessionId: SID, platform: 'ios' })) | ||
| .toBeRejectedWithError(/Screenshot not found/); | ||
| }); | ||
|
|
||
| it('pins the trim + null semantics of the exported helper', () => { | ||
| process.env.PERCY_MAESTRO_BS_SCOPE_ROOT = `${SCOPE_ROOT}///`; | ||
| expect(bsScopeRootOverride()).toBe(SCOPE_ROOT); | ||
| process.env.PERCY_MAESTRO_BS_SCOPE_ROOT = '/'; | ||
| expect(bsScopeRootOverride()).toBeNull(); | ||
| process.env.PERCY_MAESTRO_BS_SCOPE_ROOT = ''; | ||
| expect(bsScopeRootOverride()).toBeNull(); | ||
| delete process.env.PERCY_MAESTRO_BS_SCOPE_ROOT; | ||
| expect(bsScopeRootOverride()).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| // PNG-header fill: relay reads IHDR from the screenshot and populates | ||
| // payload.tag.width / payload.tag.height when missing. Source of truth | ||
| // for tag dims is the PNG bytes themselves — what Percy stores and | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Low] Rejection of root
/is incidental to the trim regex, not an explicit guard'/'is rejected only because the trailing-separator strip turns it into'', which then failspath.isAbsolute. The behaviour is pinned by a test, but the safety property — an override can never widen the root to the whole filesystem — is incidental in the code, so a future refactor of the trim could silently reintroduce it.Suggestion: Record the intent at the return, e.g.
// '/' trims to '' and is rejected here — intentional; prevents an override that would recurse the whole filesystem.Reviewer: stack-code-reviewer