Skip to content

Commit 9ba6a44

Browse files
committed
Move Workbench project prefex to cypress config
1 parent 3b2c43d commit 9ba6a44

File tree

2 files changed

+381
-0
lines changed

2 files changed

+381
-0
lines changed

test/e2e/cypress.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ module.exports = defineConfig({
6161
CONNECT_MANAGER_URL: "http://localhost:4723",
6262
CONNECT_CLOUD_ENV: process.env.CONNECT_CLOUD_ENV || "staging",
6363
WORKBENCH_URL: "http://localhost:8787",
64+
WORKBENCH_PROJECT_PREFIX: "workbench-e2e-",
6465
pccConfig,
6566
},
6667
chromeWebSecurity: false,
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
// Copyright (C) 2025 by Posit Software, PBC.
2+
3+
const WORKBENCH_BASE_URL = Cypress.env("WORKBENCH_URL");
4+
5+
describe("Workbench > Positron", { baseUrl: WORKBENCH_BASE_URL }, () => {
6+
// Each test must set this var to enable project-specific cleanup
7+
let projectDir;
8+
9+
beforeEach(() => {
10+
cy.cleanupAndRestartWorkbench();
11+
12+
cy.resetConnect();
13+
cy.setAdminCredentials();
14+
cy.visit("/");
15+
16+
cy.loginToWorkbench();
17+
});
18+
19+
afterEach(() => {
20+
cy.cleanupWorkbenchData(projectDir);
21+
});
22+
23+
it("Static Content Deployment", () => {
24+
projectDir = "static";
25+
26+
cy.startWorkbenchPositronPythonProject(projectDir);
27+
28+
// Publish the content
29+
cy.createPositronDeployment(
30+
projectDir,
31+
"index.html",
32+
"static",
33+
(tomlFiles) => {
34+
const config = tomlFiles.config.contents;
35+
expect(config.title).to.equal(projectDir);
36+
expect(config.type).to.equal("html");
37+
expect(config.entrypoint).to.equal("index.html");
38+
expect(config.files[0]).to.equal("/index.html");
39+
expect(config.files[1]).to.equal(
40+
`/.posit/publish/${tomlFiles.config.name}`,
41+
);
42+
expect(config.files[2]).to.equal(
43+
`/.posit/publish/deployments/${tomlFiles.contentRecord.name}`,
44+
);
45+
},
46+
).deployCurrentlySelected();
47+
});
48+
});
49+
50+
/**
51+
* Logs into Workbench using default credentials and waits for the UI to load.
52+
* @param {string} username - The username to login with (defaults to "rstudio")
53+
* @param {string} password - The password to login with (defaults to "rstudio")
54+
*/
55+
Cypress.Commands.add(
56+
"loginToWorkbench",
57+
(username = "rstudio", password = "rstudio") => {
58+
cy.log(`Logging into Workbench as ${username}`);
59+
60+
// Enter credentials and submit the form
61+
cy.get("#username").type(username);
62+
cy.get("#password").type(password);
63+
cy.get("#signinbutton").click();
64+
65+
// Wait for the main workbench UI to load
66+
cy.get("button").contains("New Session").should("be.visible");
67+
68+
cy.log("Successfully logged into Workbench");
69+
},
70+
);
71+
72+
/**
73+
* Clean up Workbench data files and directories to ensure a fresh state
74+
* This does not restart the Workbench container, so any existing sessions will still be visible in the Workbench UI but
75+
* all deployment data is removed
76+
* @param {string} projectDir - The project directory (optional). If provided, will also clean up project-specific data
77+
*/
78+
Cypress.Commands.add("cleanupWorkbenchData", (projectDir) => {
79+
cy.log("Cleaning up Workbench data");
80+
81+
// Define paths to clean up
82+
const cleanupPaths = [
83+
"content-workspace/.cache",
84+
"content-workspace/.duckdb",
85+
"content-workspace/.ipython",
86+
"content-workspace/.local",
87+
"content-workspace/.positron-server",
88+
"content-workspace/.connect-credentials",
89+
];
90+
91+
// Only add project-specific path if a projectDir was provided
92+
if (projectDir) {
93+
cleanupPaths.push(`content-workspace/${projectDir}/.posit`);
94+
}
95+
96+
cy.exec(`rm -rf ${cleanupPaths.join(" ")}`, {
97+
failOnNonZeroExit: false,
98+
}).then((result) => {
99+
cy.log(`Cleanup directories result: exit code ${result.code}`);
100+
if (result.stderr) cy.log(`Cleanup stderr: ${result.stderr}`);
101+
});
102+
103+
// Remove workbench e2e projects
104+
cy.exec(
105+
`for dir in content-workspace/${Cypress.env("WORKBENCH_PROJECT_PREFIX")}*; do rm -rf "$dir" 2>/dev/null || true; done`,
106+
{
107+
failOnNonZeroExit: false,
108+
},
109+
).then((result) => {
110+
cy.log(`Cleanup projects result: code ${result.code}`);
111+
if (result.stderr) cy.log(`Cleanup projects stderr: ${result.stderr}`);
112+
});
113+
});
114+
115+
Cypress.Commands.add("cleanupAndRestartWorkbench", (projectDir) => {
116+
// Stop and remove the container
117+
cy.log("Stopping and removing Workbench container");
118+
cy.exec(`just remove-workbench release`, {
119+
failOnNonZeroExit: false,
120+
timeout: 10_000,
121+
}).then((result) => {
122+
cy.log(`Remove workbench result: exit code ${result.code}`);
123+
if (result.stdout)
124+
cy.log(
125+
`Remove workbench stdout: ${result.stdout.substring(0, 500)}${result.stdout.length > 500 ? "..." : ""}`,
126+
);
127+
if (result.stderr)
128+
cy.log(
129+
`Remove workbench stderr: ${result.stderr.substring(0, 500)}${result.stderr.length > 500 ? "..." : ""}`,
130+
);
131+
});
132+
133+
// Clean up the workspace data
134+
cy.cleanupWorkbenchData(projectDir);
135+
136+
// Start a fresh container, the justfile command includes a health check wait
137+
cy.log("Starting fresh Workbench container");
138+
cy.exec(`just start-workbench release`, {
139+
failOnNonZeroExit: false,
140+
timeout: 60_000, // Should match the timeout in justfile
141+
}).then((result) => {
142+
cy.log(`Start workbench result: exit code ${result.code}`);
143+
if (result.stdout)
144+
cy.log(
145+
`Start workbench stdout: ${result.stdout.substring(0, 500)}${result.stdout.length > 500 ? "..." : ""}`,
146+
);
147+
if (result.stderr)
148+
cy.log(
149+
`Start workbench stderr: ${result.stderr.substring(0, 500)}${result.stderr.length > 500 ? "..." : ""}`,
150+
);
151+
});
152+
153+
cy.log("Workbench container started and ready with clean data");
154+
});
155+
156+
/**
157+
* Starts a Positron session in Workbench and waits for it to be ready
158+
* This includes waiting for all necessary UI elements and session initialization
159+
*/
160+
Cypress.Commands.add("startWorkbenchPositronSession", () => {
161+
cy.log("Starting Workbench Positron session");
162+
163+
// Start a Positron session
164+
cy.get("#newSessionBtn").click();
165+
cy.get("button").contains("Positron").click();
166+
cy.get("button").contains("Start Session").click();
167+
168+
// Wait for the session to start, a new session usually takes ~30s
169+
cy.contains(/Welcome (?:.* )?Positron/, { timeout: 60_000 }).should(
170+
"be.visible",
171+
);
172+
// Wait for the interpreter button to load at the top right
173+
cy.get('[aria-label="Select Interpreter Session"]', {
174+
timeout: 30_000,
175+
}).should("be.visible");
176+
177+
cy.log("Positron session started and ready");
178+
});
179+
180+
/**
181+
* Starts a Positron session in Workbench and creates a new Python project
182+
* This includes waiting for all necessary UI elements and session initialization
183+
*
184+
* @returns {string} The name of the created Python project
185+
*/
186+
Cypress.Commands.add("startWorkbenchPositronPythonProject", () => {
187+
cy.log("Starting Workbench Positron session and creating Python project");
188+
189+
// Start a Positron session
190+
cy.startWorkbenchPositronSession();
191+
192+
// Start a new Python project
193+
cy.get("button").contains("New").click();
194+
cy.get("li").contains("New Project...").focus();
195+
cy.get("li").contains("New Project...").click();
196+
cy.contains("Create New Project").should("be.visible");
197+
cy.get("label").contains("Python Project").click();
198+
cy.get("button").contains("Next").click();
199+
200+
// Set a randomized project name
201+
cy.contains("Set project name").should("be.visible");
202+
const projectName = `${Cypress.env("WORKBENCH_PROJECT_PREFIX")}${Math.random().toString(36).slice(2, 10)}`;
203+
cy.contains("span", "Enter a name for your new Python Project")
204+
.parent("label")
205+
.find("input")
206+
.clear();
207+
cy.contains("span", "Enter a name for your new Python Project")
208+
.parent("label")
209+
.find("input")
210+
.type(projectName);
211+
cy.get("button").contains("Next").click();
212+
213+
// Set up the Python environment
214+
cy.contains("Set up Python").should("be.visible");
215+
// Wait for the environment providers to load
216+
cy.get(".positron-button.drop-down-list-box", { timeout: 10_000 })
217+
.should("be.enabled")
218+
.should("contain", "Venv")
219+
.find(".dropdown-entry-title")
220+
.should("contain", "Venv")
221+
.and("be.visible");
222+
cy.get(".ok-cancel-back-action-bar")
223+
.find("button")
224+
.contains("Create")
225+
.click();
226+
cy.contains("Where would you like to").should("be.visible");
227+
cy.get("button").contains("Current Window").click();
228+
229+
// Wait for the project to open and initialize extensions
230+
cy.get(`[aria-label="Explorer Section: ${projectName}"]`).should(
231+
"be.visible",
232+
);
233+
cy.get('[aria-label="Activating Extensions..."]', {
234+
timeout: 30_000,
235+
}).should("be.visible");
236+
cy.get('[aria-label="Activating Extensions..."]', {
237+
timeout: 30_000,
238+
}).should("not.exist");
239+
// Wait for the interpreter button to load at the top right
240+
cy.get('[aria-label="Select Interpreter Session"]', {
241+
timeout: 30_000,
242+
}).should("be.visible");
243+
244+
// TODO need some other command to wait for more stuff in the IDE before proceeding
245+
// Observed a session failed to start and prevented the rest of the test from working
246+
247+
cy.log(`Successfully created Python project: ${projectName}`);
248+
});
249+
250+
Cypress.Commands.add(
251+
"createPositronDeployment",
252+
(
253+
projectDir, // string
254+
entrypointFile, // string
255+
title, // string
256+
verifyTomlCallback, // func({config: { filename: string, contents: {},}, contentRecord: { filename: string, contents: {}})
257+
) => {
258+
// Temporarily ignore uncaught exception due to a vscode worker being cancelled at some point
259+
cy.on("uncaught:exception", () => false);
260+
261+
// Open the entrypoint ahead of time for easier selection later
262+
// expand the subdirectory
263+
if (projectDir !== ".") {
264+
// Open the folder with our content
265+
cy.get("button").contains("Open Folder...").click();
266+
cy.get(".quick-input-widget").within(() => {
267+
cy.get(".quick-input-box input").should("be.visible");
268+
cy.get('.monaco-list-row[aria-label="static"]').click();
269+
cy.get(".quick-input-header a[role='button']").contains("OK").click();
270+
});
271+
}
272+
273+
// open the entrypoint file
274+
cy.get(".explorer-viewlet")
275+
.find(`[aria-label="${entrypointFile}"]`)
276+
.should("be.visible")
277+
.dblclick();
278+
279+
// confirm that the file got opened in a tab
280+
cy.get(".tabs-container")
281+
.find(`[aria-label="${entrypointFile}"]`)
282+
.should("be.visible");
283+
284+
// activate the publisher extension
285+
// Open Publisher, due to viewport size it is buried in the "..." menu
286+
cy.get(
287+
'[id="workbench.parts.activitybar"] .action-item[role="button"][aria-label="Additional Views"]',
288+
{
289+
timeout: 30_000,
290+
},
291+
).click();
292+
// Wait for popup menu to appear
293+
cy.get('.monaco-menu .actions-container[role="menu"]')
294+
.should("be.visible")
295+
.within(() => {
296+
// Finally, double-click the Posit Publisher menu item
297+
cy.findByLabelText("Posit Publisher").dblclick();
298+
});
299+
300+
// Small wait to allow the UI to settle in CI before proceeding
301+
// eslint-disable-next-line cypress/no-unnecessary-waiting
302+
cy.wait(1000);
303+
304+
// Create a new deployment via the select-deployment button
305+
cy.publisherWebview()
306+
.findByTestId("select-deployment")
307+
.then((dplyPicker) => {
308+
Cypress.$(dplyPicker).trigger("click");
309+
});
310+
311+
// Ux displayed via quick input
312+
// This has taken longer than 4 seconds on some laptops, so we're increasing the timeout
313+
cy.get(".quick-input-widget", { timeout: 10_000 }).should("be.visible");
314+
315+
// Confirm we've got the correct sequence
316+
cy.get(".quick-input-titlebar").should("have.text", "Select Deployment");
317+
318+
// Create a new deployment
319+
cy.get(".quick-input-list").contains("Create a New Deployment").click();
320+
321+
// prompt for select entrypoint
322+
// Note this deviates from the VS Code logic and does not currently handle projectDir = "."
323+
const targetLabel = `${entrypointFile}, Open Files`;
324+
cy.get(".quick-input-widget")
325+
.should("contain.text", "Select a file as your entrypoint")
326+
.find(`[aria-label="${targetLabel}"]`)
327+
.click();
328+
329+
cy.get(".quick-input-widget")
330+
.should("contain.text", "Create a New Deployment")
331+
.find("input")
332+
.type(`${title}` + "{enter}");
333+
334+
cy.get(".quick-input-widget")
335+
.should("contain.text", "Please provide the Posit Connect server's URL")
336+
.find("input")
337+
.type("http://connect-publisher-e2e:3939" + "{enter}");
338+
339+
cy.get(".quick-input-widget")
340+
.should("contain.text", "The API key")
341+
.find("input")
342+
.type(Cypress.env("BOOTSTRAP_ADMIN_API_KEY") + "{enter}");
343+
344+
cy.get(".quick-input-widget")
345+
.should("contain.text", "Enter a unique nickname for this server")
346+
.find("input")
347+
.type("Posit Connect" + "{enter}");
348+
349+
return cy
350+
.getPublisherTomlFilePaths(projectDir)
351+
.then((filePaths) => {
352+
let result = {
353+
config: {
354+
name: filePaths.config.name,
355+
path: filePaths.config.path,
356+
contents: {},
357+
},
358+
contentRecord: {
359+
name: filePaths.contentRecord.name,
360+
path: filePaths.contentRecord.path,
361+
contents: {},
362+
},
363+
};
364+
cy.loadTomlFile(filePaths.config.path)
365+
.then((config) => {
366+
result.config.contents = config;
367+
})
368+
.loadTomlFile(filePaths.contentRecord.path)
369+
.then((contentRecord) => {
370+
result.contentRecord.contents = contentRecord;
371+
})
372+
.then(() => {
373+
return result;
374+
});
375+
})
376+
.then((tomlFiles) => {
377+
return verifyTomlCallback(tomlFiles);
378+
});
379+
},
380+
);

0 commit comments

Comments
 (0)