Skip to content

Commit 4ec8d88

Browse files
committed
add cypress tests
1 parent 1e3cc46 commit 4ec8d88

4 files changed

Lines changed: 227 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
scripts/.test-token.json
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
describe('OAuth Device Flow Automation', () => {
2+
const API_BASE = 'https://api-staging.tokens.studio';
3+
const STUDIO_BASE = 'https://staging.tokens.studio';
4+
const CLIENT_ID = 'figma_plugin';
5+
const SCOPE = 'read write';
6+
const EMAIL = 'akshay@tokens.studio';
7+
const PASSWORD = 'Test@123';
8+
const SITE_PWD = 'hymahyma';
9+
10+
it('automatically authorizes a device code and saves the token', () => {
11+
// 1. Get Device Code
12+
cy.request({
13+
method: 'POST',
14+
url: `${API_BASE}/oauth/authorize_device`,
15+
form: true,
16+
body: {
17+
client_id: CLIENT_ID,
18+
scope: SCOPE
19+
}
20+
}).then((response) => {
21+
expect(response.status).to.eq(200);
22+
const { device_code, user_code, verification_uri_complete } = response.body;
23+
24+
// 2. Visit Staging
25+
cy.visit(verification_uri_complete || `${STUDIO_BASE}/device`);
26+
27+
// 3. Resilient Flow Handler
28+
let stepCount = 0;
29+
const runStep = () => {
30+
stepCount++;
31+
if (stepCount > 15) {
32+
cy.log('Too many steps, stopping.');
33+
return;
34+
}
35+
36+
cy.wait(2000);
37+
cy.get('body').then(($body) => {
38+
// A. Handle Site Password
39+
const pwdField = $body.find('#pwd');
40+
if (pwdField.length > 0 && pwdField.is(':visible')) {
41+
cy.get('#pwd').type(SITE_PWD + '{enter}');
42+
cy.then(runStep);
43+
return;
44+
}
45+
46+
// B. Handle Login
47+
const emailField = $body.find('#login-email');
48+
if (emailField.length > 0 && emailField.is(':visible')) {
49+
cy.get('#login-email').clear().type(EMAIL);
50+
cy.get('#login-password').clear().type(PASSWORD + '{enter}');
51+
cy.then(runStep);
52+
return;
53+
}
54+
55+
// C. Handle Device Code Entry
56+
const codeField = $body.find('#user_code');
57+
if (codeField.length > 0 && codeField.is(':visible')) {
58+
cy.get('#user_code').then(($el) => {
59+
if (!$el.val()) {
60+
cy.get('#user_code').type(user_code);
61+
}
62+
});
63+
cy.contains('button', 'Connect Device').click();
64+
cy.then(runStep);
65+
return;
66+
}
67+
68+
// D. Success Check
69+
if ($body.text().includes('Device connected') || $body.text().includes('Authorized')) {
70+
cy.log('Authorization successful!');
71+
return;
72+
}
73+
74+
// If no known field, just wait and retry
75+
cy.log('No known fields found, waiting...');
76+
cy.wait(2000).then(runStep);
77+
});
78+
};
79+
80+
runStep();
81+
82+
// 6. Poll for Token
83+
const pollToken = (retryCount = 0) => {
84+
if (retryCount > 60) throw new Error('Polling timed out');
85+
86+
cy.request({
87+
method: 'POST',
88+
url: `${API_BASE}/oauth/token`,
89+
form: true,
90+
body: {
91+
client_id: CLIENT_ID,
92+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
93+
device_code: device_code
94+
},
95+
failOnStatusCode: false
96+
}).then((res) => {
97+
if (res.status === 200) {
98+
cy.writeFile('scripts/.test-token.json', res.body);
99+
cy.log('Token saved!');
100+
} else {
101+
cy.log(`Poll Status: ${res.status} - ${JSON.stringify(res.body)}`);
102+
cy.wait(5000);
103+
pollToken(retryCount + 1);
104+
}
105+
});
106+
};
107+
108+
pollToken();
109+
});
110+
});
111+
});

