${content.summary ? `
${content.summary}
` : ''}
- ${content.joined_at ? `
Joined on
` : ''}
+ ${content.joined_at ? `
Joined on
` : ''}
${content.post_count ? `
${content.post_count} posts published
` : ''}
${content.latest_post || content.popular_post ? `
diff --git a/src/devto/user/user.shared-spec.js b/src/devto/user/user.shared-spec.js
new file mode 100644
index 0000000..479af86
--- /dev/null
+++ b/src/devto/user/user.shared-spec.js
@@ -0,0 +1,95 @@
+
+import { expect } from '@storybook/jest';
+import { within as shadowWithin } from 'shadow-dom-testing-library';
+import { formatDate } from "../helpers";
+
+/**
+ * Extract elements from an shadow DOM element
+ */
+export const getElements = async (canvasElement) => {
+ const screen = shadowWithin(canvasElement);
+ const container = await screen.findByShadowLabelText(/dev.to user profile/i);
+ const [mainLink] = await screen.queryAllByShadowRole('link');
+ const [ avatar ] = await screen.queryAllByShadowRole('img');
+ let latest_post = null;
+ let popular_post = null;
+ let postList = null;
+ const terms = await screen.queryAllByShadowRole('term');
+ if (terms.length) {
+ postList = terms[0].parentElement;
+ terms.forEach((term) => {
+ if (term.textContent === 'Latest post') {
+ latest_post = term.nextElementSibling;
+ }
+ if (term.textContent === 'Popular post') {
+ popular_post = term.nextElementSibling;
+ }
+ });
+ }
+ return {
+ screen,
+ canvasElement,
+ container,
+ error: await container?.querySelector('[itemprop="error"]'),
+ mainLink: mainLink !== undefined ? mainLink : null,
+ avatar,
+ name: await mainLink?.querySelector('[itemprop="name"]'),
+ summary: await container?.querySelector('[itemprop="description"]'),
+ joined_at: await container?.querySelector('[itemprop="startDate"]'),
+ post_count: await container?.querySelector('.post_count'),
+ postList,
+ latest_post,
+ popular_post,
+ };
+}
+
+/**
+ * Ensure elements are present and have the correct content
+ */
+export const ensureElements = async (elements, args) => {
+ await expect(elements.container).toBeInTheDocument();
+ if (args.error) {
+ await expect(elements.mainLink).not.toBeInTheDocument();
+ await expect(elements.error).toBeInTheDocument();
+ await expect(elements.error).toHaveTextContent(args.error);
+ return;
+ }
+
+ await expect(elements.error).not.toBeInTheDocument();
+ await expect(elements.mainLink).toBeInTheDocument();
+ await expect(elements.avatar).toBeInTheDocument();
+ await expect(elements.name).toBeInTheDocument();
+
+ if (args?.summary) {
+ await expect(elements.summary).toBeInTheDocument();
+ await expect(elements.summary.textContent).toEqual(args.summary);
+ } else {
+ await expect(elements.summary).not.toBeInTheDocument();
+ }
+
+ if (args?.joined_at) {
+ await expect(elements.joined_at).toBeInTheDocument();
+ await expect(elements.joined_at).toHaveAttribute('datetime', formatDate(args.joined_at));
+ await expect(elements.joined_at.textContent).toContain(args.joined_at);
+ } else {
+ await expect(elements.joined_at).not.toBeInTheDocument();
+ }
+
+ if (args?.post_count) {
+ await expect(elements.post_count).toBeInTheDocument();
+ await expect(elements.post_count.textContent).toContain(`${args.post_count} posts published`);
+ } else {
+ await expect(elements.post_count).not.toBeInTheDocument();
+ }
+
+ if (args?.latest_post) {
+ await expect(elements.latest_post).toBeInTheDocument();
+ } else {
+ await expect(elements.latest_post).not.toBeInTheDocument();
+ }
+ if (args?.popular_post) {
+ await expect(elements.popular_post).toBeInTheDocument();
+ } else {
+ await expect(elements.popular_post).not.toBeInTheDocument();
+ }
+}
\ No newline at end of file
diff --git a/src/devto/user/user.stories.js b/src/devto/user/user.stories.js
index 2dbf0eb..c490bb2 100644
--- a/src/devto/user/user.stories.js
+++ b/src/devto/user/user.stories.js
@@ -5,6 +5,7 @@ import { parseFetchedPost } from '../post/content';
import { default as userScottnath } from '../fixtures/generated/user--scottnath.json';
import { default as postDependabot } from '../fixtures/generated/post--dependabot.json';
import { default as postBugfix } from '../fixtures/generated/post--bugfix-multi-vite.json';
+import { getElements, ensureElements } from './user.shared-spec';
import './index.js';
@@ -26,10 +27,10 @@ export const User = {
args: {
...parseFetchedUser(userScottnath),
},
- // play: async ({ args, canvasElement, step }) => {
- // const elements = await getElements(canvasElement);
- // await ensureElements(elements, args);
- // }
+ play: async ({ args, canvasElement, step }) => {
+ const elements = await getElements(canvasElement);
+ await ensureElements(elements, args);
+ }
}
export const UserPosts = {
@@ -37,7 +38,8 @@ export const UserPosts = {
...User.args,
latest_post: stringify(parseFetchedPost(postDependabot)),
popular_post: stringify(parseFetchedPost(postBugfix)),
- }
+ },
+ play: User.play,
}
export const OnlyRequired = {
@@ -45,6 +47,7 @@ export const OnlyRequired = {
username: userScottnath.username,
name: userScottnath.name,
},
+ play: User.play,
}
export const Fetch = {
@@ -52,6 +55,23 @@ export const Fetch = {
username: userScottnath.username,
fetch: true,
},
+ parameters: {
+ mockData: [
+ generateMockResponse(userScottnath, 'users'),
+ generateMockResponse([postDependabot, postBugfix], 'articles'),
+ ]
+ },
+ play: async ({ args, canvasElement, step }) => {
+ const elements = await getElements(canvasElement);
+ const argsAfterFetch = {
+ ...parseFetchedUser(userScottnath),
+ ...args,
+ post_count: 2,
+ latest_post: parseFetchedPost(postDependabot),
+ popular_post: parseFetchedPost(postBugfix),
+ };
+ await ensureElements(elements, argsAfterFetch);
+ }
}
export const FetchWithoutPosts = {
@@ -59,6 +79,23 @@ export const FetchWithoutPosts = {
username: userScottnath.username,
fetch: 'no-posts',
},
+ parameters: {
+ mockData: [
+ generateMockResponse(userScottnath, 'users'),
+ generateMockResponse([postDependabot, postBugfix], 'articles'),
+ ]
+ },
+ play: async ({ args, canvasElement, step }) => {
+ const elements = await getElements(canvasElement);
+ const argsAfterFetch = {
+ ...parseFetchedUser(userScottnath),
+ ...args,
+ post_count: 2,
+ latest_post: null,
+ popular_post: null,
+ };
+ await ensureElements(elements, argsAfterFetch);
+ }
}
export const FetchOverides = {
@@ -72,15 +109,36 @@ export const FetchOverides = {
post_count: 1000000,
popular_post: stringify({
title: 'Meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow',
- url: 'http://example.com',
- cover_image: 'cat-1000-420.jpeg'
+ cover_image: 'cat-1000-420.jpeg',
}),
latest_post: stringify({
title: 'Mess? Make your human blame the dog',
- url: 'http://example.com',
cover_image: 'cat-glasses-1000-420.jpeg'
}),
},
+ parameters: {
+ mockData: [
+ generateMockResponse(userScottnath, 'users'),
+ generateMockResponse([postDependabot, postBugfix], 'articles'),
+ ]
+ },
+ play: async ({ args, canvasElement, step }) => {
+ const elements = await getElements(canvasElement);
+
+ const argsAfterFetch = {
+ ...parseFetchedUser(userScottnath),
+ ...args,
+ latest_post: {
+ ...parseFetchedPost(postDependabot),
+ ...parseify(args.latest_post),
+ },
+ popular_post: {
+ ...parseFetchedPost(postBugfix),
+ ...parseify(args.popular_post),
+ },
+ };
+ await ensureElements(elements, argsAfterFetch);
+ }
}
export const FetchError = {
@@ -93,6 +151,14 @@ export const FetchError = {
generateMockResponse({username: 'not-a-real-user'}, 'users', 404),
]
},
+ play: async ({ args, canvasElement, step }) => {
+ const elements = await getElements(canvasElement);
+ const argsAfterFetch = {
+ ...args,
+ error: 'Fetch Error: User "not-a-real-user" not found'
+ };
+ await ensureElements(elements, argsAfterFetch);
+ }
}
export const ContainerCheck = {
diff --git a/src/devto/user/user.test.js b/src/devto/user/user.test.js
index 35a54af..36ad4c4 100644
--- a/src/devto/user/user.test.js
+++ b/src/devto/user/user.test.js
@@ -1,22 +1,27 @@
import { describe, it } from 'node:test';
import assert from 'node:assert'
-import { fetchUser } from './content.js';
+import * as content from './content.js';
+import { parseFetchedPost } from '../post/content.js';
import { generateMockResponse } from '../helpers/testing.js';
+import { stringify } from '../../utils/index.js';
import { default as userScottnath } from '../fixtures/generated/user--scottnath.json' assert { type: 'json' };
+import { default as postBugfix } from '../fixtures/generated/post--bugfix-multi-vite.json' assert { type: 'json' };
+import { default as postDependabot } from '../fixtures/generated/post--dependabot.json' assert { type: 'json' };
+
describe('fetchUser', () => {
it('Should accept a user id and return a response', async (t) => {
const fn = t.mock.method(global, 'fetch');
const mockRes = {
- json: () => generateMockResponse(userScottnath, 'users'),
+ json: () => generateMockResponse(userScottnath, 'users').response,
};
fn.mock.mockImplementationOnce(() =>
Promise.resolve(mockRes)
)
- const res = await fetchUser(null, userScottnath.id);
+ const res = await content.fetchUser(null, userScottnath.id);
assert.deepEqual(res, userScottnath);
assert.strictEqual(fn.mock.calls[0].arguments[0], `https://dev.to/api/users/${userScottnath.id}`);
})
@@ -24,19 +29,19 @@ describe('fetchUser', () => {
const noId = {...userScottnath, id: undefined};
const fn = t.mock.method(global, 'fetch');
const mockRes = {
- json: () => generateMockResponse(noId, 'users'),
+ json: () => generateMockResponse(noId, 'users').response,
};
fn.mock.mockImplementationOnce(() =>
Promise.resolve(mockRes)
)
- const res = await fetchUser(noId.username);
+ const res = await content.fetchUser(noId.username);
assert.deepEqual(res, noId);
assert.strictEqual(fn.mock.calls[0].arguments[0], `https://dev.to/api/users/by_username?url=${noId.username}`);
})
it('Should handle missing user', async (t) => {
const fn = t.mock.method(global, 'fetch');
- const mockContent = generateMockResponse({username: 'not-a-real-user'}, 'users', 404);
+ const mockContent = generateMockResponse({username: 'not-a-real-user'}, 'users', 404).response;
const mockRes = {
json: () => mockContent,
};
@@ -44,8 +49,189 @@ describe('fetchUser', () => {
Promise.resolve(mockRes)
)
- const res = await fetchUser('not-a-real-user');
- assert.deepEqual(res.response, mockContent.response);
+ const res = await content.fetchUser('not-a-real-user');
+ assert.deepEqual(res, mockContent);
assert.strictEqual(fn.mock.calls[0].arguments[0], `https://dev.to/api/users/by_username?url=not-a-real-user`);
});
-})
\ No newline at end of file
+})
+describe('parseFetchedUser', () => {
+ it('Should parse a user', () => {
+ const testUser = userScottnath;
+ assert.deepEqual(content.parseFetchedUser(testUser), {
+ username: testUser.username,
+ name: testUser.name,
+ summary: testUser.summary,
+ joined_at: testUser.joined_at,
+ profile_image: testUser.profile_image,
+ });
+ })
+ it('Should require a username', () => {
+ const testUser = userScottnath;
+ assert.deepEqual(content.parseFetchedUser({
+ ...testUser,
+ username: ''
+ }), {
+ ...testUser,
+ name: testUser.name,
+ summary: testUser.summary,
+ joined_at: testUser.joined_at,
+ profile_image: testUser.profile_image,
+ username: '',
+ error: 'Username is required',
+ });
+ })
+})
+
+describe('parsePostString', () => {
+ it('Should parse a stringified post', () => {
+ const testString = JSON.stringify(postBugfix);
+ assert.deepEqual(content.parsePostString(testString), postBugfix);
+ });
+ it('Should fail gracefully', () => {
+ assert.deepEqual(content.parsePostString(postBugfix), postBugfix);
+ assert.deepEqual(content.parsePostString('["postBugfix`]'), {});
+ });
+});
+
+describe('cleanUserContent', () => {
+ it('Adjusts for missing items', () => {
+ const cleaned = content.cleanUserContent({username: 'meow'});
+ assert.ok(cleaned.profile_image.includes('data:image/png'));
+ assert.equal(cleaned.name, '@meow');
+ assert.equal(cleaned.latest_post, undefined);
+ assert.equal(cleaned.popular_post, undefined);
+ });
+ it('Parses post strings', () => {
+ const cleaned = content.cleanUserContent({
+ username: 'meow',
+ latest_post: JSON.stringify(parseFetchedPost(postBugfix)),
+ popular_post: JSON.stringify(parseFetchedPost(postDependabot)),
+ });
+ assert.deepEqual(cleaned.latest_post, parseFetchedPost(postBugfix));
+ assert.deepEqual(cleaned.popular_post, parseFetchedPost(postDependabot));
+ })
+ it('Does not allow duplicate posts', () => {
+ const cleaned = content.cleanUserContent({
+ username: 'meow',
+ latest_post: JSON.stringify(parseFetchedPost(postDependabot)),
+ popular_post: JSON.stringify(parseFetchedPost(postDependabot)),
+ });
+ assert.deepEqual(cleaned.latest_post, parseFetchedPost(postDependabot));
+ assert.deepEqual(cleaned.popular_post, undefined);
+ });
+});
+
+describe('generateUserContent', () => {
+ it('Errors on missing content', async () => {
+ const res = await content.generateUserContent();
+ assert.deepEqual(res.error, 'Username is required');
+ });
+ it('Cleans without fetching', async () => {
+ const testUser = userScottnath;
+ const resp = await content.generateUserContent(testUser);
+ assert.deepEqual(resp, {
+ username: testUser.username,
+ name: testUser.name,
+ summary: testUser.summary,
+ joined_at: testUser.joined_at,
+ profile_image: testUser.profile_image,
+ });
+ });
+ it('Fetches and fails', async (t) => {
+ const fn = t.mock.method(global, 'fetch');
+ const mockContent = generateMockResponse({username: 'meow'}, 'users', 404).response;
+ const mockRes = {
+ json: () => mockContent,
+ };
+ fn.mock.mockImplementationOnce(() =>
+ Promise.resolve(mockRes)
+ )
+
+ const returned = await content.generateUserContent({username: 'meow'}, true);
+ assert.equal(returned.error, 'Fetch Error: User "meow" not found');
+ });
+ it('Fetches and cleans', async (t) => {
+ const postLatest = {
+ ...postDependabot,
+ positive_reactions_count: 10,
+ published_at: '2022-01-02T00:00:00Z'
+ };
+ const postLatestUserDefined = {
+ title: 'meow article 1',
+ };
+ const postPopular = {
+ ...postBugfix,
+ positive_reactions_count: 20,
+ published_at: '1972-01-01T00:00:00Z'
+ };
+ const postPopularUserDefined = {
+ title: 'meow article 2',
+ };
+ const testUser = userScottnath;
+ const expected = {
+ username: testUser.username,
+ name: testUser.name,
+ summary: testUser.summary,
+ joined_at: testUser.joined_at,
+ profile_image: testUser.profile_image,
+ post_count: 2,
+ popular_post: {
+ ...parseFetchedPost(postPopular),
+ ...postPopularUserDefined
+ },
+ latest_post: {
+ ...parseFetchedPost(postLatest),
+ ...postLatestUserDefined
+ },
+ }
+ const fn = t.mock.method(global,'fetch');
+ const mockResUser = {
+ json: () => generateMockResponse(testUser, 'users').response,
+ };
+ const mockResPosts = {
+ json: () => generateMockResponse([postLatest, postPopular], 'articles').response,
+ };
+ fn.mock.mockImplementationOnce(async () => mockResUser, 0)
+ fn.mock.mockImplementationOnce(async () => mockResPosts, 1)
+
+ const returned = await content.generateUserContent({
+ username: testUser.username,
+ latest_post: stringify(postLatestUserDefined),
+ popular_post: stringify(postPopularUserDefined),
+ }, true);
+ assert.deepEqual(returned, expected);
+ });
+ it('Fetches without posts', async (t) => {
+ const postLatest = {
+ ...postDependabot,
+ positive_reactions_count: 10,
+ published_at: '2022-01-02T00:00:00Z'
+ };
+ const postPopular = {
+ ...postBugfix,
+ positive_reactions_count: 20,
+ published_at: '1972-01-01T00:00:00Z'
+ };
+ const testUser = userScottnath;
+ const expected = {
+ username: testUser.username,
+ name: testUser.name,
+ summary: testUser.summary,
+ joined_at: testUser.joined_at,
+ profile_image: testUser.profile_image,
+ post_count: 2,
+ }
+ const fn = t.mock.method(global,'fetch');
+ const mockResUser = {
+ json: () => generateMockResponse(testUser, 'users').response,
+ };
+ const mockResPosts = {
+ json: () => generateMockResponse([postLatest, postPopular], 'articles').response,
+ };
+ fn.mock.mockImplementationOnce(async () => mockResUser, 0)
+ fn.mock.mockImplementationOnce(async () => mockResPosts, 1)
+
+ const returned = await content.generateUserContent({username: testUser.username}, 'no-posts');
+ assert.deepEqual(returned, expected);
+ });
+});
\ No newline at end of file
diff --git a/src/github/styles/vars-global.css b/src/github/styles/vars-global.css
index d099a5b..890a579 100644
--- a/src/github/styles/vars-global.css
+++ b/src/github/styles/vars-global.css
@@ -67,7 +67,7 @@ address {
}
.sr-only {
- clip: rect(1px, 1px, 1px, 1px);
+ clip: rect(0px, 0px, 0px, 0px);
clip-path: inset(50%);
height: 1px;
width: 1px;
@@ -75,6 +75,7 @@ address {
overflow: hidden;
padding: 0;
position: absolute;
+ white-space: nowrap;
}
section[itemscope],
diff --git a/src/github/user/content.js b/src/github/user/content.js
index 445212c..d48a6e6 100644
--- a/src/github/user/content.js
+++ b/src/github/user/content.js
@@ -1,4 +1,5 @@
import { generateRepoContent } from '../repository/content.js';
+import { parseify } from '../../utils/index.js';
/** @ignore */
const githubApi = 'https://api.github.com';
@@ -78,7 +79,7 @@ export const parseReposString = (reposStr, owner) => {
if (typeof reposStr !== 'string') return reposStr;
let repos = [];
try {
- repos = JSON.parse(reposStr);
+ repos = parseify(reposStr);
} catch (error) {
console.error(error);
return [];
diff --git a/src/github/user/user.shared-spec.js b/src/github/user/user.shared-spec.js
index 1da96e8..132ad61 100644
--- a/src/github/user/user.shared-spec.js
+++ b/src/github/user/user.shared-spec.js
@@ -51,7 +51,7 @@ export const ensureElements = async (elements, args) => {
await expect(elements.avatar).toBeTruthy();
await expect(elements.name).toBeTruthy();
await expect(elements.login).toBeTruthy();
- console.log(args)
+
if (args?.bio) {
await expect(elements.bio).toBeTruthy();
await expect(elements.bio).toHaveTextContent(args.bio);
@@ -73,7 +73,7 @@ export const ensureElements = async (elements, args) => {
if (args?.repos) {
let reps = [];
try {
- reps = JSON.parse(args.repos.replace(/"/g, "\""));
+ reps = parseify(args.repos);
await expect(elements.repos).toHaveLength(reps.length);
} catch (error) {
await expect(elements.repos).toHaveLength(0);
diff --git a/src/github/user/user.test.js b/src/github/user/user.test.js
index 67aa418..9296444 100644
--- a/src/github/user/user.test.js
+++ b/src/github/user/user.test.js
@@ -1,10 +1,10 @@
import { describe, it } from 'node:test';
import assert from 'node:assert'
+import { stringify } from '../../utils/index.js';
import { generateMockResponse } from '../helpers/testing.js';
import { fetchUser, parseFetchedUser, parseReposString, cleanUserContent, generateUserContent } from './content.js';
import { default as repoFreeCodeCamp } from '../fixtures/generated/repo--freeCodeCamp-freeCodeCamp.json' assert { type: 'json' };
-
import { default as userScottnath } from '../fixtures/generated/user--scottnath.json' assert { type: 'json' };
import { default as userSindresorhus } from '../fixtures/generated/user--sindresorhus.json' assert { type: 'json' };
@@ -56,7 +56,7 @@ describe('parseFetchedUser', () => {
describe('parseReposString', () => {
it('Should parse a string of repos full_names', () => {
const testRepos = ['meow/purr', 'woof/sniff'];
- const testString = JSON.stringify(testRepos);
+ const testString = stringify(testRepos);
const expected = testRepos.map(repo => {
return {
full_name: repo,
@@ -67,7 +67,7 @@ describe('parseReposString', () => {
});
it('Should parse a string of repos names, adding owner, or fail gracefully', () => {
const testRepos = ['purr', 'sniff'];
- const testString = JSON.stringify(testRepos);
+ const testString = stringify(testRepos);
const expected = testRepos.map(repo => {
return {
full_name: `meow/${repo}`,
@@ -97,7 +97,7 @@ describe('cleanUserContent', () => {
})
it('Should convert a string of repos to an array', () => {
const testRepos = ['meow/purr'];
- const testString = JSON.stringify(testRepos);
+ const testString = stringify(testRepos);
const expected = testRepos.map(repo => {
return {
full_name: repo,
@@ -148,7 +148,7 @@ describe('generateUserContent', () => {
followers: 0,
username: userSindresorhus.login,
login: undefined,
- repos: JSON.stringify([testRepo.full_name]),
+ repos: stringify([testRepo.full_name]),
};
const expected = {
login: userSindresorhus.login,
diff --git a/src/mastodon/typedefs.js b/src/mastodon/typedefs.js
new file mode 100644
index 0000000..c8d171a
--- /dev/null
+++ b/src/mastodon/typedefs.js
@@ -0,0 +1,30 @@
+
+
+/**
+ * @typedef {Object} MastodonAccount
+ * @property {string} id - The account id.
+ * @property {string} username - The username of the account, not including domain.
+ * @property {string} acct - The Webfinger account URI. Equal to username for local users, or username@domain for remote users.
+ * @property {string} url - The location of the user’s profile page.
+ * @property {string} display_name - The profile’s display name.
+ * @property {string} note - The profile’s bio or description.
+ * @property {string} avatar - An image icon that is shown next to statuses and in the profile.
+ * @property {string} avatar_static - A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF.
+ * @property {string} header - An image banner that is shown above the profile and in profile cards.
+ * @property {string} header_static - A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF.
+ * @property {boolean} locked - Whether the account manually approves follow requests.
+ * @property {Array