diff --git a/buildspec/linuxE2ETests.yml b/buildspec/linuxE2ETests.yml index 4cb2f08f03d..d10c9b92844 100644 --- a/buildspec/linuxE2ETests.yml +++ b/buildspec/linuxE2ETests.yml @@ -5,6 +5,7 @@ run-as: codebuild-user env: variables: + VSCODE_TEST_VERSION: '1.83.0' AWS_TOOLKIT_TEST_NO_COLOR: '1' # Suppress noisy apt-get/dpkg warnings like "debconf: unable to initialize frontend: Dialog"). DEBIAN_FRONTEND: 'noninteractive' diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 799781a22f1..f7b82d5ce84 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -59,7 +59,7 @@ "watch": "npm run clean && npm run buildScripts && tsc -watch -p ./", "testCompile": "npm run clean && npm run buildScripts && npm run compileOnly", "test": "npm run testCompile && c8 --allowExternal ts-node ../core/scripts/test/launchTest.ts unit dist/test/unit/index.js ../core/dist/src/testFixtures/workspaceFolder", - "testE2E": "npm run testCompile && c8 --allowExternal ts-node ../core/scripts/test/launchTest.ts e2e dist/test/e2e/index.js ../core/dist/src/testFixtures/workspaceFolder", + "testE2E-skip-for-testing": "npm run testCompile && c8 --allowExternal ts-node ../core/scripts/test/launchTest.ts e2e dist/test/e2e/index.js ../core/dist/src/testFixtures/workspaceFolder", "testWeb": "npm run compileDev && c8 --allowExternal ts-node ../core/scripts/test/launchTest.ts web dist/test/web/testRunnerWebCore.js", "webRun": "npx @vscode/test-web --open-devtools --browserOption=--disable-web-security --waitForDebugger=9222 --extensionDevelopmentPath=. .", "webWatch": "npm run clean && npm run buildScripts && webpack --mode development --watch", diff --git a/packages/core/package.json b/packages/core/package.json index 71286bdb10d..c1d98ce9d2c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -493,7 +493,8 @@ "lint": "node --max-old-space-size=8192 -r ts-node/register ./scripts/lint/testLint.ts", "generateClients": "ts-node ./scripts/build/generateServiceClient.ts ", "generateIcons": "ts-node ../../scripts/generateIcons.ts", - "generateTelemetry": "node ../../node_modules/@aws-toolkits/telemetry/lib/generateTelemetry.js --extraInput=src/shared/telemetry/vscodeTelemetry.json --output=src/shared/telemetry/telemetry.gen.ts" + "generateTelemetry": "node ../../node_modules/@aws-toolkits/telemetry/lib/generateTelemetry.js --extraInput=src/shared/telemetry/vscodeTelemetry.json --output=src/shared/telemetry/telemetry.gen.ts", + "testE2E": "npm run compileOnly && extest setup-and-run './dist/src/testE2EUI/**/*.test.js' --code_version max --code_settings settings.json" }, "scriptsComments": { "compile": "Builds EVERYTHING we would need for a final production-ready package", @@ -556,8 +557,9 @@ "sinon": "^14.0.0", "style-loader": "^3.3.1", "ts-node": "^10.9.1", - "typescript": "^5.0.4", + "typescript": "5.9.2", "umd-compat-loader": "^2.1.2", + "vscode-extension-tester": "^8.9.0", "vue-loader": "^17.2.2", "vue-style-loader": "^4.1.3", "webfont": "^11.2.26" diff --git a/packages/core/src/testE2E/sagemakerunifiedstudio/basic.test.ts b/packages/core/src/testE2E/sagemakerunifiedstudio/basic.test.ts new file mode 100644 index 00000000000..4763257e22d --- /dev/null +++ b/packages/core/src/testE2E/sagemakerunifiedstudio/basic.test.ts @@ -0,0 +1,217 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { chromium, Browser } from 'playwright' +import { getLogger } from '../../shared/logger' +import * as assert from 'assert' +import { getSmusCredentials } from './getAuthCredentials' + +describe('SageMaker Unified Studio', function () { + this.timeout(180000) + + let browser: Browser + const logger = getLogger() + + before(async function () { + browser = await chromium.launch({ headless: false }) + // 正确初始化VSBrowser - 需要 + // 在VS Code扩展测试环境中运行 + }) + + after(async function () { + if (browser) { + await browser.close() + } + }) + + it('should get SSO token from SageMaker Unified Studio', async function () { + const page = await browser.newPage() + console.log('in test1:') + + // Listen for SSO token and refresh token requests + let ssoToken = undefined + let refreshToken = undefined + page.on('response', async (response) => { + const url = response.url() + console.log('Response URL:', url) + + if (url.includes('portal.sso.us-west-2.amazonaws.com/auth/sso-token')) { + console.log('Found SSO token endpoint!') + try { + const body = await response.text() + console.log('SSO Token Response:', body) + ssoToken = body + } catch (error) { + console.log('Error reading SSO token response:', error) + } + } + if (url.includes('refresh-token') || url.includes('refresh_token')) { + console.log('Found refresh token endpoint!') + try { + const body = await response.text() + console.log('Refresh Token Response:', body) + refreshToken = body + } catch (error) { + console.log('Error reading refresh token response:', error) + } + } + }) + + logger.info('in test1') + // Navigate to SageMaker Unified Studio login page + await page.goto('https://dzd_5hknkem8c5x2a8.sagemaker.us-west-2.on.aws/login') + // Verify SSO button exists + const ssoButton = page.locator('text=Sign in with SSO') + await ssoButton.waitFor({ state: 'visible' }) + + // Click "Sign in with SSO" button + await ssoButton.click() + + // Wait for navigation to SSO page + await page.waitForLoadState('networkidle') + const currentUrl = page.url() + // Assert that we've been redirected away from the login page + assert.notStrictEqual( + currentUrl, + 'https://dzd_5hknkem8c5x2a8.sagemaker.us-west-2.on.aws/login', + 'Should redirect to SSO page' + ) + + // Assert that URL contains expected SSO domain patterns + assert.ok( + currentUrl.includes('sso') || currentUrl.includes('auth') || currentUrl.includes('login'), + `URL should contain SSO/auth pattern: ${currentUrl}` + ) + + // Fill username and click Next + await page.fill('input[type="text"]', 'wukeyu') + await page.click('button:has-text("Next")') + await page.waitForLoadState('networkidle') + + // Fill password and click Sign in + await page.fill('input[type="password"]', 'Wky19980612!') + await page.click('button:has-text("Sign in")') + await page.waitForLoadState('networkidle') + + // Wait 5 seconds to see if login was successful + await page.waitForTimeout(5000) + + // Log the captured tokens + if (ssoToken) { + console.log('Captured SSO Token:', ssoToken) + + try { + const tokenData = JSON.parse(ssoToken) + console.log('Parsed token data:', tokenData) + + if (tokenData.token) { + console.log('Raw JWE token:', tokenData.token) + + // Extract token parts (JWE has 5 parts separated by dots) + const tokenParts = tokenData.token.split('.') + console.log('Token parts count:', tokenParts.length) + if (tokenParts.length >= 2) { + // Decode the header (first part) + try { + const header = JSON.parse(Buffer.from(tokenParts[0], 'base64url').toString()) + console.log('Token header:', header) + } catch (e) { + console.log('Could not decode token header') + } + } + } + if (tokenData.redirectUrl) { + console.log('Redirect URL:', tokenData.redirectUrl) + } + } catch (error) { + console.log('Error parsing token response:', error) + } + } else { + console.log('No SSO token found in requests') + } + + if (refreshToken) { + console.log('Captured Refresh Token:', refreshToken) + try { + const refreshData = JSON.parse(refreshToken) + console.log('Parsed refresh token data:', refreshData) + // Store captured tokens in global state for SMUS authentication + ;(global as any).capturedSmusTokens = { + accessToken: refreshData.accessToken, + csrfToken: refreshData.csrfToken, + iamCreds: refreshData.iamCreds, + userProfile: refreshData.userProfile, + } + + console.log('SMUS tokens stored globally for authentication provider') + } catch (error) { + console.log('Error parsing refresh token response:', error) + } + } else { + console.log('No refresh token found in requests') + } + + await page.close() + }) + + it.skip('should use stored credentials in VS Code to skip SMUS login', async function () { + // const capturedTokens = (global as any).capturedSmusTokens + // if (!capturedTokens) { + // throw new Error('No SMUS tokens found. Run the login test first.') + // } + // const domainUrl = 'https://dzd_5hknkem8c5x2a8.sagemaker.us-west-2.on.aws' + // const { Auth } = await import('../../auth/auth.js') + // const { scopeSmus } = await import('../../sagemakerunifiedstudio/auth/model.js') + // const { SmusAuthenticationProvider } = await import( + // '../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider.js' + // ) + // const { randomUUID } = await import('crypto') + // const { using } = await import('../../test/setupUtil.js') + // await using(async () => { + // // Get SMUS auth provider + // const smusAuth = SmusAuthenticationProvider.fromContext() + // // Mock the getAccessToken method to return our captured token + // const originalGetAccessToken = smusAuth.getAccessToken.bind(smusAuth) + // smusAuth.getAccessToken = async () => capturedTokens.accessToken + // return () => { + // // Cleanup: restore original method + // smusAuth.getAccessToken = originalGetAccessToken + // } + // }, async () => { + // // Get SMUS auth provider + // const smusAuth = SmusAuthenticationProvider.fromContext() + // // Use the real connectToSmus method + // const connection = await smusAuth.connectToSmus(domainUrl) + // console.log('Connected to SMUS:', connection.id) + // // Open AWS Explorer view + // await vscode.commands.executeCommand('workbench.view.extension.aws-explorer') + // await new Promise((resolve) => setTimeout(resolve, 3000)) + // // Open SMUS project view + // await vscode.commands.executeCommand('aws.smus.projectView') + // console.log('Opened SMUS project view in VS Code') + // // Wait for user to confirm the view is open + // await new Promise((resolve) => setTimeout(resolve, 10000)) + // console.log('Waited 10 seconds for confirmation') + // }) + }) + + it('should connect to SMUS using real SSO flow', async function () { + console.log('test3: calling getSmusCredentials') + try { + const credentials = await getSmusCredentials() + console.log('SMUS credentials obtained successfully:', { + hasTokens: Object.keys(credentials.tokens).length > 0, + hasXsrf: !!credentials.xsrf, + hasPresignedUrl: !!credentials.presignedUrl, + hasCredentials: !!credentials.credentials, + }) + } catch (error) { + console.error('Failed to get SMUS credentials:', error) + } + }) + + it('should use skip login step and get into home page', async function () { + console.log('TODO') + }) +}) diff --git a/packages/core/src/testE2E/sagemakerunifiedstudio/getAuthCredentials.ts b/packages/core/src/testE2E/sagemakerunifiedstudio/getAuthCredentials.ts new file mode 100644 index 00000000000..b0d76bca0d9 --- /dev/null +++ b/packages/core/src/testE2E/sagemakerunifiedstudio/getAuthCredentials.ts @@ -0,0 +1,251 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts' +import { + DataZoneClient, + ListEnvironmentBlueprintsCommand, + ListEnvironmentsCommand, + GetEnvironmentCredentialsCommand, +} from '@aws-sdk/client-datazone' +import { SageMakerClient, CreatePresignedDomainUrlCommand } from '@aws-sdk/client-sagemaker' +import fetch from 'cross-fetch' + +const REGION = 'us-west-2' +const domainId = 'dzd_5hknkem8c5x2a8' +const projectId = 'bhzh72l5rwvqq8' +const userId = '68117300-f051-70c3-9d2e-62f9548c2c62' + +// 1. assumeRole with tags +async function getTaggedAssumeRoleCredentials(roleArn: string, sessionName: string) { + try { + console.log('Creating STS client for region:', REGION) + const sts = new STSClient({ region: REGION }) + + console.log('AssumeRole parameters:', { + RoleArn: roleArn, + RoleSessionName: sessionName, + Tags: [ + { Key: 'datazone-domainId', Value: domainId }, + { Key: 'datazone-userId', Value: userId }, + ], + }) + + const cmd = new AssumeRoleCommand({ + RoleArn: roleArn, + RoleSessionName: sessionName, + Tags: [ + { Key: 'datazone-domainId', Value: domainId }, + { Key: 'datazone-userId', Value: userId }, + ], + }) + + console.log('Sending AssumeRole command...') + const res = await sts.send(cmd) + + if (!res.Credentials) { + throw new Error('No credentials returned from AssumeRole') + } + + console.log('AssumeRole successful, credentials received') + return res.Credentials + } catch (error) { + console.error('AssumeRole failed:', error) + throw error + } +} + +// 2. find default tooling environment +function findDefaultToolingEnvironment(environments: any[]) { + const filtered = environments.filter((env) => (env as any).deploymentOrder !== undefined) + if (filtered.length === 0) { + return undefined + } + return filtered.reduce((a, b) => ((a as any).deploymentOrder < (b as any).deploymentOrder ? a : b)) +} + +async function getProjectDefaultToolingEnvironment(datazone: DataZoneClient) { + console.log('Listing environment blueprints...') + const blueprintsRes = await datazone.send( + new ListEnvironmentBlueprintsCommand({ + domainIdentifier: domainId, + managed: true, + name: 'Tooling', + }) + ) + const toolingBlueprints = blueprintsRes.items ?? [] + console.log('Found tooling blueprints:', toolingBlueprints.length) + console.log( + 'Blueprint names:', + toolingBlueprints.map((bp) => bp.name) + ) + + if (toolingBlueprints.length === 0) { + throw new Error('Tooling environment blueprint not found') + } + + const toolingEnvBlueprint = toolingBlueprints.find((bp) => bp.name === 'Tooling') + if (!toolingEnvBlueprint) { + throw new Error('Tooling blueprint not found') + } + console.log('Using tooling blueprint:', toolingEnvBlueprint.id) + + console.log('Listing environments for project...') + const envsRes = await datazone.send( + new ListEnvironmentsCommand({ + domainIdentifier: domainId, + projectIdentifier: projectId, + environmentBlueprintIdentifier: toolingEnvBlueprint.id, + }) + ) + + const toolingEnvs = envsRes.items ?? [] + console.log('Found tooling environments:', toolingEnvs.length) + console.log( + 'Environment details:', + toolingEnvs.map((env) => ({ + id: env.id, + name: env.name, + status: env.status, + })) + ) + + const defaultEnv = findDefaultToolingEnvironment(toolingEnvs) + if (defaultEnv) { + console.log('Selected default environment:', defaultEnv.id) + return defaultEnv + } + + throw new Error('Default Tooling environment not found') +} + +// 3. get SageMaker AI DomainId from provisioned resources +function getSagemakerAiDomainId(toolingEnv: any) { + const res = toolingEnv.provisionedResources.find((r: any) => r.name === 'SageMakerSpacesDomain') + if (!res) { + throw new Error('SageMakerSpacesDomain not found') + } + return res.value +} + +// 4. parse studio tokens +function parseStudioTokens(setCookie: string | undefined) { + if (!setCookie) { + return {} + } + const tokens: Record = {} + const regex = /(StudioAuthToken[01])=([^;]+)/g + let match + while ((match = regex.exec(setCookie)) !== null) { + tokens[match[1]] = match[2] + } + return tokens +} + +export async function getSmusCredentials() { + console.log('Starting SMUS credentials retrieval...') + try { + // Step 1: assume role + console.log('Step 1: Assuming role...') + const creds = await getTaggedAssumeRoleCredentials( + 'arn:aws:iam::099100562013:role/service-role/AmazonSageMakerDomainExecution', + `user-${userId}` + ) + console.log('AssumeRole credentials obtained:', creds) + // Step 2: init datazone client + console.log('Step 2: Initializing DataZone client...') + const datazone = new DataZoneClient({ + region: REGION, + credentials: { + accessKeyId: creds.AccessKeyId!, + secretAccessKey: creds.SecretAccessKey!, + sessionToken: creds.SessionToken, + }, + }) + console.log('DataZone client initialized') + + console.log('Getting project default tooling environment...') + const toolingEnv = await getProjectDefaultToolingEnvironment(datazone) + console.log('Tooling environment found:', toolingEnv.id) + + console.log('Getting SageMaker AI Domain ID...') + const smAIDomainId = getSagemakerAiDomainId(toolingEnv) + console.log('SageMaker AI Domain ID:', smAIDomainId) + + // Step 3: get env credentials + console.log('Step 3: Getting environment credentials...') + const envCredsRes = await datazone.send( + new GetEnvironmentCredentialsCommand({ + domainIdentifier: domainId, + environmentIdentifier: toolingEnv.id, + }) + ) + console.log('Environment credentials response keys:', Object.keys(envCredsRes)) + + const envCreds = envCredsRes + if (!envCreds) { + throw new Error('No environment credentials returned') + } + console.log('Environment credentials obtained') + + if (!envCreds.accessKeyId || !envCreds.secretAccessKey || !envCreds.sessionToken) { + console.log('Missing credential fields:', { + hasAccessKeyId: !!envCreds.accessKeyId, + hasSecretAccessKey: !!envCreds.secretAccessKey, + hasSessionToken: !!envCreds.sessionToken, + }) + throw new Error('Invalid environment credentials') + } + + console.log('Initializing SageMaker client...') + const sagemaker = new SageMakerClient({ + region: REGION, + credentials: { + accessKeyId: envCreds.accessKeyId, + secretAccessKey: envCreds.secretAccessKey, + sessionToken: envCreds.sessionToken, + }, + }) + console.log('SageMaker client initialized') + + // Step 4: presigned URL + console.log('Step 4: Creating presigned domain URL...') + const urlRes = await sagemaker.send( + new CreatePresignedDomainUrlCommand({ + DomainId: smAIDomainId, + UserProfileName: userId, + SpaceName: `default-${userId}`, + LandingUri: 'app:JupyterLab:lab/tree/src/', + }) + ) + const presignedUrl = urlRes.AuthorizedUrl! + console.log('Presigned URL created:', presignedUrl.substring(0, 50) + '...') + + // Step 5: request and parse cookies + console.log('Step 5: Fetching presigned URL to get cookies...') + const response = await fetch(presignedUrl, { redirect: 'manual' }) + console.log('HTTP response status:', response.status) + + const setCookieHeader = response.headers.get('set-cookie') || '' + console.log('Set-Cookie header length:', setCookieHeader.length) + + const xsrf = /_xsrf=([^;]+)/.exec(setCookieHeader)?.[1] + console.log('XSRF token found:', !!xsrf) + + const tokens = parseStudioTokens(setCookieHeader) + console.log('Studio tokens found:', Object.keys(tokens)) + + console.log('SMUS credentials retrieval completed successfully') + return { + tokens, + xsrf, + presignedUrl, + credentials: envCreds, + } + } catch (error) { + console.error('Error in getSmusCredentials:', error) + throw error + } +} diff --git a/packages/core/src/testE2EUI/.mocharc-debug.js b/packages/core/src/testE2EUI/.mocharc-debug.js new file mode 100644 index 00000000000..86a27331619 --- /dev/null +++ b/packages/core/src/testE2EUI/.mocharc-debug.js @@ -0,0 +1,3 @@ +module.exports = { + timeout: 99999999, +} diff --git a/packages/core/src/testE2EUI/sagemakerunifiedstudio/UItest.test.ts b/packages/core/src/testE2EUI/sagemakerunifiedstudio/UItest.test.ts new file mode 100644 index 00000000000..e273740d9f8 --- /dev/null +++ b/packages/core/src/testE2EUI/sagemakerunifiedstudio/UItest.test.ts @@ -0,0 +1,64 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import { ActivityBar } from 'vscode-extension-tester' + +// sample tests using the Activity Bar (the left toolbar) +describe('Activity Bar Example Tests', () => { + let activityBar: ActivityBar + + before(async function () { + this.timeout(30000) + activityBar = new ActivityBar() + }) + + // Test what view controls are available + it('Shows explorer view control (container)', async () => { + // get all the view controls + const controls = await activityBar.getViewControls() + assert.ok(controls.length > 0) + + // get titles from the controls + const titles = await Promise.all( + controls.map(async (control) => { + return control.getTitle() + }) + ) + + // assert a view control named 'Explorer' is present + // the keyboard shortcut is part of the title, so we do a little transformation + assert.ok(titles.some((title) => title.startsWith('Explorer'))) + }) + + // Opening a view by title + it('Get a view control and open its associated view', async () => { + // retrieving a view control by title does not require the keyboard shortcut to be part of the argument + // if the given control exists, it will be returned, otherwise it is undefined + const ctrl = await activityBar.getViewControl('Explorer') + + // click the given control to open its view (using optional notation since it can be undefined) + const view = await ctrl?.openView() + + // assert the view is open + assert.ok(view !== undefined) + assert.ok(await view?.isDisplayed()) + }) + + // NOTE: This will be working only for testing with VS Code 1.101+ + + // Using the global actions controls (the ones on the bottom of the activity bar) + // This test uses context menus, which are not available on mac, so we skip it there + it('Manipulate the Global Actions', async () => { + // get a global action control analogically to view controls + const manage = await activityBar.getGlobalAction('Manage') + + // actions open a context menu on click + const menu = await manage?.openActionMenu() + + // lets just close the menu for now + await menu?.close() + }) +}) diff --git a/packages/toolkit/test/e2e/index.ts b/packages/toolkit/test/e2e/index.ts index e6c624889c6..fbbfb801196 100644 --- a/packages/toolkit/test/e2e/index.ts +++ b/packages/toolkit/test/e2e/index.ts @@ -8,7 +8,7 @@ import { VSCODE_EXTENSION_ID } from 'aws-core-vscode/utils' export function run(): Promise { return runTests( - process.env.TEST_DIR ?? ['test/e2e', '../../core/dist/src/testE2E'], + process.env.TEST_DIR ?? ['test/e2e', '../../core/dist/src/testE2E/sagemakerunifiedstudio'], VSCODE_EXTENSION_ID.awstoolkit, ['../../core/dist/src/testInteg/globalSetup.test.ts'] )