packages/tokens-studio-for-figma/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"serve:preview": "serve preview -p 58630",
3333
"changeset": "changeset",
3434
"translate": "node ./scripts/translate.mjs",
35+
"test:api": "TOKENS_STUDIO_AUTH_TOKEN=$(node scripts/token-manager.mjs) jest src/utils/tokensStudio/__tests__/restApi.int.test.ts",
3536
"lint": "eslint . --quiet --fix",
3637
"lint:nofix": "eslint .",
3738
"storybook": "start-storybook -p 6006",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
6+
const TOKEN_FILE = path.join(__dirname, '.test-token.json');
7+
const CLIENT_ID = 'figma_plugin';
8+
const SCOPE = 'read write';
9+
const API_BASE = 'https://api-staging.tokens.studio';
10+
11+
async function validateToken(token) {
12+
try {
13+
const res = await fetch(`${API_BASE}/api/v1/organizations`, {
14+
headers: { 'Authorization': `Bearer ${token}` }
15+
});
16+
return res.ok;
17+
} catch (e) {
18+
return false;
19+
}
20+
}
21+
22+
async function runDeviceFlow() {
23+
process.stderr.write("Initiating authorization flow...\n");
24+
const initRes = await fetch(`${API_BASE}/oauth/authorize_device`, {
25+
method: 'POST',
26+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
27+
body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPE })
28+
});
29+
30+
if (!initRes.ok) {
31+
throw new Error(`Failed to start device flow: ${initRes.status}`);
32+
}
33+
34+
const authData = await initRes.json();
35+
36+
process.stderr.write(`\n\n=== 🔐 AUTH REQUIRED 🔐 ===\n`);
37+
process.stderr.write(`1. Open: ${authData.verification_uri_complete || authData.verification_uri}\n`);
38+
if (!authData.verification_uri_complete) {
39+
process.stderr.write(`2. Code: ${authData.user_code}\n`);
40+
}
41+
process.stderr.write(`===========================\n\n`);
42+
43+
const pollUrl = `${API_BASE}/oauth/token`;
44+
const intervalMs = (authData.interval || 5) * 1000;
45+
46+
while (true) {
47+
await new Promise(r => setTimeout(r, intervalMs));
48+
const tokenRes = await fetch(pollUrl, {
49+
method: 'POST',
50+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
51+
body: new URLSearchParams({
52+
client_id: CLIENT_ID,
53+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
54+
device_code: authData.device_code
55+
})
56+
});
57+
58+
if (tokenRes.ok) {
59+
const data = await tokenRes.json();
60+
return data;
61+
} else {
62+
const errorData = await tokenRes.json().catch(() => ({}));
63+
if (errorData.error === 'authorization_pending') {
64+
process.stderr.write(".");
65+
} else if (errorData.error === 'slow_down') {
66+
process.stderr.write("-");
67+
} else {
68+
throw new Error(`OAuth error: ${errorData.error}`);
69+
}
70+
}
71+
}
72+
}
73+
74+
async function runCypressAuth() {
75+
process.stderr.write("Initiating automated headless authorization via Cypress...\n");
76+
const { execSync } = await import('node:child_process');
77+
try {
78+
execSync('npx cypress run --spec cypress/e2e/auth_automation.cy.js --browser chrome --headless --config baseUrl=https://staging.tokens.studio', {
79+
cwd: path.join(__dirname, '..'),
80+
stdio: 'inherit'
81+
});
82+
83+
if (fs.existsSync(TOKEN_FILE)) {
84+
return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'));
85+
}
86+
throw new Error("Cypress finished but token file was not created.");
87+
} catch (err) {
88+
throw new Error(`Cypress auth failed: ${err.message}`);
89+
}
90+
}
91+
92+
async function main() {
93+
let cache = {};
94+
if (fs.existsSync(TOKEN_FILE)) {
95+
cache = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'));
96+
}
97+
98+
if (cache.access_token && await validateToken(cache.access_token)) {
99+
console.log(cache.access_token);
100+
return;
101+
}
102+
103+
// Token missing or invalid, run headless auth
104+
try {
105+
const newData = await runCypressAuth();
106+
process.stderr.write("\n✅ Automated Auth successful.\n");
107+
console.log(newData.access_token);
108+
} catch (err) {
109+
process.stderr.write(`\n❌ Error: ${err.message}\n`);
110+
process.exit(1);
111+
}
112+
}
113+
114+
main();

0 commit comments

Comments
 (0)