Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ jobs:
nuts:
needs: linux-unit-tests
uses: salesforcecli/github-workflows/.github/workflows/nut.yml@main
secrets: inherit
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
fail-fast: false
with:
os: ${{ matrix.os }}
command: yarn test:nuts
retries: 5
secrets: inherit
30 changes: 30 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,36 @@
"smartStep": true,
"internalConsoleOptions": "openOnSessionStart",
"preLaunchTask": "Compile tests"
},
{
"name": "Run Nuts Test",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": [
"--inspect-brk",
"--no-deprecation",
"--no-warnings",
"-r",
"dotenv/config",
"--loader",
"ts-node/esm",
"--loader",
"esmock"
],
"program": "${workspaceFolder}/node_modules/mocha/lib/cli/cli.js",
"args": ["${file}", "--slow", "4500", "--timeout", "600000"],
"cwd": "${workspaceFolder}",
"env": {
"NODE_ENV": "development",
"SFDX_ENV": "development",
"TS_NODE_PROJECT": "test/tsconfig.json"
},
"sourceMaps": true,
"skipFiles": ["<node_internals>/**"],
"internalConsoleOptions": "openOnSessionStart",
"console": "integratedTerminal",
"preLaunchTask": "Compile plugin only"
}
]
}
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,53 @@ yarn && yarn build
yarn update-snapshots
```

## e2e Org configuration (for NUTs)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ngl, NUT is a weird way to call integration tests

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look on the bright side. There are way more dumb jokes to be made about NUTs than there are about e2e!


If a new org is required for NUTs tests, these are the steps to create and configure one.

1. Create a new STM org in [Org Farm](https://orgfarm.salesforce.com/farms)
2. [Enable DevHub for Scratch Org Creation](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_setup_enable_devhub.htm)
3. Enable the following org perm: CreateConnectedApps
4. Increase the following org limits to 99: ProvScratchActiveLimit, ProvScratchDailyLimit, ScratchRequestActiveCount, ScratchRequestActiveLimit, ScratchRequestDailyLimit
5. [Create a Connected App](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_connected_app.htm)
6. Enable JWT authentication and [test it](https://developer.salesforce.com/docs/atlas.en-us.260.0.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference_org_commands_unified.htm#cli_reference_org_login_jwt_unified)
7. Use the credentials as values for the respective NUTS environment variables.

## Running NUTs (integration tests) locally

NUTs (e2e integration tests) run the plugin against a real org and, for component-preview tests, a real browser (Playwright). To run them locally:

1. **Environment variables**
Copy `.env.template` to `.env` and set the values for your Dev Hub org and test setup:
- `TESTKIT_JWT_KEY` - ./server.key from JWT configuration
- `TESTKIT_JWT_CLIENT_ID` – Client id from JWT configuration
- `TESTKIT_HUB_USERNAME` - Dev Hub username
- `TESTKIT_HUB_INSTANCE` – Dev Hub login URL

2. **Run all NUTs** (loads variables from `.env` via `dotenv/config`):

```bash
yarn test:nuts:local
```

3. **Run a single NUT file** (e.g. one test file):

```bash
yarn test:nut:local path/to/file.nut.ts
```

4. **Run with a visible browser** (headed mode) for debugging:

```bash
HEADED=true yarn test:nuts:local
```

or, for a single file:

```bash
HEADED=true yarn test:nut:local path/to/file.nut.ts
```

## Commands

<!-- commands -->
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"@types/node-fetch": "^2.6.13",
"@types/xml2js": "^0.4.14",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@playwright/test": "^1.49.0",
"playwright": "^1.49.0",
"dotenv": "^16.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.2",
Expand Down Expand Up @@ -99,11 +101,14 @@
"link-check": "wireit",
"link-lwr": "yarn link @lwrjs/api @lwrjs/app-service @lwrjs/asset-registry @lwrjs/asset-transformer @lwrjs/auth-middleware @lwrjs/base-view-provider @lwrjs/base-view-transformer @lwrjs/client-modules @lwrjs/config @lwrjs/core @lwrjs/dev-proxy-server @lwrjs/diagnostics @lwrjs/esbuild @lwrjs/everywhere @lwrjs/fs-asset-provider @lwrjs/fs-watch @lwrjs/html-view-provider @lwrjs/instrumentation @lwrjs/label-module-provider @lwrjs/lambda @lwrjs/legacy-npm-module-provider @lwrjs/loader @lwrjs/lwc-module-provider @lwrjs/lwc-ssr @lwrjs/markdown-view-provider @lwrjs/module-bundler @lwrjs/module-registry @lwrjs/npm-module-provider @lwrjs/nunjucks-view-provider @lwrjs/o11y @lwrjs/resource-registry @lwrjs/router @lwrjs/security @lwrjs/server @lwrjs/shared-utils @lwrjs/static @lwrjs/tools @lwrjs/types @lwrjs/view-registry lwr",
"lint": "wireit",
"postinstall": "npx playwright install --with-deps",
"postpack": "sf-clean --ignore-signing-artifacts",
"prepack": "sf-prepack",
"prepare": "sf-install",
"test": "wireit",
"test:nuts": "nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 600000 --parallel",
"test:nuts": "mocha \"**/*.nut.ts\" --slow 30000 --timeout 600000 --parallel=false",
"test:nuts:local": "node -r dotenv/config ./node_modules/.bin/nyc mocha \"**/*.nut.ts\" --slow 30000 --timeout 600000 --parallel=false",
"test:nut:local": "node -r dotenv/config ./node_modules/.bin/nyc mocha --slow 30000 --timeout 600000",
"test:only": "wireit",
"unlink-lwr": "yarn unlink @lwrjs/api @lwrjs/app-service @lwrjs/asset-registry @lwrjs/asset-transformer @lwrjs/auth-middleware @lwrjs/base-view-provider @lwrjs/base-view-transformer @lwrjs/client-modules @lwrjs/config @lwrjs/core @lwrjs/dev-proxy-server @lwrjs/diagnostics @lwrjs/esbuild @lwrjs/everywhere @lwrjs/fs-asset-provider @lwrjs/fs-watch @lwrjs/html-view-provider @lwrjs/instrumentation @lwrjs/label-module-provider @lwrjs/lambda @lwrjs/legacy-npm-module-provider @lwrjs/loader @lwrjs/lwc-module-provider @lwrjs/lwc-ssr @lwrjs/markdown-view-provider @lwrjs/module-bundler @lwrjs/module-registry @lwrjs/npm-module-provider @lwrjs/nunjucks-view-provider @lwrjs/o11y @lwrjs/resource-registry @lwrjs/router @lwrjs/security @lwrjs/server @lwrjs/shared-utils @lwrjs/static @lwrjs/tools @lwrjs/types @lwrjs/view-registry lwr",
"update-snapshots": "node --loader ts-node/esm --no-warnings=ExperimentalWarning \"./bin/dev.js\" snapshot:generate",
Expand Down
5 changes: 5 additions & 0 deletions src/commands/lightning/dev/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ export default class LightningDevComponent extends SfCommand<ComponentPreviewRes
await this.config.runCommand('org:open', launchArguments);
}

// Emit preview URL for tests (e.g. NUTs that drive Playwright against the preview page)
if (process.env.LIGHTNING_DEV_PRINT_PREVIEW_URL === 'true') {
this.log(previewUrl);
}

return result;
}
}
39 changes: 0 additions & 39 deletions test/commands/lightning/dev/app.nut.ts

This file was deleted.

104 changes: 104 additions & 0 deletions test/commands/lightning/dev/component-preview/browserMenu.nut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright 2026, Salesforce, Inc.
*
* Licensed 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 type { ChildProcessByStdio } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { TestSession } from '@salesforce/cli-plugins-testkit';
import { expect } from 'chai';
import { type Browser, type Page } from 'playwright';
import { getSession } from '../helpers/sessionUtils.js';
import { startLightningDevServer, getPreviewURL, killServerProcess } from '../helpers/devServerUtils.js';
import { getPreview } from '../helpers/browserUtils.js';

const COMPONENT_NAME = 'helloWorld';
const INITIAL_GREETING = 'Hello World';
const STATIC_CONTENT = 'Static Content';

describe('lightning preview menu', () => {
let session: TestSession;
let childProcess: ChildProcessByStdio<Writable, Readable, Readable> | undefined;
let browser: Browser;
let page: Page;

beforeEach(async () => {
session = await getSession();
childProcess = startLightningDevServer(session, { AUTO_ENABLE_LOCAL_DEV: 'true' }, COMPONENT_NAME);
const previewUrl = await getPreviewURL(childProcess.stdout);
({ browser, page } = await getPreview(previewUrl, session));
});

afterEach(async () => {
if (page) await page.close();
if (browser) await browser.close();
killServerProcess(childProcess);
});

it('should render select link and hamburger menu with helloWorld available and clickable', async () => {
const greetingLocator = page.getByText(INITIAL_GREETING);
await greetingLocator.waitFor({ state: 'visible' });

// When a component is already selected (e.g. --name helloWorld), the canvas shows the component,
// not the "Select a component..." link. Open the hamburger to verify the panel and helloWorld.
const menuToggle = page.getByRole('link', { name: 'Toggle menu' });
await menuToggle.waitFor({ state: 'visible' });
await menuToggle.scrollIntoViewIfNeeded();
await menuToggle.click({ force: true });

// Hamburger opens lwr_dev-component-panel (slide-in panel)
const componentPanel = page.locator('lwr_dev-component-panel >> .lwr-dev-component-panel__panel--visible');
await componentPanel.waitFor({ state: 'visible' });

const staticItem = page.locator(
'lwr_dev-component-panel >> .lwr-dev-component-panel__item[data-specifier="c/static"]',
);
await staticItem.waitFor({ state: 'visible' });
await staticItem.click();

// Wait for the app to load the selected component (URL updates with specifier)
await page.waitForURL(/specifier=c%2Fstatic|c\/static/, { timeout: 15_000 });

const staticContentLocator = page.getByText(STATIC_CONTENT);
await staticContentLocator.waitFor({ state: 'visible', timeout: 15_000 });
expect(await staticContentLocator.textContent()).to.include(STATIC_CONTENT);
});

it('should render component in performance mode when performance mode button is clicked', async () => {
const greetingLocator = page.getByText(INITIAL_GREETING);
await greetingLocator.waitFor({ state: 'visible' });

const performanceLink = page.locator(
'lwr_dev-preview-application >> lwr_dev-preview-header >> .lwr-dev-preview-header__performance-mode-link',
);
await performanceLink.waitFor({ state: 'visible' });
await performanceLink.click();

await page.waitForURL(/mode=performance/);
expect(page.url()).to.include('mode=performance');

const header = page.locator(
'lwr_dev-preview-application >> lwr_dev-preview-header >> .lwr-dev-preview-header__header',
);
expect(await header.first().isHidden()).to.be.true;

const performanceLinkAfter = page.locator(
'lwr_dev-preview-application >> lwr_dev-preview-header >> .lwr-dev-preview-header__performance-mode-link',
);
expect(await performanceLinkAfter.first().isHidden()).to.be.true;

await greetingLocator.waitFor({ state: 'visible' });
expect(await greetingLocator.textContent()).to.equal(INITIAL_GREETING);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2026, Salesforce, Inc.
*
* Licensed 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 type { ChildProcessByStdio } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { TestSession } from '@salesforce/cli-plugins-testkit';
import { expect } from 'chai';
import { type Browser, type Page } from 'playwright';
import { getSession } from '../helpers/sessionUtils.js';
import { startLightningDevServer, getPreviewURL, killServerProcess } from '../helpers/devServerUtils.js';
import { getPreview } from '../helpers/browserUtils.js';

const COMPONENT_NAME = 'withError';
const ERROR_MESSAGE = 'Component generated error';

/** Locator for error message text (class from LWR error display / lwr_dev/errorDisplay) */
const errorMessageEl = (p: Page) => p.locator('.error-message-text');

describe('lightning preview component error', () => {
let session: TestSession;
let childProcess: ChildProcessByStdio<Writable, Readable, Readable> | undefined;
let browser: Browser;
let page: Page;

before(async () => {
session = await getSession();
childProcess = startLightningDevServer(session, { AUTO_ENABLE_LOCAL_DEV: 'true' }, COMPONENT_NAME);
const previewUrl = await getPreviewURL(childProcess.stdout);
({ browser, page } = await getPreview(previewUrl, session));
});

after(async () => {
if (page) await page.close();
if (browser) await browser.close();
killServerProcess(childProcess);
});

it('should render the error component and display the error modal', async () => {
const message = errorMessageEl(page);
await message.waitFor({ state: 'visible', timeout: 15_000 });
expect(await message.textContent()).to.include(ERROR_MESSAGE);
});

it('should display the error modal and close it when the dismiss button is clicked', async () => {
const message = errorMessageEl(page);
await message.waitFor({ state: 'visible', timeout: 15_000 });

const dismissButton = page.getByRole('button', { name: /dismiss/i });
await dismissButton.waitFor({ state: 'visible' });
await dismissButton.click();

await message.waitFor({ state: 'hidden', timeout: 10_000 });
expect(await message.isHidden()).to.be.true;
});

it('should copy the error text to the clipboard when copy is clicked', async () => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.reload({ waitUntil: 'load' });

const message = errorMessageEl(page);
await message.waitFor({ state: 'visible', timeout: 15_000 });

const copyButton = page.getByRole('button', { name: /copy/i });
await copyButton.waitFor({ state: 'visible' });
await copyButton.click();

const clipboardText = await page.evaluate('navigator.clipboard.readText()');
expect(clipboardText).to.include(ERROR_MESSAGE);
});
});
Loading
Loading